Просмотр исходного кода

feat: grouped songs and queue position indicator

    * feat: queue position indicator
    * fix: deselect album when searching for artists
    * feat: scroll to next song when queueing a song
    * chore: consistent lint/test commands on backend / frontend
    * feat: group songs by album
    * chore: moved queries to constants file
    * chore: moved fixtures to separate file
    * feat: don't show track number when there isn't one
Fela Maslen 5 лет назад
Родитель
Сommit
28a2fb8cd1

+ 2 - 2
Jenkinsfile

@@ -32,7 +32,7 @@ node {
           stage('Lint') {
           stage('Lint') {
             parallel([
             parallel([
               "gmus-web:lint": {
               "gmus-web:lint": {
-                sh "docker run -e 'CI=1' --rm docker.fela.space/gmus-web-builder:latest sh -c 'yarn lint'"
+                sh "docker run -e 'CI=1' --rm docker.fela.space/gmus-web-builder:latest sh -c 'make lint'"
               },
               },
               "gmus-backend:lint": {
               "gmus-backend:lint": {
                 sh "docker run --rm ${IMAGE_BACKEND} sh -c 'make lint'"
                 sh "docker run --rm ${IMAGE_BACKEND} sh -c 'make lint'"
@@ -43,7 +43,7 @@ node {
           stage('Test') {
           stage('Test') {
             parallel([
             parallel([
               "gmus-web:unit tests": {
               "gmus-web:unit tests": {
-                sh "docker run --rm -e 'CI=1' -e 'REACT_APP_API_URL=http://my-api.url:1234' docker.fela.space/gmus-web-builder:latest sh -c 'yarn test'"
+                sh "docker run --rm -e 'CI=1' -e 'REACT_APP_API_URL=http://my-api.url:1234' docker.fela.space/gmus-web-builder:latest sh -c 'make test'"
               },
               },
               "gmus-backend:tests": {
               "gmus-backend:tests": {
                 sh "docker run --rm --link ${pg.id}:db --link ${redis.id}:redis ${IMAGE_BACKEND} sh -c 'make test.ci'"
                 sh "docker run --rm --link ${pg.id}:db --link ${redis.id}:redis ${IMAGE_BACKEND} sh -c 'make test.ci'"

+ 2 - 88
gmus-backend/pkg/repository/player.go

@@ -10,50 +10,7 @@ import (
 func GetNextSong(db *sqlx.DB, prevSongId int) (nextSong *read.Song, err error) {
 func GetNextSong(db *sqlx.DB, prevSongId int) (nextSong *read.Song, err error) {
 	nextSong = &read.Song{}
 	nextSong = &read.Song{}
 	err = db.QueryRowx(
 	err = db.QueryRowx(
-		`
-select
-  s1.id
-  ,s1.track_number
-  ,s1.title
-  ,s1.artist
-  ,s1.album
-  ,s1.duration
-
-from (
-  select * from songs where id = $1
-) s0
-
-left join songs s1 on (
-  s1.artist > s0.artist
-  or (s1.artist = s0.artist
-    and s1.album > s0.album
-  )
-  or (s1.artist = s0.artist
-    and s1.album = s0.album
-    and s1.track_number > s0.track_number
-  )
-  or (s1.artist = s0.artist
-    and s1.album = s0.album
-    and s1.track_number = s0.track_number
-    and s1.title > s0.title
-  )
-  or (s1.artist = s0.artist
-    and s1.album = s0.album
-    and s1.track_number = s0.track_number
-    and s1.title = s0.title
-    and s1.id > s0.id
-  )
-)
-
-order by
-  s1.artist
-  ,s1.album
-  ,s1.track_number
-  ,s1.title
-  ,s1.id
-
-limit 1
-    `,
+		querySelectNextSong,
 		prevSongId,
 		prevSongId,
 	).StructScan(nextSong)
 	).StructScan(nextSong)
 	if err != nil && err == sql.ErrNoRows {
 	if err != nil && err == sql.ErrNoRows {
@@ -66,50 +23,7 @@ limit 1
 func GetPrevSong(db *sqlx.DB, nextSongId int) (prevSong *read.Song, err error) {
 func GetPrevSong(db *sqlx.DB, nextSongId int) (prevSong *read.Song, err error) {
 	prevSong = &read.Song{}
 	prevSong = &read.Song{}
 	err = db.QueryRowx(
 	err = db.QueryRowx(
-		`
-select
-  s1.id
-  ,s1.track_number
-  ,s1.title
-  ,s1.artist
-  ,s1.album
-  ,s1.duration
-
-from (
-  select * from songs where id = $1
-) s0
-
-left join songs s1 on (
-  s1.artist < s0.artist
-  or (s1.artist = s0.artist
-    and s1.album < s0.album
-  )
-  or (s1.artist = s0.artist
-    and s1.album = s0.album
-    and s1.track_number < s0.track_number
-  )
-  or (s1.artist = s0.artist
-    and s1.album = s0.album
-    and s1.track_number = s0.track_number
-    and s1.title < s0.title
-  )
-  or (s1.artist = s0.artist
-    and s1.album = s0.album
-    and s1.track_number = s0.track_number
-    and s1.title = s0.title
-    and s1.id < s0.id
-  )
-)
-
-order by
-  s1.artist desc
-  ,s1.album desc
-  ,s1.track_number desc
-  ,s1.title desc
-  ,s1.id desc
-
-limit 1
-    `,
+		querySelectPrevSong,
 		nextSongId,
 		nextSongId,
 	).StructScan(prevSong)
 	).StructScan(prevSong)
 	if err != nil && err == sql.ErrNoRows {
 	if err != nil && err == sql.ErrNoRows {

+ 171 - 0
gmus-backend/pkg/repository/queries.go

@@ -0,0 +1,171 @@
+package repository
+
+const querySelectSongById = `
+select
+  id
+  ,track_number
+  ,title
+  ,artist
+  ,album
+  ,duration
+  ,modified_date
+  ,base_path
+  ,relative_path
+from songs
+where id = ANY($1)
+`
+
+const querySelectArtistsOrdered = `
+select distinct artist
+from songs
+order by artist
+limit $1
+offset $2
+`
+
+const queryCountArtists = `
+select count(*) as count from (
+  select distinct artist from songs
+) distinct_artists
+`
+
+const querySelectAlbumsByArtist = `
+select distinct album
+from songs
+where artist = $1
+order by album
+`
+
+const querySelectSongsByArtist = `
+select
+  id
+  ,track_number
+  ,title
+  ,artist
+  ,album
+  ,duration
+from songs
+where artist = $1
+order by album, track_number, title, id
+`
+
+const queryInsertSongs = `
+insert into songs (
+  track_number
+  ,title
+  ,artist
+  ,album
+  ,duration
+  ,modified_date
+  ,base_path
+  ,relative_path
+)
+select * from unnest(
+  $1::integer[]
+  ,$2::varchar[]
+  ,$3::varchar[]
+  ,$4::varchar[]
+  ,$5::integer[]
+  ,$6::bigint[]
+  ,$7::varchar[]
+  ,$8::varchar[]
+)
+on conflict (base_path, relative_path) do update
+set
+  track_number = excluded.track_number
+  ,title = excluded.title
+  ,artist = excluded.artist
+  ,album = excluded.album
+  ,duration = excluded.duration
+  ,modified_date = excluded.modified_date
+`
+
+const querySelectNextSong = `
+select
+  s1.id
+  ,s1.track_number
+  ,s1.title
+  ,s1.artist
+  ,s1.album
+  ,s1.duration
+
+from (
+  select * from songs where id = $1
+) s0
+
+left join songs s1 on (
+  s1.artist > s0.artist
+  or (s1.artist = s0.artist
+    and s1.album > s0.album
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number > s0.track_number
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number = s0.track_number
+    and s1.title > s0.title
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number = s0.track_number
+    and s1.title = s0.title
+    and s1.id > s0.id
+  )
+)
+
+order by
+  s1.artist
+  ,s1.album
+  ,s1.track_number
+  ,s1.title
+  ,s1.id
+
+limit 1
+`
+
+const querySelectPrevSong = `
+select
+  s1.id
+  ,s1.track_number
+  ,s1.title
+  ,s1.artist
+  ,s1.album
+  ,s1.duration
+
+from (
+  select * from songs where id = $1
+) s0
+
+left join songs s1 on (
+  s1.artist < s0.artist
+  or (s1.artist = s0.artist
+    and s1.album < s0.album
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number < s0.track_number
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number = s0.track_number
+    and s1.title < s0.title
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number = s0.track_number
+    and s1.title = s0.title
+    and s1.id < s0.id
+  )
+)
+
+order by
+  s1.artist desc
+  ,s1.album desc
+  ,s1.track_number desc
+  ,s1.title desc
+  ,s1.id desc
+
+limit 1
+`

+ 24 - 0
gmus-backend/pkg/repository/queries_test.go

@@ -0,0 +1,24 @@
+package repository_test
+
+const testQueryInsertHeyJude = `
+insert into songs (track_number, title, artist, album, duration, modified_date, base_path, relative_path)
+values ($1, $2, $3, $4, $5, $6, $7, $8)
+returning id
+`
+
+const testQueryInsertTrack1 = `
+insert into songs (track_number, title, artist, album, duration, modified_date, base_path, relative_path)
+values ($1, $2, $3, $4, $5, $6, $7, $8)
+returning id
+`
+
+const testQuerySelectAllSongs = `
+select track_number, title, artist, album, duration, base_path, relative_path, modified_date
+from songs
+`
+
+const testQuerySelectSong12 = `
+select track_number, title, artist, album, duration, base_path, relative_path, modified_date
+from songs
+where relative_path in ('song1.ogg', 'song2.ogg')
+`

+ 6 - 74
gmus-backend/pkg/repository/songs.go

@@ -15,33 +15,14 @@ func SelectSong(db *sqlx.DB, ids []int) (songs *[]*read.Song, err error) {
 		idsArray = append(idsArray, int64(id))
 		idsArray = append(idsArray, int64(id))
 	}
 	}
 
 
-	err = db.Select(songs, `
-  select
-    id
-    ,track_number
-    ,title
-    ,artist
-    ,album
-    ,duration
-    ,modified_date
-    ,base_path
-    ,relative_path
-  from songs
-  where id = ANY($1)
-  `, idsArray)
+	err = db.Select(songs, querySelectSongById, idsArray)
 
 
 	return
 	return
 }
 }
 
 
 func SelectPagedArtists(db *sqlx.DB, limit int, offset int) (artists *[]string, err error) {
 func SelectPagedArtists(db *sqlx.DB, limit int, offset int) (artists *[]string, err error) {
 	artists = &[]string{}
 	artists = &[]string{}
-	err = db.Select(artists, `
-  select distinct artist
-  from songs
-  order by artist
-  limit $1
-  offset $2
-  `, limit, offset)
+	err = db.Select(artists, querySelectArtistsOrdered, limit, offset)
 	return
 	return
 }
 }
 
 
@@ -52,11 +33,7 @@ type CountRow struct {
 func SelectArtistCount(db *sqlx.DB) (count int, err error) {
 func SelectArtistCount(db *sqlx.DB) (count int, err error) {
 	var countRow CountRow
 	var countRow CountRow
 
 
-	err = db.QueryRowx(`
-  select count(*) as count from (
-    select distinct artist from songs
-  ) distinct_artists
-  `).StructScan(&countRow)
+	err = db.QueryRowx(queryCountArtists).StructScan(&countRow)
 
 
 	count = countRow.Count
 	count = countRow.Count
 
 
@@ -71,30 +48,14 @@ func SelectAllArtists(db *sqlx.DB) (artists *[]string, err error) {
 
 
 func SelectAlbumsByArtist(db *sqlx.DB, artist string) (albums *[]string, err error) {
 func SelectAlbumsByArtist(db *sqlx.DB, artist string) (albums *[]string, err error) {
 	albums = &[]string{}
 	albums = &[]string{}
-	err = db.Select(albums, `
-  select distinct album
-  from songs
-  where artist = $1
-  order by album
-  `, artist)
+	err = db.Select(albums, querySelectAlbumsByArtist, artist)
 
 
 	return
 	return
 }
 }
 
 
 func SelectSongsByArtist(db *sqlx.DB, artist string) (songs *[]*read.SongExternal, err error) {
 func SelectSongsByArtist(db *sqlx.DB, artist string) (songs *[]*read.SongExternal, err error) {
 	songs = &[]*read.SongExternal{}
 	songs = &[]*read.SongExternal{}
-	err = db.Select(songs, `
-  select
-    id
-    ,track_number
-    ,title
-    ,artist
-    ,album
-    ,duration
-  from songs
-  where artist = $1
-  order by album, track_number, title, id
-  `, artist)
+	err = db.Select(songs, querySelectSongsByArtist, artist)
 
 
 	return
 	return
 }
 }
@@ -125,36 +86,7 @@ func BatchUpsertSongs(db *sqlx.DB, batch *[BATCH_SIZE]*read.Song, batchSize int)
 	}
 	}
 
 
 	_, err := db.Exec(
 	_, err := db.Exec(
-		`
-    insert into songs (
-      track_number
-      ,title
-      ,artist
-      ,album
-      ,duration
-      ,modified_date
-      ,base_path
-      ,relative_path
-    )
-    select * from unnest(
-      $1::integer[]
-      ,$2::varchar[]
-      ,$3::varchar[]
-      ,$4::varchar[]
-      ,$5::integer[]
-      ,$6::bigint[]
-      ,$7::varchar[]
-      ,$8::varchar[]
-    )
-    on conflict (base_path, relative_path) do update
-    set
-      track_number = excluded.track_number
-      ,title = excluded.title
-      ,artist = excluded.artist
-      ,album = excluded.album
-      ,duration = excluded.duration
-      ,modified_date = excluded.modified_date
-    `,
+		queryInsertSongs,
 		trackNumbers,
 		trackNumbers,
 		titles,
 		titles,
 		artists,
 		artists,

+ 4 - 19
gmus-backend/pkg/repository/songs_test.go

@@ -23,11 +23,7 @@ var _ = Describe("songs repository", func() {
 
 
 		BeforeEach(func() {
 		BeforeEach(func() {
 			db.QueryRowx(
 			db.QueryRowx(
-				`
-	insert into songs (track_number, title, artist, album, duration, modified_date, base_path, relative_path)
-	values ($1, $2, $3, $4, $5, $6, $7, $8)
-	returning id
-	`,
+				testQueryInsertHeyJude,
 				7,
 				7,
 				"Hey Jude",
 				"Hey Jude",
 				"The Beatles",
 				"The Beatles",
@@ -39,11 +35,7 @@ var _ = Describe("songs repository", func() {
 			).Scan(&id)
 			).Scan(&id)
 
 
 			db.QueryRowx(
 			db.QueryRowx(
-				`
-	insert into songs (track_number, title, artist, album, duration, modified_date, base_path, relative_path)
-	values ($1, $2, $3, $4, $5, $6, $7, $8)
-	returning id
-	`,
+				testQueryInsertTrack1,
 				13,
 				13,
 				"Track 1",
 				"Track 1",
 				"Untitled Artist",
 				"Untitled Artist",
@@ -151,10 +143,7 @@ var _ = Describe("songs repository", func() {
 
 
 			It("should insert the batch into the database", func() {
 			It("should insert the batch into the database", func() {
 				var result []*read.Song
 				var result []*read.Song
-				db.Select(&result, `
-	select track_number, title, artist, album, duration, base_path, relative_path, modified_date
-	from songs
-	`)
+				db.Select(&result, testQuerySelectAllSongs)
 
 
 				Expect(result).To(HaveLen(2))
 				Expect(result).To(HaveLen(2))
 				Expect(songs[0]).To(BeElementOf(result))
 				Expect(songs[0]).To(BeElementOf(result))
@@ -176,11 +165,7 @@ var _ = Describe("songs repository", func() {
 				repository.BatchUpsertSongs(db, &modifiedBatch, 2)
 				repository.BatchUpsertSongs(db, &modifiedBatch, 2)
 
 
 				result = []*read.Song{}
 				result = []*read.Song{}
-				db.Select(&result, `
-  select track_number, title, artist, album, duration, base_path, relative_path, modified_date
-  from songs
-  where relative_path in ('song1.ogg', 'song2.ogg')
-	`)
+				db.Select(&result, testQuerySelectSong12)
 			})
 			})
 
 
 			It("should not create any additional rows", func() {
 			It("should not create any additional rows", func() {

+ 7 - 88
gmus-backend/pkg/server/actions_test.go

@@ -36,19 +36,7 @@ var _ = Describe("Server actions", func() {
 		Describe("state set actions", func() {
 		Describe("state set actions", func() {
 			Context("when the action is valid", func() {
 			Context("when the action is valid", func() {
 				var action server.Action
 				var action server.Action
-				json.Unmarshal([]byte(`
-{
-  "type": "STATE_SET",
-  "payload": {
-    "songId": 123,
-    "playing": true,
-    "currentTime": 94,
-    "seekTime": -1,
-    "queue": [],
-    "master": "some-master-client"
-  }
-}
-`), &action)
+				json.Unmarshal([]byte(actionStateSetValid), &action)
 				myClient := "my-client"
 				myClient := "my-client"
 				action.FromClient = &myClient
 				action.FromClient = &myClient
 
 
@@ -83,19 +71,7 @@ var _ = Describe("Server actions", func() {
 
 
 			Context("when the song ID is non-positive", func() {
 			Context("when the song ID is non-positive", func() {
 				var action server.Action
 				var action server.Action
-				json.Unmarshal([]byte(`
-{
-  "type": "STATE_SET",
-  "payload": {
-    "songId": 0,
-    "playing": true,
-    "currentTime": 94,
-    "seekTime": -1,
-    "queue": [],
-    "master": "some-master-client"
-  }
-}
-`), &action)
+				json.Unmarshal([]byte(actionStateSetIdNonPositive), &action)
 
 
 				It("should not publish a message", func() {
 				It("should not publish a message", func() {
 					err := server.PublishActionFromClient(rdb, &action)
 					err := server.PublishActionFromClient(rdb, &action)
@@ -106,19 +82,7 @@ var _ = Describe("Server actions", func() {
 
 
 			Context("when the song ID is null", func() {
 			Context("when the song ID is null", func() {
 				var action server.Action
 				var action server.Action
-				json.Unmarshal([]byte(`
-{
-  "type": "STATE_SET",
-  "payload": {
-    "songId": null,
-    "playing": false,
-    "currentTime": 0,
-    "seekTime": -1,
-    "queue": [],
-    "master": "some-master-client"
-  }
-}
-`), &action)
+				json.Unmarshal([]byte(actionStateSetSongIdNull), &action)
 
 
 				expectedAction := server.Action{
 				expectedAction := server.Action{
 					Type: server.StateSet,
 					Type: server.StateSet,
@@ -150,19 +114,7 @@ var _ = Describe("Server actions", func() {
 
 
 			Context("when the current time is negative", func() {
 			Context("when the current time is negative", func() {
 				var action server.Action
 				var action server.Action
-				json.Unmarshal([]byte(`
-{
-  "type": "STATE_SET",
-  "payload": {
-    "songId": 123,
-    "playing": false,
-    "currentTime": -32,
-    "seekTime": -1,
-    "queue": [],
-    "master": "some-master-client"
-  }
-}
-`), &action)
+				json.Unmarshal([]byte(actionStateSetCurrentTimeNegative), &action)
 
 
 				It("should not publish a message", func() {
 				It("should not publish a message", func() {
 					err := server.PublishActionFromClient(rdb, &action)
 					err := server.PublishActionFromClient(rdb, &action)
@@ -173,19 +125,7 @@ var _ = Describe("Server actions", func() {
 
 
 			Context("when the seek time is less than -1", func() {
 			Context("when the seek time is less than -1", func() {
 				var action server.Action
 				var action server.Action
-				json.Unmarshal([]byte(`
-{
-  "type": "STATE_SET",
-  "payload": {
-    "songId": 123,
-    "playing": false,
-    "currentTime": 13,
-    "seekTime": -3,
-    "queue": [],
-    "master": "some-master-client"
-  }
-}
-`), &action)
+				json.Unmarshal([]byte(actionStateSetSeekTimeTooNegative), &action)
 
 
 				It("should not publish a message", func() {
 				It("should not publish a message", func() {
 					err := server.PublishActionFromClient(rdb, &action)
 					err := server.PublishActionFromClient(rdb, &action)
@@ -196,19 +136,7 @@ var _ = Describe("Server actions", func() {
 
 
 			Context("when the master is empty", func() {
 			Context("when the master is empty", func() {
 				var action server.Action
 				var action server.Action
-				json.Unmarshal([]byte(`
-{
-  "type": "STATE_SET",
-  "payload": {
-    "songId": 123,
-    "playing": false,
-    "currentTime": 13,
-    "seekTime": -3,
-    "queue": [],
-    "master": ""
-  }
-}
-`), &action)
+				json.Unmarshal([]byte(actionStateSetMasterEmpty), &action)
 
 
 				It("should not publish a message", func() {
 				It("should not publish a message", func() {
 					err := server.PublishActionFromClient(rdb, &action)
 					err := server.PublishActionFromClient(rdb, &action)
@@ -220,16 +148,7 @@ var _ = Describe("Server actions", func() {
 
 
 		Describe("when the action is unrecognised", func() {
 		Describe("when the action is unrecognised", func() {
 			var action server.Action
 			var action server.Action
-			// CLIENT_LIST_UPDATED should only ever come from the server
-			err := json.Unmarshal([]byte(`
-{
-  "type": "CLIENT_LIST_UPDATED",
-  "payload": [
-    { "name": "client-a", "lastPing": 123 },
-    { "name": "client-b", "lastPing": 456 }
-  ]
-}
-`), &action)
+			err := json.Unmarshal([]byte(actionUnrecognised), &action)
 			if err != nil {
 			if err != nil {
 				panic(err)
 				panic(err)
 			}
 			}

+ 96 - 0
gmus-backend/pkg/server/fixtures_test.go

@@ -0,0 +1,96 @@
+package server_test
+
+const actionStateSetValid = `
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": 123,
+    "playing": true,
+    "currentTime": 94,
+    "seekTime": -1,
+    "queue": [],
+    "master": "some-master-client"
+  }
+}
+`
+
+const actionStateSetIdNonPositive = `
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": 0,
+    "playing": true,
+    "currentTime": 94,
+    "seekTime": -1,
+    "queue": [],
+    "master": "some-master-client"
+  }
+}
+`
+
+const actionStateSetSongIdNull = `
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": null,
+    "playing": false,
+    "currentTime": 0,
+    "seekTime": -1,
+    "queue": [],
+    "master": "some-master-client"
+  }
+}
+`
+
+const actionStateSetCurrentTimeNegative = `
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": 123,
+    "playing": false,
+    "currentTime": -32,
+    "seekTime": -1,
+    "queue": [],
+    "master": "some-master-client"
+  }
+}
+`
+
+const actionStateSetSeekTimeTooNegative = `
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": 123,
+    "playing": false,
+    "currentTime": 13,
+    "seekTime": -3,
+    "queue": [],
+    "master": "some-master-client"
+  }
+}
+`
+
+const actionStateSetMasterEmpty = `
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": 123,
+    "playing": false,
+    "currentTime": 13,
+    "seekTime": -3,
+    "queue": [],
+    "master": ""
+  }
+}
+`
+
+// CLIENT_LIST_UPDATED should only ever come from the server
+const actionUnrecognised = `
+{
+  "type": "CLIENT_LIST_UPDATED",
+  "payload": [
+    { "name": "client-a", "lastPing": 123 },
+    { "name": "client-b", "lastPing": 456 }
+  ]
+}
+`

+ 6 - 0
gmus-web/Makefile

@@ -2,6 +2,12 @@ NAME 	:= docker.fela.space/gmus-web
 TAG 	:= $$(git log -1 --pretty=%H)
 TAG 	:= $$(git log -1 --pretty=%H)
 IMG 	:= ${NAME}:${TAG}
 IMG 	:= ${NAME}:${TAG}
 
 
+lint:
+	yarn lint
+
+test:
+	yarn test
+
 build.docker:
 build.docker:
 	docker build -t docker.fela.space/gmus-web-builder:latest --build-arg REACT_APP_API_URL=${REACT_APP_API_URL} -f builder.Dockerfile .
 	docker build -t docker.fela.space/gmus-web-builder:latest --build-arg REACT_APP_API_URL=${REACT_APP_API_URL} -f builder.Dockerfile .
 	docker build -t ${IMG} .
 	docker build -t ${IMG} .

+ 2 - 0
gmus-web/package.json

@@ -59,6 +59,7 @@
     "@testing-library/react": "^11.1.0",
     "@testing-library/react": "^11.1.0",
     "@testing-library/user-event": "^12.1.10",
     "@testing-library/user-event": "^12.1.10",
     "@types/jest": "^26.0.15",
     "@types/jest": "^26.0.15",
+    "@types/lodash": "^4.14.165",
     "@types/nock": "^11.1.0",
     "@types/nock": "^11.1.0",
     "@types/node": "^12.0.0",
     "@types/node": "^12.0.0",
     "@types/pluralize": "^0.0.29",
     "@types/pluralize": "^0.0.29",
@@ -73,6 +74,7 @@
     "husky": "^4.3.6",
     "husky": "^4.3.6",
     "jest-websocket-mock": "^2.2.0",
     "jest-websocket-mock": "^2.2.0",
     "lint-staged": "^10.5.3",
     "lint-staged": "^10.5.3",
+    "lodash": "^4.17.20",
     "mock-socket": "^9.0.3",
     "mock-socket": "^9.0.3",
     "nanoid": "^3.1.20",
     "nanoid": "^3.1.20",
     "nock": "^13.0.5",
     "nock": "^13.0.5",

+ 9 - 0
gmus-web/src/components/ui/cmus/reducer/fixtures.ts

@@ -36,9 +36,18 @@ export const stateWithActiveArtist: CmusUIState = {
 
 
 export const stateWithActiveSong: CmusUIState = {
 export const stateWithActiveSong: CmusUIState = {
   ...stateLibrary,
   ...stateLibrary,
+  artistSongs: {
+    'My artist': [
+      { id: 1867 } as Song,
+      { id: 1870, album: 'Different album' } as Song,
+      { id: 46, album: 'My album' } as Song,
+    ],
+  },
   library: {
   library: {
     ...stateLibrary.library,
     ...stateLibrary.library,
     modeWindow: LibraryModeWindow.SongList,
     modeWindow: LibraryModeWindow.SongList,
+    activeArtist: 'My artist',
+    activeAlbum: 'My album',
     activeSongId: 1867,
     activeSongId: 1867,
   },
   },
 };
 };

+ 6 - 4
gmus-web/src/components/ui/cmus/reducer/keypress.spec.ts

@@ -77,10 +77,12 @@ describe(ActionTypeKeyPressed, () => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.E };
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.E };
 
 
     describe('when in library view', () => {
     describe('when in library view', () => {
-      it('should set global action to add the selected song to the queue', () => {
-        expect.assertions(1);
-        const result = cmusUIReducer(stateWithActiveSong, action);
-        expect(result.globalAction).toStrictEqual(queuePushed(1867));
+      describe('when in songs list mode', () => {
+        it('should set global action to add the selected song to the queue', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateWithActiveSong, action);
+          expect(result.globalAction).toStrictEqual(queuePushed(1867));
+        });
       });
       });
     });
     });
   });
   });

+ 1 - 1
gmus-web/src/components/ui/cmus/reducer/scroll.ts

@@ -4,7 +4,7 @@ import { getFilteredSongs } from '../selectors';
 import { CmusUIState, LibraryModeWindow, View } from '../types';
 import { CmusUIState, LibraryModeWindow, View } from '../types';
 import { getNextActiveArtistAndAlbum } from '../utils/scroll';
 import { getNextActiveArtistAndAlbum } from '../utils/scroll';
 
 
-const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
+export const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
   state.library.activeArtist === null
   state.library.activeArtist === null
     ? state
     ? state
     : {
     : {

+ 4 - 2
gmus-web/src/components/ui/cmus/reducer/search.spec.ts

@@ -23,7 +23,8 @@ describe('Searching', () => {
       artists: ['Amy Winehouse', 'Anticon', 'Bach'],
       artists: ['Amy Winehouse', 'Anticon', 'Bach'],
       library: {
       library: {
         ...stateSearching.library,
         ...stateSearching.library,
-        activeArtist: null,
+        activeArtist: 'Amy Winehouse',
+        activeAlbum: 'Back to Black',
         modeWindow: LibraryModeWindow.ArtistList,
         modeWindow: LibraryModeWindow.ArtistList,
         activeSongId: 883,
         activeSongId: 883,
       },
       },
@@ -49,10 +50,11 @@ describe('Searching', () => {
 
 
     describe('artists', () => {
     describe('artists', () => {
       it('should select the first match', () => {
       it('should select the first match', () => {
-        expect.assertions(2);
+        expect.assertions(3);
         const result = cmusUIReducer(stateSearchingArtists, searched('ant'));
         const result = cmusUIReducer(stateSearchingArtists, searched('ant'));
         expect(result.library.activeArtist).toBe('Anticon');
         expect(result.library.activeArtist).toBe('Anticon');
         expect(result.library.activeSongId).toBeNull();
         expect(result.library.activeSongId).toBeNull();
+        expect(result.library.activeAlbum).toBeNull();
       });
       });
 
 
       describe('when the artist has songs loaded', () => {
       describe('when the artist has songs loaded', () => {

+ 4 - 1
gmus-web/src/components/ui/cmus/reducer/search.ts

@@ -9,7 +9,10 @@ function searchForArtist(state: CmusUIState, term: string): CmusUIState {
 
 
   const activeSongId = state.artistSongs[closestArtist]?.[0]?.id ?? null;
   const activeSongId = state.artistSongs[closestArtist]?.[0]?.id ?? null;
 
 
-  return { ...state, library: { ...state.library, activeArtist: closestArtist, activeSongId } };
+  return {
+    ...state,
+    library: { ...state.library, activeArtist: closestArtist, activeAlbum: null, activeSongId },
+  };
 }
 }
 
 
 function searchForSong(state: CmusUIState, term: string): CmusUIState {
 function searchForSong(state: CmusUIState, term: string): CmusUIState {

+ 9 - 5
gmus-web/src/components/ui/cmus/utils/scroll.ts

@@ -133,13 +133,17 @@ export function getArtistAlbumScrollIndex(
 }
 }
 
 
 export function getSongScrollIndex(
 export function getSongScrollIndex(
-  filteredSongs: Pick<Song, 'id'>[],
+  filteredSongs: Pick<Song, 'id' | 'album'>[],
   activeSongId: number | null,
   activeSongId: number | null,
 ): number {
 ): number {
-  if (activeSongId === null) {
+  const songIndex = filteredSongs.findIndex(({ id }) => id === activeSongId);
+  if (songIndex === -1) {
     return -1;
     return -1;
   }
   }
-  return filteredSongs.findIndex(({ id }) => id === activeSongId);
+  const numUniqueAlbums = Array.from(
+    new Set(filteredSongs.slice(0, songIndex + 1).map(({ album }) => album)),
+  ).length;
+  return songIndex + 2 * numUniqueAlbums;
 }
 }
 
 
 export const lineHeight = 16;
 export const lineHeight = 16;
@@ -164,9 +168,9 @@ export function useAutoJumpyScroll(ref: RefObject<HTMLDivElement>, scrollIndex:
     if (linesBefore < 0 || linesAfter < 0) {
     if (linesBefore < 0 || linesAfter < 0) {
       ref.current.scrollTop = Math.max(0, (scrollIndex - 1) * lineHeight);
       ref.current.scrollTop = Math.max(0, (scrollIndex - 1) * lineHeight);
     } else if (linesAfter < scrollThresholdLines) {
     } else if (linesAfter < scrollThresholdLines) {
-      ref.current.scrollTop += lineHeight;
+      ref.current.scrollTop += (scrollThresholdLines - linesAfter) * lineHeight;
     } else if (linesBefore < scrollThresholdLines) {
     } else if (linesBefore < scrollThresholdLines) {
-      ref.current.scrollTop -= lineHeight;
+      ref.current.scrollTop -= (scrollThresholdLines - linesBefore) * lineHeight;
     }
     }
   }, [scrollIndex]);
   }, [scrollIndex]);
   /* eslint-enable react-hooks/exhaustive-deps, no-param-reassign */
   /* eslint-enable react-hooks/exhaustive-deps, no-param-reassign */

+ 39 - 0
gmus-web/src/components/ui/cmus/views/songs.styles.ts

@@ -0,0 +1,39 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { ActiveHighlightRow, FlexList } from '../styled/layout';
+import { colors } from '../styled/variables';
+
+export const Container = styled(FlexList)`
+  border-left: 1px solid ${colors.border};
+  flex: 2 0 0;
+  flex-flow: column;
+  height: 100%;
+`;
+
+export const Song = styled(ActiveHighlightRow)``;
+
+export const QueuePosition = styled.span<{ invert?: boolean }>`
+  color: ${({ invert }): string =>
+    invert ? colors.active.parentInactive : colors.title.background};
+  flex: 0 0 ${rem(16)};
+  font-weight: bold;
+`;
+
+export const Separator = styled(ActiveHighlightRow)`
+  height: ${rem(26)};
+  margin-top: ${rem(6)};
+
+  :not(:first-child) {
+    border-top: 1px dashed ${colors.selected.inactive};
+  }
+`;
+
+export const SeparatorText = styled.span`
+  box-sizing: border-box;
+  color: ${colors.selected.inactive};
+  font-style: italic;
+  height: ${rem(26)};
+  padding: ${rem(4)} 0 ${rem(6)} 0;
+  width: 100%;
+`;

+ 0 - 13
gmus-web/src/components/ui/cmus/views/songs.styles.tsx

@@ -1,13 +0,0 @@
-import styled from 'styled-components';
-
-import { ActiveHighlightRow, FlexList } from '../styled/layout';
-import { colors } from '../styled/variables';
-
-export const Container = styled(FlexList)`
-  border-left: 1px solid ${colors.border};
-  flex: 2 0 0;
-  flex-flow: column;
-  height: 100%;
-`;
-
-export const Song = styled(ActiveHighlightRow)``;

+ 80 - 18
gmus-web/src/components/ui/cmus/views/songs.tsx

@@ -1,6 +1,7 @@
-import React, { CSSProperties, useContext, useMemo, useRef } from 'react';
+import groupBy from 'lodash/groupBy';
+import React, { CSSProperties, useCallback, useContext, useMemo, useRef } from 'react';
 import AutoSizer from 'react-virtualized-auto-sizer';
 import AutoSizer from 'react-virtualized-auto-sizer';
-import { FixedSizeList as List } from 'react-window';
+import { VariableSizeList as List } from 'react-window';
 import { StateContext } from '../../../../context/state';
 import { StateContext } from '../../../../context/state';
 
 
 import { Song } from '../../../../types';
 import { Song } from '../../../../types';
@@ -22,18 +23,64 @@ type SongData = {
   active: boolean;
   active: boolean;
   parentActive: boolean;
   parentActive: boolean;
   highlight: boolean;
   highlight: boolean;
+  queuePosition: number;
 };
 };
 
 
-const itemKey = (index: number, data: SongData[]): number => data[index].song.id;
+type Separator = {
+  album: string;
+};
+
+type ItemData = (SongData | Separator) & { id: number };
+
+const isSeparator = (item: ItemData | Separator): item is Separator => !Reflect.has(item, 'song');
+
+const itemKey = (index: number, data: ItemData[]): number => data[index].id;
+
+const queueSymbols = [
+  '⑴',
+  '⑵',
+  '⑶',
+  '⑷',
+  '⑸',
+  '⑹',
+  '⑺',
+  '⑻',
+  '⑼',
+  '⑽',
+  '⑾',
+  '⑿',
+  '⒀',
+  '⒁',
+  '⒂',
+  '⒃',
+  '⒄',
+  '⒅',
+  '⒆',
+  '⒇',
+];
 
 
-const Row = namedMemo<{ index: number; data: SongData[]; style: CSSProperties }>(
+const Row = namedMemo<{ index: number; data: ItemData[]; style: CSSProperties }>(
   'Song',
   'Song',
   ({ index, data, style }) => {
   ({ index, data, style }) => {
-    const { song, active, parentActive, highlight } = data[index];
+    const item = data[index];
+    if (isSeparator(item)) {
+      return (
+        <Styled.Separator style={style}>
+          <Styled.SeparatorText>{item.album || 'Unknown Album'}</Styled.SeparatorText>
+        </Styled.Separator>
+      );
+    }
+    const { song, active, parentActive, highlight, queuePosition } = item;
     return (
     return (
       <Styled.Song active={active} parentActive={parentActive} style={style} highlight={highlight}>
       <Styled.Song active={active} parentActive={parentActive} style={style} highlight={highlight}>
+        <Styled.QueuePosition invert={active && !parentActive}>
+          {queuePosition >= 0 && queuePosition < queueSymbols.length
+            ? queueSymbols[queuePosition]
+            : ''}
+        </Styled.QueuePosition>
         <NoWrapFill>
         <NoWrapFill>
-          {song.track} - {song.title || 'Untitled Track'}
+          {song.track ? `${song.track} - ` : ''}
+          {song.title || 'Untitled Track'}
         </NoWrapFill>
         </NoWrapFill>
       </Styled.Song>
       </Styled.Song>
     );
     );
@@ -42,22 +89,36 @@ const Row = namedMemo<{ index: number; data: SongData[]; style: CSSProperties }>
 
 
 export const Songs: React.FC<Props> = ({ active: parentActive }) => {
 export const Songs: React.FC<Props> = ({ active: parentActive }) => {
   const globalState = useContext(StateContext);
   const globalState = useContext(StateContext);
-  const { songId: playingSongId } = globalState.player;
+  const { songId: playingSongId, queue } = globalState.player;
 
 
   const state = useContext(CmusUIStateContext);
   const state = useContext(CmusUIStateContext);
-  const { activeArtist, activeSongId } = state.library;
+  const { activeArtist, activeAlbum, activeSongId } = state.library;
 
 
   const filteredSongs = getFilteredSongs(state);
   const filteredSongs = getFilteredSongs(state);
 
 
-  const itemData = useMemo<SongData[]>(
-    () =>
-      filteredSongs.map<SongData>((song) => ({
-        song,
-        active: song.id === activeSongId,
-        parentActive,
-        highlight: song.id === playingSongId,
-      })),
-    [parentActive, activeSongId, playingSongId, filteredSongs],
+  const itemData = useMemo<ItemData[]>(() => {
+    const allSongs = filteredSongs.map<SongData & { id: number }>((song) => ({
+      id: song.id,
+      song,
+      active: song.id === activeSongId,
+      parentActive,
+      highlight: song.id === playingSongId,
+      queuePosition: queue.indexOf(song.id),
+    }));
+
+    if (activeAlbum !== null) {
+      return allSongs;
+    }
+
+    return Object.entries(groupBy(allSongs, ({ song }) => song.album)).reduce<ItemData[]>(
+      (last, [album, group], index) => [...last, { id: -index, album }, ...group],
+      [],
+    );
+  }, [parentActive, activeSongId, playingSongId, filteredSongs, activeAlbum, queue]);
+
+  const getItemSize = useCallback(
+    (index: number): number => lineHeight * (isSeparator(itemData[index]) ? 2 : 1),
+    [itemData],
   );
   );
 
 
   const windowRef = useRef<HTMLDivElement>(null);
   const windowRef = useRef<HTMLDivElement>(null);
@@ -78,11 +139,12 @@ export const Songs: React.FC<Props> = ({ active: parentActive }) => {
       <AutoSizer>
       <AutoSizer>
         {({ height, width }): React.ReactElement => (
         {({ height, width }): React.ReactElement => (
           <List
           <List
+            key={`${activeArtist}-${activeAlbum}`}
             outerRef={windowRef}
             outerRef={windowRef}
             height={height}
             height={height}
             width={width}
             width={width}
             itemCount={itemData.length}
             itemCount={itemData.length}
-            itemSize={lineHeight}
+            itemSize={getItemSize}
             itemKey={itemKey}
             itemKey={itemKey}
             itemData={itemData}
             itemData={itemData}
           >
           >

+ 5 - 3
gmus-web/src/hooks/request.spec.tsx

@@ -67,8 +67,9 @@ describe(useRequestCallback.name, () => {
       unmount();
       unmount();
     });
     });
 
 
-    // eslint-disable-next-line jest/prefer-expect-assertions
     it('should set the response and loading state back to false', async () => {
     it('should set the response and loading state back to false', async () => {
+      expect.hasAssertions();
+
       const { getByTestId, unmount } = setupRequest();
       const { getByTestId, unmount } = setupRequest();
 
 
       await waitFor(() => {
       await waitFor(() => {
@@ -80,8 +81,9 @@ describe(useRequestCallback.name, () => {
     });
     });
 
 
     describe('when the request is cancelled', () => {
     describe('when the request is cancelled', () => {
-      // eslint-disable-next-line jest/prefer-expect-assertions
       it('should set the loading state back to false and not set the response', async () => {
       it('should set the loading state back to false and not set the response', async () => {
+        expect.hasAssertions();
+
         const { getByText, getByTestId, unmount } = setupRequest();
         const { getByText, getByTestId, unmount } = setupRequest();
         act(() => {
         act(() => {
           fireEvent.click(getByText('Cancel!'));
           fireEvent.click(getByText('Cancel!'));
@@ -109,8 +111,8 @@ describe(useRequestCallback.name, () => {
       onError.mockClear();
       onError.mockClear();
     });
     });
 
 
-    // eslint-disable-next-line jest/prefer-expect-assertions
     it('should call onError', async () => {
     it('should call onError', async () => {
+      expect.hasAssertions();
       const { unmount } = setupRequest();
       const { unmount } = setupRequest();
 
 
       await waitFor(() => {
       await waitFor(() => {

+ 2 - 1
gmus-web/src/hooks/status.spec.tsx

@@ -105,9 +105,10 @@ describe(useCurrentlyPlayingSongInfo.name, () => {
           .reply(200, testSong, { 'Access-Control-Allow-Origin': '*' });
           .reply(200, testSong, { 'Access-Control-Allow-Origin': '*' });
       });
       });
 
 
-      // eslint-disable-next-line jest/prefer-expect-assertions
       it('should fetch the info for the updated song ID, and update the state', async () => {
       it('should fetch the info for the updated song ID, and update the state', async () => {
+        expect.hasAssertions();
         setup(stateStale);
         setup(stateStale);
+
         await waitFor(() => {
         await waitFor(() => {
           expect(dispatch).toHaveBeenCalledTimes(1);
           expect(dispatch).toHaveBeenCalledTimes(1);
         });
         });

+ 5 - 0
gmus-web/yarn.lock

@@ -1893,6 +1893,11 @@
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
   integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
 
 
+"@types/lodash@^4.14.165":
+  version "4.14.165"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f"
+  integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg==
+
 "@types/minimatch@*":
 "@types/minimatch@*":
   version "3.0.3"
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"