From 3ac77823c3bdc2e2be543f18e7b78295a7eb2fb8 Mon Sep 17 00:00:00 2001 From: sentriz Date: Mon, 31 Jul 2023 23:07:41 +0100 Subject: [PATCH] feat(subsonic): add support for multi-valued album artist tags closes #103 a a a r a a a a a a a a a a --- db/migrations.go | 51 +++++ db/model.go | 51 +++-- go.mod | 26 +-- go.sum | 51 +++-- mockfs/mockfs.go | 6 +- scanner/scanner.go | 41 +++- scanner/scanner_test.go | 211 +++++++++++++++--- scanner/tags/tags.go | 10 - scrobble/lastfm/scrobbler.go | 7 +- server/ctrladmin/handlers.go | 3 +- server/ctrlsubsonic/handlers_by_folder.go | 8 +- server/ctrlsubsonic/handlers_by_tags.go | 66 +++--- server/ctrlsubsonic/handlers_common.go | 6 +- server/ctrlsubsonic/handlers_playlist.go | 2 +- server/ctrlsubsonic/handlers_raw.go | 3 +- server/ctrlsubsonic/spec/construct_by_tags.go | 23 +- server/ctrlsubsonic/spec/spec.go | 12 +- .../testdata/test_get_album_list_random | 72 +++--- .../test_get_album_list_two_alpha_artist | 18 ++ .../test_get_album_list_two_alpha_name | 18 ++ .../testdata/test_get_album_list_two_newest | 18 ++ .../testdata/test_get_album_list_two_random | 84 ++++--- .../testdata/test_get_album_with_cover | 2 + .../testdata/test_get_artist_id_one | 6 + .../testdata/test_get_artist_id_three | 6 + .../testdata/test_get_artist_id_two | 6 + .../testdata/test_search_three_q_alb | 18 ++ 27 files changed, 600 insertions(+), 225 deletions(-) diff --git a/db/migrations.go b/db/migrations.go index a7181a41..c5732e7b 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "path/filepath" + "strings" "time" "github.com/jinzhu/gorm" @@ -55,6 +56,7 @@ func (db *DB) Migrate(ctx MigrationContext) error { construct(ctx, "202211111057", migratePlaylistsQueuesToFullID), construct(ctx, "202304221528", migratePlaylistsToM3U), construct(ctx, "202305301718", migratePlayCountToLength), + construct(ctx, "202307281628", migrateAlbumArtistsMany2Many), } return gormigrate. @@ -538,3 +540,52 @@ func migratePlayCountToLength(tx *gorm.DB, _ MigrationContext) error { return nil } + +func migrateAlbumArtistsMany2Many(tx *gorm.DB, _ MigrationContext) error { + // gorms seems to want to create the table automatically without ON DELETE rules + step := tx.DropTableIfExists(AlbumArtist{}) + if err := step.Error; err != nil { + return fmt.Errorf("step drop prev: %w", err) + } + + step = tx.AutoMigrate( + AlbumArtist{}, + Album{}, + Artist{}, + ) + if err := step.Error; err != nil { + return fmt.Errorf("step auto migrate: %w", err) + } + + if tx.Dialect().HasColumn("albums", "tag_artist_id") { + tx = tx.LogMode(false) + step = tx.Exec(` + INSERT INTO album_artists (album_id, artist_id) + SELECT id album_id, tag_artist_id artist_id + FROM albums + WHERE tag_artist_id IS NOT NULL; + `) + if err := step.Error; err != nil && !strings.Contains(err.Error(), "no such column") { + return fmt.Errorf("step insert from albums: %w", err) + } + + step = tx.Exec(`DROP INDEX idx_albums_tag_artist_id`) + if err := step.Error; err != nil { + return fmt.Errorf("step drop index: %w", err) + } + + step = tx.Exec(`ALTER TABLE albums DROP COLUMN tag_artist_id;`) + if err := step.Error; err != nil { + return fmt.Errorf("step drop albums tag artist id: %w", err) + } + } + + if tx.Dialect().HasColumn("tracks", "artist_id") { + step = tx.Exec(`ALTER TABLE tracks DROP COLUMN artist_id;`) + if err := step.Error; err != nil { + return fmt.Errorf("step drop track tag artist: %w", err) + } + } + + return nil +} diff --git a/db/model.go b/db/model.go index 17091561..1a275f84 100644 --- a/db/model.go +++ b/db/model.go @@ -9,6 +9,7 @@ package db import ( "path" "path/filepath" + "sort" "strings" "time" @@ -46,7 +47,7 @@ type Artist struct { ID int `gorm:"primary_key"` Name string `gorm:"not null; unique_index"` NameUDec string `sql:"default: null"` - Albums []*Album `gorm:"foreignkey:TagArtistID"` + Albums []*Album `gorm:"many2many:album_artists"` AlbumCount int `sql:"-"` Cover string `sql:"default: null"` ArtistStar *ArtistStar @@ -89,9 +90,7 @@ type Track struct { Filename string `gorm:"not null; unique_index:idx_folder_filename" sql:"default: null"` FilenameUDec string `sql:"default: null"` Album *Album - AlbumID int `gorm:"not null; unique_index:idx_folder_filename" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` - Artist *Artist - ArtistID int `gorm:"not null" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` + AlbumID int `gorm:"not null; unique_index:idx_folder_filename" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` Genres []*Genre `gorm:"many2many:track_genres"` Size int `sql:"default: null"` Length int `sql:"default: null"` @@ -118,10 +117,6 @@ func (t *Track) AlbumSID() *specid.ID { return &specid.ID{Type: specid.Album, Value: t.AlbumID} } -func (t *Track) ArtistSID() *specid.ID { - return &specid.ID{Type: specid.Artist, Value: t.ArtistID} -} - func (t *Track) Ext() string { return filepath.Ext(t.Filename) } @@ -190,7 +185,7 @@ type Play struct { AlbumID int `gorm:"not null; index" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` Time time.Time `sql:"default: null"` Count int - Length int + Length int } type Album struct { @@ -202,16 +197,15 @@ type Album struct { RightPath string `gorm:"not null; unique_index:idx_album_abs_path" sql:"default: null"` RightPathUDec string `sql:"default: null"` Parent *Album - ParentID int `sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` - RootDir string `gorm:"unique_index:idx_album_abs_path" sql:"default: null"` - Genres []*Genre `gorm:"many2many:album_genres"` - Cover string `sql:"default: null"` - TagArtist *Artist - TagArtistID int `gorm:"index" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` - TagTitle string `sql:"default: null"` - TagTitleUDec string `sql:"default: null"` - TagBrainzID string `sql:"default: null"` - TagYear int `sql:"default: null"` + ParentID int `sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` + RootDir string `gorm:"unique_index:idx_album_abs_path" sql:"default: null"` + Genres []*Genre `gorm:"many2many:album_genres"` + Cover string `sql:"default: null"` + Artists []*Artist `gorm:"many2many:album_artists"` + TagTitle string `sql:"default: null"` + TagTitleUDec string `sql:"default: null"` + TagBrainzID string `sql:"default: null"` + TagYear int `sql:"default: null"` Tracks []*Track ChildCount int `sql:"-"` Duration int `sql:"-"` @@ -243,6 +237,18 @@ func (a *Album) GenreStrings() []string { return strs } +func (a *Album) ArtistsString() string { + var artists = append([]*Artist(nil), a.Artists...) + sort.Slice(artists, func(i, j int) bool { + return artists[i].ID < artists[j].ID + }) + var names []string + for _, artist := range artists { + names = append(names, artist.Name) + } + return strings.Join(names, " & ") +} + type PlayQueue struct { ID int `gorm:"primary_key"` CreatedAt time.Time @@ -275,6 +281,13 @@ type TranscodePreference struct { Profile string `gorm:"not null" sql:"default: null"` } +type AlbumArtist struct { + Album *Album + AlbumID int `gorm:"not null; unique_index:idx_album_id_artist_id" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` + Artist *Artist + ArtistID int `gorm:"not null; unique_index:idx_album_id_artist_id" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` +} + type TrackGenre struct { Track *Track TrackID int `gorm:"not null; unique_index:idx_track_id_genre_id" sql:"default: null; type:int REFERENCES tracks(id) ON DELETE CASCADE"` diff --git a/go.mod b/go.mod index 02a45f5e..b26f0617 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.19 require ( github.com/Masterminds/sprig v2.22.0+incompatible - github.com/andybalholm/cascadia v1.3.1 + github.com/andybalholm/cascadia v1.3.2 github.com/dexterlb/mpvipc v0.0.0-20221227161445-38b9935eae9d github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 @@ -16,12 +16,11 @@ require ( github.com/gorilla/mux v1.8.0 github.com/gorilla/securecookie v1.1.1 github.com/gorilla/sessions v1.2.1 - jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 github.com/jinzhu/gorm v1.9.17-0.20211120011537-5c235b72a414 github.com/josephburnett/jd v1.5.2 - github.com/mattn/go-sqlite3 v1.14.16 + github.com/mattn/go-sqlite3 v1.14.17 github.com/mitchellh/mapstructure v1.5.0 - github.com/mmcdole/gofeed v1.2.0 + github.com/mmcdole/gofeed v1.2.1 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/oklog/run v1.1.0 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c @@ -31,9 +30,10 @@ require ( github.com/sentriz/audiotags v0.0.0-20230419125925-8886243b2137 github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 github.com/stretchr/testify v1.8.1 - golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 - golang.org/x/net v0.7.0 + golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb + golang.org/x/net v0.14.0 gopkg.in/gormigrate.v1 v1.6.0 + jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 ) require ( @@ -46,14 +46,14 @@ require ( github.com/go-openapi/swag v0.21.1 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/huandu/xstrings v1.4.0 // indirect - github.com/imdario/mergo v0.3.13 // indirect + github.com/imdario/mergo v0.3.16 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/lib/pq v1.3.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mmcdole/goxpp v1.1.0 // indirect @@ -61,13 +61,13 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.4 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect - golang.org/x/crypto v0.6.0 // indirect - golang.org/x/image v0.5.0 // indirect - golang.org/x/sys v0.5.0 // indirect - golang.org/x/text v0.7.0 // indirect + golang.org/x/crypto v0.12.0 // indirect + golang.org/x/image v0.11.0 // indirect + golang.org/x/sys v0.11.0 // indirect + golang.org/x/text v0.12.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 // indirect ) diff --git a/go.sum b/go.sum index 56846553..e9ed78b4 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,9 @@ github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBK github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= -github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -64,8 +65,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= -github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= github.com/jinzhu/gorm v1.9.17-0.20211120011537-5c235b72a414 h1:JkXdZo2OKW1t+GcTx5eb1kD2qW5lt1CDLrL2Ep9t+j4= github.com/jinzhu/gorm v1.9.17-0.20211120011537-5c235b72a414/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= @@ -98,12 +99,13 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= -github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= -github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= @@ -111,8 +113,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/mmcdole/gofeed v1.2.0 h1:kuq7tJnDf0pnsDzF820ukuySHxFimAcizpG15gYHIns= -github.com/mmcdole/gofeed v1.2.0/go.mod h1:TEyTG4gw4Q5Co+Hgahx/Oi3E0JHLM8BXtWC+mkJtRsw= +github.com/mmcdole/gofeed v1.2.1 h1:tPbFN+mfOLcM1kDF1x2c/N68ChbdBatkppdzf/vDe1s= +github.com/mmcdole/gofeed v1.2.1/go.mod h1:2wVInNpgmC85q16QTTuwbuKxtKkHLCDDtf0dCmnrNr4= github.com/mmcdole/goxpp v1.1.0 h1:WwslZNF7KNAXTFuzRtn/OKZxFLJAAyOA9w82mDz2ZGI= github.com/mmcdole/goxpp v1.1.0/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -139,6 +141,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sentriz/audiotags v0.0.0-20230419125925-8886243b2137 h1:K0PSMi/p9ISHpfRFJB03d7VX+jjEsDARsTlAcN1zpac= github.com/sentriz/audiotags v0.0.0-20230419125925-8886243b2137/go.mod h1:nUVlCJ7n2jQoJ5rttpHozZ8pHJIhD9VehL6GP21FoDU= github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 h1:sLILANWN76ja66/K4k/mBqJuCjDZaM67w+Ru6rEB0s0= @@ -160,14 +165,15 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 h1:Jvc7gsqn21cJHCmAWx0LiimpP18LZmUxkT5Mp7EZ1mI= -golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb h1:mIKbk8weKhSeLH2GmUTrvx8CjkyJmnU1wFmg59CUjFA= +golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI= -golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= +golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo= +golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -177,10 +183,14 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -190,20 +200,26 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -221,7 +237,6 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 h1:6YFJoB+0fUH6X3xU/G2tQqCYg+PkGtnZ5nMR5rpw72g= diff --git a/mockfs/mockfs.go b/mockfs/mockfs.go index 7f2caa47..9cb0e869 100644 --- a/mockfs/mockfs.go +++ b/mockfs/mockfs.go @@ -208,8 +208,8 @@ func (m *MockFS) LogAlbums() { m.t.Logf("\nalbums") for _, album := range albums { - m.t.Logf("id %-3d root %-3s lr %-15s %-10s pid %-3d aid %-3d cov %-10s", - album.ID, album.RootDir, album.LeftPath, album.RightPath, album.ParentID, album.TagArtistID, album.Cover) + m.t.Logf("id %-3d root %-3s lr %-15s %-10s pid %-3d cov %-10s", + album.ID, album.RootDir, album.LeftPath, album.RightPath, album.ParentID, album.Cover) } m.t.Logf("total %d", len(albums)) } @@ -341,7 +341,7 @@ func (m *MockFS) DumpDB(suffix ...string) { m.t.Fatalf("backing up: %v", err) } - m.t.Error("DumpDB left behind") + m.t.Error(destPath) } type tagReaderResult struct { diff --git a/scanner/scanner.go b/scanner/scanner.go index 0fcb7cec..6d748dcf 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -360,14 +360,24 @@ func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *Context, i int, par } // metadata for the album table comes only from the the first track's tags - if i == 0 || album.TagArtist == nil { - albumArtist, err := populateAlbumArtist(tx, parent, tags.MustAlbumArtist(trags)) - if err != nil { - return fmt.Errorf("populate album artist: %w", err) + if i == 0 { + albumArtists := tags.MustAlbumArtists(trags) + var albumArtistIDs []int + for _, albumArtistName := range albumArtists { + albumArtist, err := populateArtist(tx, parent, albumArtistName) + if err != nil { + return fmt.Errorf("populate album artist: %w", err) + } + albumArtistIDs = append(albumArtistIDs, albumArtist.ID) } - if err := populateAlbum(tx, album, albumArtist, trags, stat.ModTime()); err != nil { + if err := populateAlbumArtists(tx, album, albumArtistIDs); err != nil { + return fmt.Errorf("populate album artists: %w", err) + } + + if err := populateAlbum(tx, album, trags, stat.ModTime()); err != nil { return fmt.Errorf("populate album: %w", err) } + if err := populateAlbumGenres(tx, album, genreIDs); err != nil { return fmt.Errorf("populate album genres: %w", err) } @@ -386,13 +396,12 @@ func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *Context, i int, par return nil } -func populateAlbum(tx *db.DB, album *db.Album, albumArtist *db.Artist, trags tags.Parser, modTime time.Time) error { +func populateAlbum(tx *db.DB, album *db.Album, trags tags.Parser, modTime time.Time) error { albumName := tags.MustAlbum(trags) album.TagTitle = albumName album.TagTitleUDec = decoded(albumName) album.TagBrainzID = trags.AlbumBrainzID() album.TagYear = trags.Year() - album.TagArtist = albumArtist album.ModifiedAt = modTime album.CreatedAt = modTime @@ -434,7 +443,6 @@ func populateTrack(tx *db.DB, album *db.Album, track *db.Track, trags tags.Parse track.FilenameUDec = decoded(basename) track.Size = size track.AlbumID = album.ID - track.ArtistID = album.TagArtist.ID track.TagTitle = trags.Title() track.TagTitleUDec = decoded(trags.Title()) @@ -453,7 +461,7 @@ func populateTrack(tx *db.DB, album *db.Album, track *db.Track, trags tags.Parse return nil } -func populateAlbumArtist(tx *db.DB, parent *db.Album, artistName string) (*db.Artist, error) { +func populateArtist(tx *db.DB, parent *db.Album, artistName string) (*db.Artist, error) { var update db.Artist update.Name = artistName update.NameUDec = decoded(artistName) @@ -510,6 +518,17 @@ func populateAlbumGenres(tx *db.DB, album *db.Album, genreIDs []int) error { return nil } +func populateAlbumArtists(tx *db.DB, album *db.Album, albumArtistIDs []int) error { + if err := tx.Where("album_id=?", album.ID).Delete(db.AlbumArtist{}).Error; err != nil { + return fmt.Errorf("delete old album album artists: %w", err) + } + + if err := tx.InsertBulkLeftMany("album_artists", []string{"album_id", "artist_id"}, album.ID, albumArtistIDs); err != nil { + return fmt.Errorf("insert bulk album artists: %w", err) + } + return nil +} + func (s *Scanner) cleanTracks(c *Context) error { start := time.Now() defer func() { log.Printf("finished clean tracks in %s, %d removed", durSince(start), c.TracksMissing()) }() @@ -561,8 +580,8 @@ func (s *Scanner) cleanArtists(c *Context) error { sub := s.db. Select("artists.id"). Model(&db.Artist{}). - Joins("LEFT JOIN albums ON albums.tag_artist_id=artists.id"). - Where("albums.id IS NULL"). + Joins("LEFT JOIN album_artists ON album_artists.artist_id=artists.id"). + Where("album_artists.artist_id IS NULL"). SubQuery() q := s.db. Where("artists.id IN ?", sub). diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 5462bb64..baaf5efd 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -115,9 +115,12 @@ func TestCoverBeforeTracks(t *testing.T) { m.ScanAndClean() var album db.Album - require.NoError(m.DB().Preload("TagArtist").Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&album).Error) // album has cover - require.Equal("cover.jpg", album.Cover) // album has cover - require.Equal("artist-2", album.TagArtist.Name) // album artist + require.NoError(m.DB().Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&album).Error) // album has cover + require.Equal("cover.jpg", album.Cover) // album has cover + + var albumArtist db.Artist + require.NoError(m.DB().Joins("JOIN album_artists ON album_artists.artist_id=artists.id").Where("album_artists.album_id=?", album.ID).Find(&albumArtist).Error) // album has cover + require.Equal("artist-2", albumArtist.Name) // album artist var tracks []*db.Track require.NoError(m.DB().Where("album_id=?", album.ID).Find(&tracks).Error) // album has tracks @@ -141,11 +144,14 @@ func TestUpdatedTags(t *testing.T) { m.ScanAndClean() var track db.Track - require.NoError(m.DB().Preload("Album").Preload("Artist").Where("filename=?", "track-10.flac").Find(&track).Error) // track has tags - require.Equal("artist", track.TagTrackArtist) // track has tags - require.Equal("album-artist", track.Artist.Name) // track has tags - require.Equal("album", track.Album.TagTitle) // track has tags - require.Equal("title", track.TagTitle) // track has tags + require.NoError(m.DB().Preload("Album").Where("filename=?", "track-10.flac").Find(&track).Error) // track has tags + require.Equal("artist", track.TagTrackArtist) // track has tags + require.Equal("album", track.Album.TagTitle) // track has tags + require.Equal("title", track.TagTitle) // track has tags + + var trackArtistA db.Artist + require.NoError(m.DB().Joins("JOIN album_artists ON album_artists.artist_id=artists.id").Where("album_artists.album_id=?", track.AlbumID).Limit(1).Find(&trackArtistA).Error) // updated has tags + require.Equal("album-artist", trackArtistA.Name) // track has tags m.SetTags("artist-10/album-10/track-10.flac", func(tags *mockfs.Tags) error { tags.RawArtist = "artist-upd" @@ -158,12 +164,15 @@ func TestUpdatedTags(t *testing.T) { m.ScanAndClean() var updated db.Track - require.NoError(m.DB().Preload("Album").Preload("Artist").Where("filename=?", "track-10.flac").Find(&updated).Error) // updated has tags - require.Equal(track.ID, updated.ID) // updated has tags - require.Equal("artist-upd", updated.TagTrackArtist) // updated has tags - require.Equal("album-artist-upd", updated.Artist.Name) // updated has tags - require.Equal("album-upd", updated.Album.TagTitle) // updated has tags - require.Equal("title-upd", updated.TagTitle) // updated has tags + require.NoError(m.DB().Preload("Album").Where("filename=?", "track-10.flac").Find(&updated).Error) // updated has tags + require.Equal(track.ID, updated.ID) // updated has tags + require.Equal("artist-upd", updated.TagTrackArtist) // updated has tags + require.Equal("album-upd", updated.Album.TagTitle) // updated has tags + require.Equal("title-upd", updated.TagTitle) // updated has tags + + var trackArtistB db.Artist + require.NoError(m.DB().Joins("JOIN album_artists ON album_artists.artist_id=artists.id").Where("album_artists.album_id=?", track.AlbumID).Limit(1).Find(&trackArtistB).Error) // updated has tags + require.Equal("album-artist-upd", trackArtistB.Name) // updated has tags } // https://github.com/sentriz/gonic/issues/225 @@ -409,21 +418,22 @@ func TestMultiFolderWithSharedArtist(t *testing.T) { }) m.ScanAndClean() - sq := func(db *gorm.DB) *gorm.DB { - return db. - Select("*, count(sub.id) child_count, sum(sub.length) duration"). - Joins("LEFT JOIN tracks sub ON albums.id=sub.album_id"). - Group("albums.id") - } - var artist db.Artist - require.NoError(m.DB().Where("name=?", artistName).Preload("Albums", sq).First(&artist).Error) + require.NoError(m.DB().Where("name=?", artistName).First(&artist).Error) require.Equal(artistName, artist.Name) - require.Equal(2, len(artist.Albums)) - for _, album := range artist.Albums { + var artistAlbums []*db.Album + require.NoError(m.DB(). + Select("*, count(sub.id) child_count, sum(sub.length) duration"). + Joins("JOIN album_artists ON album_artists.album_id=albums.id"). + Joins("LEFT JOIN tracks sub ON albums.id=sub.album_id"). + Where("album_artists.artist_id=?", artist.ID). + Group("albums.id"). + Find(&artistAlbums).Error) + require.Equal(2, len(artistAlbums)) + + for _, album := range artistAlbums { require.Greater(album.TagYear, 0) - require.Equal(artist.ID, album.TagArtistID) require.Greater(album.ChildCount, 0) require.Greater(album.Duration, 0) } @@ -574,12 +584,15 @@ func TestCompilationAlbumWithoutAlbumArtist(t *testing.T) { require.Equal(5, trackCount) var artists []*db.Artist - require.NoError(m.DB().Preload("Albums").Find(&artists).Error) + require.NoError(m.DB().Find(&artists).Error) require.Equal(1, len(artists)) // we only have one album artist require.Equal("artist 0", artists[0].Name) // it came from the first track's fallback to artist tag - require.Equal(1, len(artists[0].Albums)) // the artist has one album - require.Equal(pathAlbum, artists[0].Albums[0].RightPath) - require.Equal(pathArtist+"/", artists[0].Albums[0].LeftPath) + + var artistAlbums []*db.Album + require.NoError(m.DB().Joins("JOIN album_artists ON album_artists.album_id=albums.id").Where("album_artists.artist_id=?", artists[0].ID).Find(&artistAlbums).Error) + require.Equal(1, len(artistAlbums)) // the artist has one album + require.Equal(pathAlbum, artistAlbums[0].RightPath) + require.Equal(pathArtist+"/", artistAlbums[0].LeftPath) } func TestIncrementalScanNoChangeNoUpdatedAt(t *testing.T) { @@ -591,11 +604,11 @@ func TestIncrementalScanNoChangeNoUpdatedAt(t *testing.T) { m.ScanAndClean() var albumA db.Album - require.NoError(m.DB().Where("tag_artist_id NOT NULL").Order("updated_at DESC").Find(&albumA).Error) + require.NoError(m.DB().Joins("JOIN album_artists ON album_artists.album_id=albums.id").Order("updated_at DESC").Find(&albumA).Error) m.ScanAndClean() var albumB db.Album - require.NoError(m.DB().Where("tag_artist_id NOT NULL").Order("updated_at DESC").Find(&albumB).Error) + require.NoError(m.DB().Joins("JOIN album_artists ON album_artists.album_id=albums.id").Order("updated_at DESC").Find(&albumB).Error) require.Equal(albumB.UpdatedAt, albumA.UpdatedAt) } @@ -646,3 +659,139 @@ func TestNoOrphanedGenres(t *testing.T) { require.NoError(m.DB().Model(&db.Genre{}).Count(&genreCount).Error) require.Equal(0, genreCount) } + +func TestMultiArtistSupport(t *testing.T) { + t.Parallel() + require := assert.New(t) + m := mockfs.New(t) + + m.AddItemsGlob("artist-0/album-[012]/track-0.*") + m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) error { + tags.RawAlbum = "Mutator" + tags.RawAlbumArtists = []string{"Alan Vega", "Liz Lamere"} + return nil + }) + m.SetTags("artist-0/album-1/track-0.flac", func(tags *mockfs.Tags) error { + tags.RawAlbum = "Dead Man" + tags.RawAlbumArtists = []string{"Alan Vega", "Mercury Rev"} + return nil + }) + m.SetTags("artist-0/album-2/track-0.flac", func(tags *mockfs.Tags) error { + tags.RawAlbum = "Yerself Is Steam" + tags.RawAlbumArtist = "Mercury Rev" + return nil + }) + + m.ScanAndClean() + + var artists []*db.Artist + require.NoError(m.DB().Find(&artists).Error) + require.Len(artists, 3) // alan, liz, mercury + + var albumArtists []*db.AlbumArtist + require.NoError(m.DB().Find(&albumArtists).Error) + require.Len(albumArtists, 5) + + type row struct{ Artist, Albums string } + state := func() []row { + var table []row + require.NoError(m.DB(). + Select("artists.name artist, group_concat(albums.tag_title, ';') albums"). + Model(db.Artist{}). + Joins("JOIN album_artists ON album_artists.artist_id=artists.id"). + Joins("JOIN albums ON albums.id=album_artists.album_id"). + Order("artists.name, albums.tag_title"). + Group("artists.id"). + Scan(&table). + Error) + return table + } + + require.Equal( + []row{ + {"Alan Vega", "Mutator;Dead Man"}, + {"Liz Lamere", "Mutator"}, + {"Mercury Rev", "Dead Man;Yerself Is Steam"}, + }, + state(), + ) + + m.RemoveAll("artist-0/album-2") + m.SetTags("artist-0/album-1/track-0.flac", func(tags *mockfs.Tags) error { + tags.RawAlbum = "Dead Man" + tags.RawAlbumArtists = []string{"Alan Vega"} + return nil + }) + + m.ScanAndClean() + + require.NoError(m.DB().Find(&artists).Error) + require.Len(artists, 2) // alan, liz + + require.NoError(m.DB().Find(&albumArtists).Error) + require.Len(albumArtists, 3) + + require.Equal( + []row{ + {"Alan Vega", "Mutator;Dead Man"}, + {"Liz Lamere", "Mutator"}, + }, + state(), + ) + +} + +func TestMultiArtistPreload(t *testing.T) { + t.Parallel() + require := assert.New(t) + m := mockfs.New(t) + + m.AddItemsGlob("artist-0/album-[012]/track-0.*") + m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) error { + tags.RawAlbum = "Mutator" + tags.RawAlbumArtists = []string{"Alan Vega", "Liz Lamere"} + return nil + }) + m.SetTags("artist-0/album-1/track-0.flac", func(tags *mockfs.Tags) error { + tags.RawAlbum = "Dead Man" + tags.RawAlbumArtists = []string{"Alan Vega", "Mercury Rev"} + return nil + }) + m.SetTags("artist-0/album-2/track-0.flac", func(tags *mockfs.Tags) error { + tags.RawAlbum = "Yerself Is Steam" + tags.RawAlbumArtist = "Mercury Rev" + return nil + }) + + m.ScanAndClean() + + var albums []*db.Album + require.NoError(m.DB().Preload("Artists").Find(&albums).Error) + require.GreaterOrEqual(len(albums), 3) + + for _, album := range albums { + switch album.TagTitle { + case "Mutator": + require.Len(album.Artists, 2) + case "Dead Man": + require.Len(album.Artists, 2) + case "Yerself Is Steam": + require.Len(album.Artists, 1) + } + } + + var artists []*db.Artist + require.NoError(m.DB().Preload("Albums").Find(&artists).Error) + require.Equal(3, len(artists)) + + for _, artist := range artists { + switch artist.Name { + case "Alan Vega": + require.Len(artist.Albums, 2) + case "Mercury Rev": + require.Len(artist.Albums, 2) + case "Liz Lamere": + require.Len(artist.Albums, 1) + } + } +} diff --git a/scanner/tags/tags.go b/scanner/tags/tags.go index b7e910e0..43155312 100644 --- a/scanner/tags/tags.go +++ b/scanner/tags/tags.go @@ -127,16 +127,6 @@ func MustArtist(p Parser) string { return "Unknown Artist" } -func MustAlbumArtist(p Parser) string { - if r := p.AlbumArtist(); r != "" { - return r - } - if r := p.Artist(); r != "" { - return r - } - return "Unknown Artist" -} - func MustAlbumArtists(p Parser) []string { if r := p.AlbumArtists(); len(r) > 0 { return r diff --git a/scrobble/lastfm/scrobbler.go b/scrobble/lastfm/scrobbler.go index 80e87efa..96abd828 100644 --- a/scrobble/lastfm/scrobbler.go +++ b/scrobble/lastfm/scrobbler.go @@ -29,6 +29,10 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su if user.LastFMSession == "" { return nil } + if track.Album == nil || len(track.Album.Artists) == 0 { + return fmt.Errorf("track has no album artists") + } + apiKey, err := s.db.GetSetting("lastfm_api_key") if err != nil { return fmt.Errorf("get api key: %w", err) @@ -46,13 +50,14 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su } else { params.Add("method", "track.updateNowPlaying") } + params.Add("api_key", apiKey) params.Add("sk", user.LastFMSession) params.Add("artist", track.TagTrackArtist) params.Add("track", track.TagTitle) params.Add("trackNumber", strconv.Itoa(track.TagTrackNumber)) params.Add("album", track.Album.TagTitle) - params.Add("albumArtist", track.Artist.Name) + params.Add("albumArtist", track.Album.ArtistsString()) params.Add("duration", strconv.Itoa(track.Length)) // make sure we provide a valid uuid, since some users may have an incorrect mbid in their tags diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index 5aa168d8..9c42ac23 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -62,9 +62,8 @@ func (c *Controller) ServeHome(r *http.Request) *Response { // recent folders box c.DB. - Where("tag_artist_id IS NOT NULL"). Order("created_at DESC"). - Limit(8). + Limit(20). Find(&data.RecentFolders) data.IsScanning = c.Scanner.IsScanning() if tStr, err := c.DB.GetSetting("last_scan_time"); err != nil { diff --git a/server/ctrlsubsonic/handlers_by_folder.go b/server/ctrlsubsonic/handlers_by_folder.go index 61caf26c..af06caf1 100644 --- a/server/ctrlsubsonic/handlers_by_folder.go +++ b/server/ctrlsubsonic/handlers_by_folder.go @@ -90,7 +90,7 @@ func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response { c.DB. Where("album_id=?", id.Value). Preload("Album"). - Preload("Album.TagArtist"). + Preload("Album.Artists"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). Order("filename"). @@ -178,7 +178,7 @@ func (c *Controller) ServeGetAlbumList(r *http.Request) *spec.Response { Select("albums.*, count(tracks.id) child_count, sum(tracks.length) duration"). Joins("LEFT JOIN tracks ON tracks.album_id=albums.id"). Group("albums.id"). - Where("albums.tag_artist_id IS NOT NULL"). + Joins("JOIN album_artists ON album_artists.album_id=albums.id"). Offset(params.GetOrInt("offset", 0)). Limit(params.GetOrInt("size", 10)). Preload("Parent"). @@ -236,7 +236,7 @@ func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response { // search "albums" var albums []*db.Album - q = c.DB.Where(`tag_artist_id IS NOT NULL`) + q = c.DB.Joins("JOIN album_artists ON album_artists.album_id=albums.id") for _, s := range queries { q = q.Where(`right_path LIKE ? OR right_path_u_dec LIKE ?`, s, s) } @@ -323,7 +323,7 @@ func (c *Controller) ServeGetStarred(r *http.Request) *spec.Response { // "albums" var albums []*db.Album q = c.DB. - Where("tag_artist_id IS NOT NULL"). + Joins("JOIN album_artists ON album_artists.album_id=albums.id"). Joins("JOIN album_stars ON albums.id=album_stars.album_id"). Where("album_stars.user_id=?", user.ID). Preload("AlbumStar", "user_id=?", user.ID). diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index 4f0b6fbb..5f96d8aa 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -24,7 +24,8 @@ func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response { var artists []*db.Artist q := c.DB. Select("*, count(sub.id) album_count"). - Joins("LEFT JOIN albums sub ON artists.id=sub.tag_artist_id"). + Joins("JOIN album_artists ON album_artists.artist_id=artists.id"). + Joins("JOIN albums sub ON sub.id=album_artists.album_id"). Preload("ArtistStar", "user_id=?", user.ID). Preload("ArtistRating", "user_id=?", user.ID). Group("artists.id"). @@ -69,11 +70,10 @@ func (c *Controller) ServeGetArtist(r *http.Request) *spec.Response { return db. Select("*, count(sub.id) child_count, sum(sub.length) duration"). Joins("LEFT JOIN tracks sub ON albums.id=sub.album_id"). - Preload("AlbumStar", "user_id=?", user.ID). - Preload("AlbumRating", "user_id=?", user.ID). Order("albums.right_path"). Group("albums.id") }). + Preload("Albums.Artists"). Preload("ArtistStar", "user_id=?", user.ID). Preload("ArtistRating", "user_id=?", user.ID). First(artist, id.Value) @@ -81,7 +81,7 @@ func (c *Controller) ServeGetArtist(r *http.Request) *spec.Response { sub.Artist = spec.NewArtistByTags(artist) sub.Artist.Albums = make([]*spec.Album, len(artist.Albums)) for i, album := range artist.Albums { - sub.Artist.Albums[i] = spec.NewAlbumByTags(album, artist) + sub.Artist.Albums[i] = spec.NewAlbumByTags(album, album.Artists) } sub.Artist.AlbumCount = len(artist.Albums) return sub @@ -98,7 +98,7 @@ func (c *Controller) ServeGetAlbum(r *http.Request) *spec.Response { err = c.DB. Select("albums.*, count(tracks.id) child_count, sum(tracks.length) duration"). Joins("LEFT JOIN tracks ON tracks.album_id=albums.id"). - Preload("TagArtist"). + Preload("Artists"). Preload("Genres"). Preload("Tracks", func(db *gorm.DB) *gorm.DB { return db. @@ -114,7 +114,7 @@ func (c *Controller) ServeGetAlbum(r *http.Request) *spec.Response { return spec.NewError(10, "couldn't find an album with that id") } sub := spec.NewResponse() - sub.Album = spec.NewAlbumByTags(album, album.TagArtist) + sub.Album = spec.NewAlbumByTags(album, album.Artists) sub.Album.Tracks = make([]*spec.TrackChild, len(album.Tracks)) transcodeMIME, transcodeSuffix := streamGetTransPrefProfile(c.DB, user.ID, params.GetOr("c", "")) @@ -140,7 +140,7 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { q := c.DB.DB switch listType { case "alphabeticalByArtist": - q = q.Joins("JOIN artists ON albums.tag_artist_id=artists.id") + q = q.Joins("JOIN artists ON artists.id=album_artists.artist_id") q = q.Order("artists.name") case "alphabeticalByName": q = q.Order("tag_title") @@ -186,10 +186,10 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { Select("albums.*, count(tracks.id) child_count, sum(tracks.length) duration"). Joins("LEFT JOIN tracks ON tracks.album_id=albums.id"). Group("albums.id"). - Where("albums.tag_artist_id IS NOT NULL"). + Joins("JOIN album_artists ON album_artists.album_id=albums.id"). Offset(params.GetOrInt("offset", 0)). Limit(params.GetOrInt("size", 10)). - Preload("TagArtist"). + Preload("Artists"). Preload("AlbumStar", "user_id=?", user.ID). Preload("AlbumRating", "user_id=?", user.ID). Find(&albums) @@ -198,7 +198,7 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { List: make([]*spec.Album, len(albums)), } for i, album := range albums { - sub.AlbumsTwo.List[i] = spec.NewAlbumByTags(album, album.TagArtist) + sub.AlbumsTwo.List[i] = spec.NewAlbumByTags(album, album.Artists) } return sub } @@ -225,7 +225,9 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response { for _, s := range queries { q = q.Where(`name LIKE ? OR name_u_dec LIKE ?`, s, s) } - q = q.Joins("JOIN albums ON albums.tag_artist_id=artists.id"). + q = q. + Joins("JOIN album_artists ON album_artists.artist_id=artists.id"). + Joins("JOIN albums ON albums.id=album_artists.album_id"). Preload("ArtistStar", "user_id=?", user.ID). Preload("ArtistRating", "user_id=?", user.ID). Offset(params.GetOrInt("artistOffset", 0)). @@ -243,14 +245,15 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response { // search albums var albums []*db.Album q = c.DB. - Preload("TagArtist"). + Preload("Artists"). Preload("Genres"). Preload("AlbumStar", "user_id=?", user.ID). Preload("AlbumRating", "user_id=?", user.ID) for _, s := range queries { q = q.Where(`tag_title LIKE ? OR tag_title_u_dec LIKE ?`, s, s) } - q = q.Offset(params.GetOrInt("albumOffset", 0)). + q = q. + Offset(params.GetOrInt("albumOffset", 0)). Limit(params.GetOrInt("albumCount", 20)) if m := getMusicFolder(c.MusicPaths, params); m != "" { q = q.Where("root_dir=?", m) @@ -259,19 +262,19 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response { return spec.NewError(0, "find albums: %v", err) } for _, a := range albums { - results.Albums = append(results.Albums, spec.NewAlbumByTags(a, a.TagArtist)) + results.Albums = append(results.Albums, spec.NewAlbumByTags(a, a.Artists)) } // search tracks var tracks []*db.Track q = c.DB. Preload("Album"). - Preload("Album.TagArtist"). + Preload("Album.Artists"). Preload("Genres"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID) for _, s := range queries { - q = q.Where(`tag_title LIKE ? OR tag_title_u_dec LIKE ?`, s, s) + q = q.Where(`tracks.tag_title LIKE ? OR tracks.tag_title_u_dec LIKE ?`, s, s) } q = q.Offset(params.GetOrInt("songOffset", 0)). Limit(params.GetOrInt("songCount", 20)) @@ -369,7 +372,8 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response { err = c.DB. Select("artists.*, count(albums.id) album_count"). Where("name=?", similarInfo.Name). - Joins("LEFT JOIN albums ON artists.id=albums.tag_artist_id"). + Joins("LEFT JOIN album_artists ON album_artists.artist_id=artists.id"). + Joins("LEFT JOIN albums ON albums.id=album_artists.album_id"). Group("artists.id"). Find(&artist). Error @@ -421,7 +425,7 @@ func (c *Controller) ServeGetSongsByGenre(r *http.Request) *spec.Response { Joins("JOIN track_genres ON track_genres.track_id=tracks.id"). Joins("JOIN genres ON track_genres.genre_id=genres.id AND genres.name=?", genre). Preload("Album"). - Preload("Album.TagArtist"). + Preload("Album.Artists"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). Offset(params.GetOrInt("offset", 0)). @@ -429,6 +433,7 @@ func (c *Controller) ServeGetSongsByGenre(r *http.Request) *spec.Response { if m := getMusicFolder(c.MusicPaths, params); m != "" { q = q.Where("albums.root_dir=?", m) } + q = q.Group("tracks.id") if err := q.Find(&tracks).Error; err != nil { return spec.NewError(0, "error finding tracks: %v", err) } @@ -457,11 +462,13 @@ func (c *Controller) ServeGetStarredTwo(r *http.Request) *spec.Response { // artists var artists []*db.Artist q := c.DB. - Group("artists.id"). Joins("JOIN artist_stars ON artist_stars.artist_id=artists.id"). Where("artist_stars.user_id=?", user.ID). + Joins("JOIN album_artists ON album_artists.artist_id=artists.id"). + Joins("JOIN albums ON albums.id=album_artists.album_id"). Preload("ArtistStar", "user_id=?", user.ID). - Preload("ArtistRating", "user_id=?", user.ID) + Preload("ArtistRating", "user_id=?", user.ID). + Group("artists.id") if m := getMusicFolder(c.MusicPaths, params); m != "" { q = q.Where("albums.root_dir=?", m) } @@ -477,7 +484,7 @@ func (c *Controller) ServeGetStarredTwo(r *http.Request) *spec.Response { q = c.DB. Joins("JOIN album_stars ON album_stars.album_id=albums.id"). Where("album_stars.user_id=?", user.ID). - Preload("TagArtist"). + Preload("Artists"). Preload("AlbumStar", "user_id=?", user.ID). Preload("AlbumRating", "user_id=?", user.ID) if m := getMusicFolder(c.MusicPaths, params); m != "" { @@ -487,7 +494,7 @@ func (c *Controller) ServeGetStarredTwo(r *http.Request) *spec.Response { return spec.NewError(0, "find albums: %v", err) } for _, a := range albums { - results.Albums = append(results.Albums, spec.NewAlbumByTags(a, a.TagArtist)) + results.Albums = append(results.Albums, spec.NewAlbumByTags(a, a.Artists)) } // tracks @@ -572,10 +579,13 @@ func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response { var tracks []*db.Track err = c.DB. Preload("Album"). - Where("artist_id=? AND tracks.tag_title IN (?)", artist.ID, topTrackNames). + Joins("JOIN albums ON albums.id=tracks.album_id"). + Joins("JOIN album_artists ON album_artists.album_id=albums.id"). + Where("album_artists.artist_id=? AND tracks.tag_title IN (?)", artist.ID, topTrackNames). Limit(count). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). + Group("tracks.id"). Find(&tracks). Error if err != nil { @@ -611,7 +621,6 @@ func (c *Controller) ServeGetSimilarSongs(r *http.Request) *spec.Response { var track db.Track err = c.DB. - Preload("Artist"). Preload("Album"). Where("id=?", id.Value). First(&track). @@ -620,7 +629,7 @@ func (c *Controller) ServeGetSimilarSongs(r *http.Request) *spec.Response { return spec.NewError(10, "couldn't find a track with that id") } - similarTracks, err := c.LastFMClient.TrackGetSimilarTracks(apiKey, track.Artist.Name, track.TagTitle) + similarTracks, err := c.LastFMClient.TrackGetSimilarTracks(apiKey, track.TagTrackArtist, track.TagTitle) if err != nil { return spec.NewError(0, "fetching track similar tracks: %v", err) } @@ -635,11 +644,10 @@ func (c *Controller) ServeGetSimilarSongs(r *http.Request) *spec.Response { var tracks []*db.Track err = c.DB. - Preload("Artist"). + Select("tracks.*"). Preload("Album"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). - Select("tracks.*"). Where("tracks.tag_title IN (?)", similarTrackNames). Order(gorm.Expr("random()")). Limit(count). @@ -708,9 +716,11 @@ func (c *Controller) ServeGetSimilarSongsTwo(r *http.Request) *spec.Response { Preload("Album"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). - Joins("JOIN artists on tracks.artist_id=artists.id"). + Joins("JOIN album_artists ON album_artists.album_id=tracks.album_id"). + Joins("JOIN artists ON artists.id=album_artists.artist_id"). Where("artists.name IN (?)", artistNames). Order(gorm.Expr("random()")). + Group("tracks.id"). Limit(count). Find(&tracks). Error diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index d5f4960f..ad47e2f6 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -62,7 +62,7 @@ func (c *Controller) ServeScrobble(r *http.Request) *spec.Response { } track := &db.Track{} - if err := c.DB.Preload("Album").Preload("Artist").First(track, id.Value).Error; err != nil { + if err := c.DB.Preload("Album").Preload("Album.Artists").First(track, id.Value).Error; err != nil { return spec.NewError(0, "error finding track: %v", err) } @@ -236,7 +236,7 @@ func (c *Controller) ServeGetSong(r *http.Request) *spec.Response { err = c.DB. Where("id=?", id.Value). Preload("Album"). - Preload("Album.TagArtist"). + Preload("Album.Artists"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). First(&track). @@ -256,7 +256,7 @@ func (c *Controller) ServeGetRandomSongs(r *http.Request) *spec.Response { q := c.DB.DB. Limit(params.GetOrInt("size", 10)). Preload("Album"). - Preload("Album.TagArtist"). + Preload("Album.Artists"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). Joins("JOIN albums ON tracks.album_id=albums.id"). diff --git a/server/ctrlsubsonic/handlers_playlist.go b/server/ctrlsubsonic/handlers_playlist.go index 333e601e..bc3c1014 100644 --- a/server/ctrlsubsonic/handlers_playlist.go +++ b/server/ctrlsubsonic/handlers_playlist.go @@ -217,7 +217,7 @@ func playlistRender(c *Controller, params params.Params, playlistID string, play switch id := file.SID(); id.Type { case specid.Track: var track db.Track - if err := c.DB.Where("id=?", id.Value).Preload("Album").Preload("Album.TagArtist").Preload("TrackStar", "user_id=?", user.ID).Preload("TrackRating", "user_id=?", user.ID).Find(&track).Error; errors.Is(err, gorm.ErrRecordNotFound) { + if err := c.DB.Where("id=?", id.Value).Preload("Album").Preload("Album.Artists").Preload("TrackStar", "user_id=?", user.ID).Preload("TrackRating", "user_id=?", user.ID).Find(&track).Error; errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("load track by id: %w", err) } trch = spec.NewTCTrackByFolder(&track, track.Album) diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 16b4acc6..58e13e5b 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -150,8 +150,9 @@ func coverGetPathArtist(dbc *db.DB, id int) (string, error) { folder := &db.Album{} err := dbc.DB. Select("parent.id, parent.root_dir, parent.left_path, parent.right_path, parent.cover"). + Joins("JOIN album_artists ON album_artists.album_id"). + Where("album_artists.artist_id=?", id). Joins("JOIN albums parent ON parent.id=albums.parent_id"). - Where("albums.tag_artist_id=?", id). Find(folder). Error if err != nil { diff --git a/server/ctrlsubsonic/spec/construct_by_tags.go b/server/ctrlsubsonic/spec/construct_by_tags.go index 7b7b6bcf..d4ef0756 100644 --- a/server/ctrlsubsonic/spec/construct_by_tags.go +++ b/server/ctrlsubsonic/spec/construct_by_tags.go @@ -2,12 +2,13 @@ package spec import ( "path" + "sort" "strings" "go.senan.xyz/gonic/db" ) -func NewAlbumByTags(a *db.Album, artist *db.Artist) *Album { +func NewAlbumByTags(a *db.Album, artists []*db.Artist) *Album { ret := &Album{ Created: a.CreatedAt, ID: a.SID(), @@ -27,9 +28,16 @@ func NewAlbumByTags(a *db.Album, artist *db.Artist) *Album { if a.AlbumRating != nil { ret.UserRating = a.AlbumRating.Rating } - if artist != nil { - ret.Artist = artist.Name - ret.ArtistID = artist.SID() + sort.Slice(artists, func(i, j int) bool { + return artists[i].ID < artists[j].ID + }) + if len(artists) > 0 { + ret.Artist = artists[0].Name + ret.ArtistID = artists[0].SID() + } + for _, a := range artists { + ret.Artists = append(ret.Artists, a.Name) + ret.ArtistIDs = append(ret.ArtistIDs, a.SID()) } return ret } @@ -69,8 +77,11 @@ func NewTrackByTags(t *db.Track, album *db.Album) *TrackChild { if t.TrackRating != nil { ret.UserRating = t.TrackRating.Rating } - if album.TagArtist != nil { - ret.ArtistID = album.TagArtist.SID() + if len(album.Artists) > 0 { + sort.Slice(album.Artists, func(i, j int) bool { + return album.Artists[i].ID < album.Artists[j].ID + }) + ret.ArtistID = album.Artists[0].SID() } // replace tags that we're present if ret.Title == "" { diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go index 323894d6..60c2dd8b 100644 --- a/server/ctrlsubsonic/spec/spec.go +++ b/server/ctrlsubsonic/spec/spec.go @@ -113,11 +113,13 @@ type Albums struct { type Album struct { // common - ID *specid.ID `xml:"id,attr,omitempty" json:"id"` - CoverID *specid.ID `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` - ArtistID *specid.ID `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` - Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` - Created time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` + ID *specid.ID `xml:"id,attr,omitempty" json:"id"` + CoverID *specid.ID `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` + ArtistID *specid.ID `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` + Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` + ArtistIDs []*specid.ID `xml:"artistIds,attr,omitempty" json:"artistIds,omitempty"` + Artists []string `xml:"artists,attr,omitempty" json:"artists,omitempty"` + Created time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` // browsing by folder (eg. getAlbumList) Title string `xml:"title,attr,omitempty" json:"title"` Album string `xml:"album,attr,omitempty" json:"album"` diff --git a/server/ctrlsubsonic/testdata/test_get_album_list_random b/server/ctrlsubsonic/testdata/test_get_album_list_random index 436f0ff6..e4648354 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_list_random +++ b/server/ctrlsubsonic/testdata/test_get_album_list_random @@ -7,91 +7,91 @@ "albumList": { "album": [ { - "id": "al-13", - "coverArt": "al-13", - "artist": "artist-2", + "id": "al-7", + "coverArt": "al-7", + "artist": "artist-1", "created": "2019-11-30T00:00:00Z", - "title": "album-2", + "title": "album-0", "album": "", - "parent": "al-10", + "parent": "al-6", "isDir": true, "name": "", "songCount": 3, "duration": 300 }, { - "id": "al-12", - "coverArt": "al-12", - "artist": "artist-2", + "id": "al-4", + "coverArt": "al-4", + "artist": "artist-0", "created": "2019-11-30T00:00:00Z", "title": "album-1", "album": "", - "parent": "al-10", + "parent": "al-2", "isDir": true, "name": "", "songCount": 3, "duration": 300 }, { - "id": "al-7", - "coverArt": "al-7", - "artist": "artist-1", + "id": "al-3", + "coverArt": "al-3", + "artist": "artist-0", "created": "2019-11-30T00:00:00Z", "title": "album-0", "album": "", - "parent": "al-6", + "parent": "al-2", "isDir": true, "name": "", "songCount": 3, "duration": 300 }, { - "id": "al-5", - "coverArt": "al-5", - "artist": "artist-0", + "id": "al-11", + "coverArt": "al-11", + "artist": "artist-2", "created": "2019-11-30T00:00:00Z", - "title": "album-2", + "title": "album-0", "album": "", - "parent": "al-2", + "parent": "al-10", "isDir": true, "name": "", "songCount": 3, "duration": 300 }, { - "id": "al-9", - "coverArt": "al-9", - "artist": "artist-1", + "id": "al-13", + "coverArt": "al-13", + "artist": "artist-2", "created": "2019-11-30T00:00:00Z", "title": "album-2", "album": "", - "parent": "al-6", + "parent": "al-10", "isDir": true, "name": "", "songCount": 3, "duration": 300 }, { - "id": "al-11", - "coverArt": "al-11", - "artist": "artist-2", + "id": "al-5", + "coverArt": "al-5", + "artist": "artist-0", "created": "2019-11-30T00:00:00Z", - "title": "album-0", + "title": "album-2", "album": "", - "parent": "al-10", + "parent": "al-2", "isDir": true, "name": "", "songCount": 3, "duration": 300 }, { - "id": "al-3", - "coverArt": "al-3", - "artist": "artist-0", + "id": "al-9", + "coverArt": "al-9", + "artist": "artist-1", "created": "2019-11-30T00:00:00Z", - "title": "album-0", + "title": "album-2", "album": "", - "parent": "al-2", + "parent": "al-6", "isDir": true, "name": "", "songCount": 3, @@ -111,13 +111,13 @@ "duration": 300 }, { - "id": "al-4", - "coverArt": "al-4", - "artist": "artist-0", + "id": "al-12", + "coverArt": "al-12", + "artist": "artist-2", "created": "2019-11-30T00:00:00Z", "title": "album-1", "album": "", - "parent": "al-2", + "parent": "al-10", "isDir": true, "name": "", "songCount": 3, diff --git a/server/ctrlsubsonic/testdata/test_get_album_list_two_alpha_artist b/server/ctrlsubsonic/testdata/test_get_album_list_two_alpha_artist index 10db2ef4..11c17910 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_list_two_alpha_artist +++ b/server/ctrlsubsonic/testdata/test_get_album_list_two_alpha_artist @@ -11,6 +11,8 @@ "coverArt": "al-3", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -24,6 +26,8 @@ "coverArt": "al-4", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -37,6 +41,8 @@ "coverArt": "al-5", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -50,6 +56,8 @@ "coverArt": "al-7", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -63,6 +71,8 @@ "coverArt": "al-8", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -76,6 +86,8 @@ "coverArt": "al-9", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -89,6 +101,8 @@ "coverArt": "al-11", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -102,6 +116,8 @@ "coverArt": "al-12", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -115,6 +131,8 @@ "coverArt": "al-13", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", diff --git a/server/ctrlsubsonic/testdata/test_get_album_list_two_alpha_name b/server/ctrlsubsonic/testdata/test_get_album_list_two_alpha_name index 34dff4e6..d8bffaa7 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_list_two_alpha_name +++ b/server/ctrlsubsonic/testdata/test_get_album_list_two_alpha_name @@ -11,6 +11,8 @@ "coverArt": "al-3", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -24,6 +26,8 @@ "coverArt": "al-7", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -37,6 +41,8 @@ "coverArt": "al-11", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -50,6 +56,8 @@ "coverArt": "al-4", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -63,6 +71,8 @@ "coverArt": "al-8", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -76,6 +86,8 @@ "coverArt": "al-12", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -89,6 +101,8 @@ "coverArt": "al-5", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -102,6 +116,8 @@ "coverArt": "al-9", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -115,6 +131,8 @@ "coverArt": "al-13", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", diff --git a/server/ctrlsubsonic/testdata/test_get_album_list_two_newest b/server/ctrlsubsonic/testdata/test_get_album_list_two_newest index 10db2ef4..11c17910 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_list_two_newest +++ b/server/ctrlsubsonic/testdata/test_get_album_list_two_newest @@ -11,6 +11,8 @@ "coverArt": "al-3", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -24,6 +26,8 @@ "coverArt": "al-4", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -37,6 +41,8 @@ "coverArt": "al-5", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -50,6 +56,8 @@ "coverArt": "al-7", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -63,6 +71,8 @@ "coverArt": "al-8", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -76,6 +86,8 @@ "coverArt": "al-9", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -89,6 +101,8 @@ "coverArt": "al-11", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -102,6 +116,8 @@ "coverArt": "al-12", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -115,6 +131,8 @@ "coverArt": "al-13", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", diff --git a/server/ctrlsubsonic/testdata/test_get_album_list_two_random b/server/ctrlsubsonic/testdata/test_get_album_list_two_random index ccbfab48..a52a0768 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_list_two_random +++ b/server/ctrlsubsonic/testdata/test_get_album_list_two_random @@ -6,24 +6,13 @@ "serverVersion": "", "albumList2": { "album": [ - { - "id": "al-13", - "coverArt": "al-13", - "artistId": "ar-3", - "artist": "artist-2", - "created": "2019-11-30T00:00:00Z", - "title": "", - "album": "", - "name": "album-2", - "songCount": 3, - "duration": 300, - "year": 2021 - }, { "id": "al-4", "coverArt": "al-4", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -33,14 +22,16 @@ "year": 2021 }, { - "id": "al-11", - "coverArt": "al-11", + "id": "al-13", + "coverArt": "al-13", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", - "name": "album-0", + "name": "album-2", "songCount": 3, "duration": 300, "year": 2021 @@ -50,6 +41,8 @@ "coverArt": "al-3", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -59,10 +52,12 @@ "year": 2021 }, { - "id": "al-7", - "coverArt": "al-7", - "artistId": "ar-2", - "artist": "artist-1", + "id": "al-11", + "coverArt": "al-11", + "artistId": "ar-3", + "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -71,24 +66,13 @@ "duration": 300, "year": 2021 }, - { - "id": "al-5", - "coverArt": "al-5", - "artistId": "ar-1", - "artist": "artist-0", - "created": "2019-11-30T00:00:00Z", - "title": "", - "album": "", - "name": "album-2", - "songCount": 3, - "duration": 300, - "year": 2021 - }, { "id": "al-9", "coverArt": "al-9", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -102,6 +86,8 @@ "coverArt": "al-8", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -115,6 +101,8 @@ "coverArt": "al-12", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -122,6 +110,36 @@ "songCount": 3, "duration": 300, "year": 2021 + }, + { + "id": "al-5", + "coverArt": "al-5", + "artistId": "ar-1", + "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], + "created": "2019-11-30T00:00:00Z", + "title": "", + "album": "", + "name": "album-2", + "songCount": 3, + "duration": 300, + "year": 2021 + }, + { + "id": "al-7", + "coverArt": "al-7", + "artistId": "ar-2", + "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], + "created": "2019-11-30T00:00:00Z", + "title": "", + "album": "", + "name": "album-0", + "songCount": 3, + "duration": 300, + "year": 2021 } ] } diff --git a/server/ctrlsubsonic/testdata/test_get_album_with_cover b/server/ctrlsubsonic/testdata/test_get_album_with_cover index 05178ff7..6a27840b 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_with_cover +++ b/server/ctrlsubsonic/testdata/test_get_album_with_cover @@ -9,6 +9,8 @@ "coverArt": "al-3", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", diff --git a/server/ctrlsubsonic/testdata/test_get_artist_id_one b/server/ctrlsubsonic/testdata/test_get_artist_id_one index 201fdd81..5cc13063 100644 --- a/server/ctrlsubsonic/testdata/test_get_artist_id_one +++ b/server/ctrlsubsonic/testdata/test_get_artist_id_one @@ -14,6 +14,8 @@ "coverArt": "al-3", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -27,6 +29,8 @@ "coverArt": "al-4", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -40,6 +44,8 @@ "coverArt": "al-5", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", diff --git a/server/ctrlsubsonic/testdata/test_get_artist_id_three b/server/ctrlsubsonic/testdata/test_get_artist_id_three index 30587c24..5bf678c6 100644 --- a/server/ctrlsubsonic/testdata/test_get_artist_id_three +++ b/server/ctrlsubsonic/testdata/test_get_artist_id_three @@ -14,6 +14,8 @@ "coverArt": "al-11", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -27,6 +29,8 @@ "coverArt": "al-12", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -40,6 +44,8 @@ "coverArt": "al-13", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", diff --git a/server/ctrlsubsonic/testdata/test_get_artist_id_two b/server/ctrlsubsonic/testdata/test_get_artist_id_two index 68cf3b80..46c48fef 100644 --- a/server/ctrlsubsonic/testdata/test_get_artist_id_two +++ b/server/ctrlsubsonic/testdata/test_get_artist_id_two @@ -14,6 +14,8 @@ "coverArt": "al-7", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -27,6 +29,8 @@ "coverArt": "al-8", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -40,6 +44,8 @@ "coverArt": "al-9", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", diff --git a/server/ctrlsubsonic/testdata/test_search_three_q_alb b/server/ctrlsubsonic/testdata/test_search_three_q_alb index 74a16c2a..b5e038cb 100644 --- a/server/ctrlsubsonic/testdata/test_search_three_q_alb +++ b/server/ctrlsubsonic/testdata/test_search_three_q_alb @@ -11,6 +11,8 @@ "coverArt": "al-3", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -25,6 +27,8 @@ "coverArt": "al-4", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -39,6 +43,8 @@ "coverArt": "al-5", "artistId": "ar-1", "artist": "artist-0", + "artistIds": ["ar-1"], + "artists": ["artist-0"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -53,6 +59,8 @@ "coverArt": "al-7", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -67,6 +75,8 @@ "coverArt": "al-8", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -81,6 +91,8 @@ "coverArt": "al-9", "artistId": "ar-2", "artist": "artist-1", + "artistIds": ["ar-2"], + "artists": ["artist-1"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -95,6 +107,8 @@ "coverArt": "al-11", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -109,6 +123,8 @@ "coverArt": "al-12", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "", @@ -123,6 +139,8 @@ "coverArt": "al-13", "artistId": "ar-3", "artist": "artist-2", + "artistIds": ["ar-3"], + "artists": ["artist-2"], "created": "2019-11-30T00:00:00Z", "title": "", "album": "",