diff --git a/.gitignore b/.gitignore index 56e6d4032..df86721ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.db +*.db.*.bak *db-wal *db-shm *.sql diff --git a/README.md b/README.md index 72d2ecd67..2a18f0184 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,15 @@ password can then be changed from the web interface | `GONIC_TRANSCODE_CACHE_SIZE` | `-transcode-cache-size` | **optional** size of the transcode cache in MB (0 = no limit) | | `GONIC_TRANSCODE_EJECT_INTERVAL` | `-transcode-eject-interval` | **optional** interval (in minutes) to eject transcode cache (0 = never) | | `GONIC_EXPVAR` | `-expvar` | **optional** enable the /debug/vars endpoint (exposes useful debugging attributes as well as database stats) | +| `GONIC_LDAP_FQDN` | `-ldap-fqdn` | **optional** the name of the server to connect to (required for LDAP) | +| `GONIC_LDAP_PORT` | `-ldap-port` | **optional** what port the LDAP server is hosted on (_default_ `389`) | +| `GONIC_LDAP_TLS` | `-ldap-tls` | **optional** whether gonic will connect to the LDAP server using TLS (_default_ `false`) | +| `GONIC_LDAP_BASE_DN` | `-ldap-base-dn` | **optional** the base DN for LDAP objects (required for LDAP, escaping the commas might be necessary if using environmental variable) | +| `GONIC_LDAP_USERNAME_ATTR` | `-ldap-username-attr` | **optional** attribute used by the LDAP server for usernames, gets prepended to BaseDN (_default_ `uid`) | +| `GONIC_LDAP_BIND_USER` | `-ldap-bind-user` | **optional** the bind user to bind to LDAP with (required for LDAP) | +| `GONIC_LDAP_BIND_PASS` | `-ldap-bind-pass` | **optional** the password of the LDAP bind user (required for LDAP) | +| `GONIC_LDAP_FILTER` | `-ldap-filter` | **optional** the filter to select LDAP objects with (escaping the commas might be necessary if using environmental variable) | +| `GONIC_LDAP_ADMIN_FILTER` | `-ldap-admin-filter` | **optional** the filter to select LDAP admin objects with (escaping the commas might be necessary if using environmental variable) (_default_ `(memberof=cn=admin)`) | ## multi valued tags (v0.16+) diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index 6f0872413..58a981362 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -39,6 +39,7 @@ import ( "go.senan.xyz/gonic/infocache/artistinfocache" "go.senan.xyz/gonic/jukebox" "go.senan.xyz/gonic/lastfm" + "go.senan.xyz/gonic/ldap" "go.senan.xyz/gonic/listenbrainz" "go.senan.xyz/gonic/playlist" "go.senan.xyz/gonic/podcast" @@ -83,6 +84,26 @@ func main() { confExcludePattern := flag.String("exclude-pattern", "", "regex pattern to exclude files from scan (optional)") + var ldapConfig ldap.Config + var ldapStore = make(ldap.LDAPStore) + flag.StringVar(&ldapConfig.BindUser, "ldap-bind-user", "", "the bind user to bind to LDAP with (required for LDAP)") + flag.StringVar(&ldapConfig.BindPass, "ldap-bind-pass", "", "the password of the LDAP bind user (required for LDAP)") + flag.StringVar(&ldapConfig.BaseDN, "ldap-base-dn", "", "the base DN for LDAP objects (required for LDAP)") + flag.StringVar(&ldapConfig.UsernameAttr, "ldap-username-attr", "uid", "attribute used by the LDAP server for usernames, gets prepended to BaseDN (optional)") + + flag.StringVar(&ldapConfig.Filter, "ldap-filter", "", "the filter to select LDAP objects with (optional)") + flag.StringVar(&ldapConfig.AdminFilter, "ldap-admin-filter", "(memberof=cn=admin)", "the filter to select LDAP objects with (optional)") + + flag.StringVar(&ldapConfig.FQDN, "ldap-fqdn", "", "the name of the server to connect to (required for LDAP)") + flag.UintVar(&ldapConfig.Port, "ldap-port", 389, "what port the LDAP server is hosted on (optional)") + flag.BoolVar(&ldapConfig.TLS, "ldap-tls", false, "whether gonic will connect to the LDAP server using TLS (optional)") + + if ldapConfig.FQDN != "" { + if ldapConfig.BindUser == "" || ldapConfig.BindPass == "" || ldapConfig.BaseDN == "" { + log.Fatal("a server was provided for an LDAP connection, but configuration is incomplete") + } + } + var confMultiValueGenre, confMultiValueArtist, confMultiValueAlbumArtist multiValueSetting flag.Var(&confMultiValueGenre, "multi-value-genre", "setting for multi-valued genre scanning (optional)") flag.Var(&confMultiValueArtist, "multi-value-artist", "setting for multi-valued track artist scanning (optional)") @@ -252,11 +273,11 @@ func main() { return url.String() } - ctrlAdmin, err := ctrladmin.New(dbc, sessDB, scannr, podcast, lastfmClient, resolveProxyPath) + ctrlAdmin, err := ctrladmin.New(dbc, sessDB, scannr, podcast, lastfmClient, resolveProxyPath, ldapConfig, ldapStore) if err != nil { log.Panicf("error creating admin controller: %v\n", err) } - ctrlSubsonic, err := ctrlsubsonic.New(dbc, scannr, musicPaths, *confPodcastPath, cacheDirAudio, cacheDirCovers, jukebx, playlistStore, scrobblers, podcast, transcoder, lastfmClient, artistInfoCache, albumInfoCache, tagReader, resolveProxyPath) + ctrlSubsonic, err := ctrlsubsonic.New(dbc, scannr, musicPaths, *confPodcastPath, cacheDirAudio, cacheDirCovers, jukebx, playlistStore, scrobblers, podcast, transcoder, lastfmClient, artistInfoCache, albumInfoCache, tagReader, resolveProxyPath, ldapConfig, ldapStore) if err != nil { log.Panicf("error creating subsonic controller: %v\n", err) } diff --git a/db/db.go b/db/db.go index 7ade42f8c..f01a56b99 100644 --- a/db/db.go +++ b/db/db.go @@ -54,19 +54,34 @@ func NewMock(opts url.Values) (*DB, error) { } func (db *DB) InsertBulkLeftMany(table string, head []string, left int, col []int) error { - if len(col) == 0 { + rows := make([][]any, len(col)) + for i, c := range col { + rows[i] = []any{c} + } + return db.InsertBulkLeftManyRows(table, head, left, rows) +} + +func (db *DB) InsertBulkLeftManyRows(table string, head []string, left int, rows [][]any) error { + if len(rows) == 0 { return nil } - var rows []string - var values []any - for _, c := range col { - rows = append(rows, "(?, ?)") - values = append(values, left, c) + tail := len(head) - 1 + placeholder := "(?" + strings.Repeat(", ?", tail) + ")" + + placeholders := make([]string, len(rows)) + values := make([]any, 0, len(rows)*len(head)) + for i, r := range rows { + if len(r) != tail { + return fmt.Errorf("row %d has %d values, expected %d", i, len(r), tail) + } + placeholders[i] = placeholder + values = append(values, left) + values = append(values, r...) } q := fmt.Sprintf("INSERT OR IGNORE INTO %q (%s) VALUES %s", table, strings.Join(head, ", "), - strings.Join(rows, ", "), + strings.Join(placeholders, ", "), ) return db.Exec(q, values...).Error } @@ -211,20 +226,21 @@ 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"` - Artists []*Artist `gorm:"many2many:track_artists"` - Genres []*Genre `gorm:"many2many:track_genres"` - Size int `sql:"default: null"` - Length int `sql:"default: null"` - Bitrate int `sql:"default: null"` - TagTitle string `sql:"default: null"` - TagTitleUDec string `sql:"default: null"` - TagTrackArtist string `sql:"default: null"` - TagTrackNumber int `sql:"default: null"` - TagDiscNumber int `sql:"default: null"` - TagBrainzID string `sql:"default: null"` - TagLyrics string `sql:"default: null"` - TagYear int `sql:"default: null"` + AlbumID int `gorm:"not null; unique_index:idx_folder_filename" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` + Artists []*Artist `gorm:"many2many:track_artists"` + Contributors []*TrackContributor `gorm:"foreignkey:track_id"` + Genres []*Genre `gorm:"many2many:track_genres"` + Size int `sql:"default: null"` + Length int `sql:"default: null"` + Bitrate int `sql:"default: null"` + TagTitle string `sql:"default: null"` + TagTitleUDec string `sql:"default: null"` + TagTrackArtist string `sql:"default: null"` + TagTrackNumber int `sql:"default: null"` + TagDiscNumber int `sql:"default: null"` + TagBrainzID string `sql:"default: null"` + TagLyrics string `sql:"default: null"` + TagYear int `sql:"default: null"` ReplayGainTrackGain float32 ReplayGainTrackPeak float32 @@ -288,7 +304,7 @@ type User struct { ID int `gorm:"primary_key"` CreatedAt time.Time Name string `gorm:"not null; unique_index" sql:"default: null"` - Password string `gorm:"not null" sql:"default: null"` + Password string `sql:"default: null"` LastFMSession string `sql:"default: null"` ListenBrainzURL string `sql:"default: null"` ListenBrainzToken string `sql:"default: null"` @@ -404,6 +420,24 @@ type TrackArtist struct { ArtistID int `gorm:"not null; unique_index:idx_track_id_artist_id" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` } +type ContributorRole string + +const ( + ContributorRoleComposer ContributorRole = "composer" + ContributorRoleLyricist ContributorRole = "lyricist" + ContributorRoleRemixer ContributorRole = "remixer" + ContributorRoleConductor ContributorRole = "conductor" + ContributorRoleProducer ContributorRole = "producer" + ContributorRoleArranger ContributorRole = "arranger" +) + +type TrackContributor struct { + TrackID int `gorm:"not null; unique_index:idx_track_contributor" sql:"default: null; type:int REFERENCES tracks(id) ON DELETE CASCADE"` + ArtistID int `gorm:"not null; unique_index:idx_track_contributor" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` + Role ContributorRole `gorm:"not null; unique_index:idx_track_contributor" sql:"default: null"` + Artist *Artist +} + type ArtistAppearances struct { ArtistID int `gorm:"not null; unique_index:idx_artist_id_album_id" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` AlbumID int `gorm:"not null; unique_index:idx_artist_id_album_id" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` diff --git a/db/migrations.go b/db/migrations.go index 610b86801..895da4e9a 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -84,6 +84,7 @@ func (db *DB) Migrate(ctx MigrationContext) error { construct(ctx, "202512021147", migrateAlbumAddIndexOnCreatedAt), construct(ctx, "202601201000", migrateAddAlbumDiscTitles), construct(ctx, "202602061800", migrateAddTrackYear), + construct(ctx, "202604231200", migrateAddTrackContributors), } return gormigrate. @@ -881,6 +882,10 @@ func migrateAddAlbumDiscTitles(tx *gorm.DB, _ MigrationContext) error { return tx.AutoMigrate(AlbumDiscTitle{}).Error } +func migrateAddTrackContributors(tx *gorm.DB, _ MigrationContext) error { + return tx.AutoMigrate(TrackContributor{}).Error +} + func migrateAddTrackYear(tx *gorm.DB, _ MigrationContext) error { step := tx.AutoMigrate(Track{}) if err := step.Error; err != nil { diff --git a/go.mod b/go.mod index f86922dd7..9935c881e 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/env25/mpdlrc v0.7.4 github.com/fatih/structs v1.1.0 github.com/fsnotify/fsnotify v1.9.0 + github.com/go-ldap/ldap/v3 v3.4.6 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.2 @@ -38,11 +39,13 @@ require ( ) require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/PuerkitoBio/goquery v1.12.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/swag/jsonname v0.26.0 // indirect github.com/gorilla/context v1.1.2 // indirect diff --git a/go.sum b/go.sum index 5b0f9b193..ad9bf7343 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ cloud.google.com/go v0.33.1/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -8,6 +10,8 @@ github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuN github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= @@ -37,6 +41,10 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= +github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A= +github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= @@ -57,6 +65,7 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= @@ -143,8 +152,11 @@ github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7 github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 h1:sLILANWN76ja66/K4k/mBqJuCjDZaM67w+Ru6rEB0s0= github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981/go.mod h1:Rx8XB1ck+so+41uu9VY1gMKs1CPQ2NTq0pzf+OCCQHo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= diff --git a/ldap/ldap.go b/ldap/ldap.go new file mode 100644 index 000000000..5de1c8b30 --- /dev/null +++ b/ldap/ldap.go @@ -0,0 +1,225 @@ +package ldap + +import ( + "errors" + "fmt" + "log" + "time" + + "go.senan.xyz/gonic/db" + + "github.com/go-ldap/ldap/v3" +) + +// LDAPStore maps users to a cached password +type LDAPStore map[string]CachedLDAPpassword + +// Add caches a password username set. +func (store LDAPStore) Add(username, password string) { + store[username] = CachedLDAPpassword{ + Password: password, + ExpiresAt: time.Now().Add(time.Hour * 8), // Keep the password valid for 8 hours. + } +} + +// IsValid checks if a user's password is stored in the cache and checks if a +// given password is valid. +func (store LDAPStore) IsValid(username, password string) bool { + cached, ok := store[username] + if !ok { + return false + } + + if cached.Password != password { + return false + } + + return cached.IsValid() +} + +// CachedLDAPpassword stores an LDAP user's password and a time at which the +// server should no longer accept it. +type CachedLDAPpassword struct { + Password string + ExpiresAt time.Time +} + +func (password CachedLDAPpassword) IsValid() bool { + return password.ExpiresAt.After(time.Now()) +} + +// Cofig stores the user's LDAP server options. +type Config struct { + BindUser string + BindPass string + BaseDN string + UsernameAttr string + + Filter string + AdminFilter string + + FQDN string + Port uint + TLS bool +} + +func (c Config) IsSetup() bool { + // This is basically checking if LDAP is setup, if ldapFQDN isn't set we can + // assume that the user hasn't configured LDAP. + return c.FQDN != "" +} + +func CheckLDAPcreds(username string, password string, dbc *db.DB, config Config, store LDAPStore) (bool, error) { + if !config.IsSetup() { + return false, nil + } + + if store.IsValid(username, password) { + log.Println("Password authenticated via cache!") + return true, nil + } + + log.Println("Checking password against LDAP server ...") + + // Now, we can try to connect to the LDAP server. + l, err := createLDAPconnection(config) + if err != nil { + // Return a generic error. + log.Println("Failed to connect to LDAP server:", err) + return false, errors.New("failed to connect to LDAP server") + } + defer l.Close() + + // Create the user if it doesn't exist on the database already. + err = createUserFromLDAP(username, dbc, config, l) + if err != nil { + log.Println("Failed to create user from LDAP:", err) + return false, err + } + + // After we have a connection, let's try binding + _, err = l.SimpleBind(&ldap.SimpleBindRequest{ + Username: fmt.Sprintf("%s=%s,%s", config.UsernameAttr, username, config.BaseDN), + Password: password, + }) + + if err == nil { + // Authentication was OK + store.Add(username, password) + return true, nil + } + + log.Println("Failed to bind to LDAP server:", err) + return false, nil +} + +// Creates a user from creds +func createUserFromLDAP(username string, dbc *db.DB, config Config, l *ldap.Conn) error { + user := dbc.GetUserByName(username) + if user != nil { + return nil + } + + if !config.IsSetup() { + return nil + } + + isAdmin := doesLDAPAdminExist(username, config, l) + log.Println(username, isAdmin) + + if !doesLDAPUserExist(username, config, l) { + return errors.New("no such user") + } + + newUser := db.User{ + Name: username, + Password: "", // no password because we want auth to fail. + IsAdmin: isAdmin, + } + + err := dbc.Create(&newUser).Error + if err != nil { + return err + } + + log.Println("User created via LDAP:", username) + return nil +} + +// doesLDAPAdminExist checks if an admin exists on the server. +func doesLDAPAdminExist(username string, config Config, l *ldap.Conn) bool { + filter := fmt.Sprintf("(&(%s=%s)%s)", config.UsernameAttr, ldap.EscapeFilter(username), config.AdminFilter) + + searchReq := ldap.NewSearchRequest( + config.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + filter, + []string{"dn"}, + nil, + ) + + result, err := l.Search(searchReq) + if err != nil { + log.Println("failed to query LDAP server:", err) + return false + } + + if len(result.Entries) == 1 { + return true + } + + return false +} + +// doesLDAPUserExist checks if a user exists on the server. +func doesLDAPUserExist(username string, config Config, l *ldap.Conn) bool { + filter := fmt.Sprintf("(&(%s=%s)%s)", config.UsernameAttr, ldap.EscapeFilter(username), config.Filter) + + searchReq := ldap.NewSearchRequest( + config.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + filter, + []string{"dn"}, + nil, + ) + + result, err := l.Search(searchReq) + if err != nil { + log.Println("failed to query LDAP server:", err) + return false + } + + if len(result.Entries) == 1 { + return true + } + + return false +} + +// Creates a connection to an LDAP server. +func createLDAPconnection(config Config) (*ldap.Conn, error) { + protocol := "ldap" + if config.TLS { + protocol = "ldaps" + } + + // Now, we can try to connect to the LDAP server. + l, err := ldap.DialURL(fmt.Sprintf("%s://%s:%d", protocol, config.FQDN, config.Port)) + if err != nil { + // Warn the server and return the error. + log.Println("Failed to connect to LDAP server", err) + return nil, err + } + + // After we have a connection, let's try binding + _, err = l.SimpleBind(&ldap.SimpleBindRequest{ + Username: fmt.Sprintf("%s=%s,%s", config.UsernameAttr, config.BindUser, config.BaseDN), + Password: config.BindPass, + }) + if err != nil { + log.Println("Failed to bind to LDAP:", err) + return nil, errors.New("wrong username or password") + } + + return l, nil +} diff --git a/scanner/scanner.go b/scanner/scanner.go index 25357c264..0b096dc55 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -485,6 +485,14 @@ func (s *Scanner) populateTrackAndArtists(tx *db.DB, st *State, i int, album *db return fmt.Errorf("populate track artists: %w", err) } + contributorIDs, err := populateTrackContributors(tx, track, trags) + if err != nil { + return fmt.Errorf("populate track contributors: %w", err) + } + if err := populateArtistAppearances(tx, album, contributorIDs); err != nil { + return fmt.Errorf("populate contributor appearances: %w", err) + } + // possible album level embedded covers come only from the first track if i == 0 { if err := populateAlbumEmbeddedCover(tx, s.scanEmbeddedCover, album, track, trprops); err != nil { @@ -686,6 +694,46 @@ func populateAlbumArtists(tx *db.DB, album *db.Album, albumArtistIDs []int) erro return nil } +//nolint:gochecknoglobals +var contributorTagKeys = []struct { + keys []string + role db.ContributorRole +}{ + {[]string{normtag.Remixers, normtag.Remixer}, db.ContributorRoleRemixer}, + {[]string{normtag.Composers, normtag.Composer}, db.ContributorRoleComposer}, + {[]string{normtag.Lyricists, normtag.Lyricist}, db.ContributorRoleLyricist}, + {[]string{normtag.Conductors, normtag.Conductor}, db.ContributorRoleConductor}, + {[]string{normtag.Producers, normtag.Producer}, db.ContributorRoleProducer}, + {[]string{normtag.Arrangers, normtag.Arranger}, db.ContributorRoleArranger}, +} + +func populateTrackContributors(tx *db.DB, track *db.Track, trags tags.Tags) ([]int, error) { + if err := tx.Where("track_id=?", track.ID).Delete(db.TrackContributor{}).Error; err != nil { + return nil, fmt.Errorf("delete old track contributors: %w", err) + } + + var rows [][]any + var artistIDs []int + for _, rk := range contributorTagKeys { + for _, name := range tags.FirstValues(trags, rk.keys...) { + if name == "" { + continue + } + artist, err := populateArtist(tx, name) + if err != nil { + return nil, fmt.Errorf("populate contributor artist: %w", err) + } + rows = append(rows, []any{artist.ID, string(rk.role)}) + artistIDs = append(artistIDs, artist.ID) + } + } + + if err := tx.InsertBulkLeftManyRows("track_contributors", []string{"track_id", "artist_id", "role"}, track.ID, rows); err != nil { + return nil, fmt.Errorf("insert bulk track contributors: %w", err) + } + return artistIDs, nil +} + func populateTrackArtists(tx *db.DB, track *db.Track, trackArtistIDs []int) error { if err := tx.Where("track_id=?", track.ID).Delete(db.TrackArtist{}).Error; err != nil { return fmt.Errorf("delete old track artists: %w", err) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 0136f6bc3..31e68bcb3 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -461,6 +461,74 @@ func TestMultiFolderWithSharedArtist(t *testing.T) { } } +func TestTrackContributors(t *testing.T) { + t.Parallel() + m := mockfs.New(t) + + m.AddTrack("artist-a/album-a/track-1.flac") + m.SetTags("artist-a/album-a/track-1.flac", func(tags *mockfs.TagInfo) { + normtag.Set(tags.Tags, normtag.Artist, "artist-a") + normtag.Set(tags.Tags, normtag.AlbumArtist, "artist-a") + normtag.Set(tags.Tags, normtag.Album, "album-a") + normtag.Set(tags.Tags, normtag.Title, "track-1") + normtag.Set(tags.Tags, normtag.Composers, "comp-a", "comp-b") + normtag.Set(tags.Tags, normtag.Remixer, "rem-a") + normtag.Set(tags.Tags, "LYRICIST", "lyr-a") + normtag.Set(tags.Tags, "PRODUCER", "prod-a") + }) + m.ScanAndClean() + + var track db.Track + require.NoError(t, m.DB().Preload("Contributors.Artist").Where("tag_title=?", "track-1").First(&track).Error) + + type pair struct { + Role db.ContributorRole + Artist string + } + var got []pair + for _, c := range track.Contributors { + got = append(got, pair{Role: c.Role, Artist: c.Artist.Name}) + } + assert.ElementsMatch(t, []pair{ + {db.ContributorRoleComposer, "comp-a"}, + {db.ContributorRoleComposer, "comp-b"}, + {db.ContributorRoleRemixer, "rem-a"}, + {db.ContributorRoleLyricist, "lyr-a"}, + {db.ContributorRoleProducer, "prod-a"}, + }, got) + + // contributor artists should also show up on the album via artist_appearances + for _, name := range []string{"comp-a", "comp-b", "rem-a", "lyr-a", "prod-a"} { + var count int + require.NoError(t, m.DB(). + Model(&db.ArtistAppearances{}). + Joins("JOIN artists ON artists.id=artist_appearances.artist_id"). + Where("artists.name=?", name). + Count(&count).Error) + assert.Equal(t, 1, count, "expected %q in artist_appearances", name) + } + + // re-scanning with a changed composer list should clear the stale row (comp-b) + // and add the new one (comp-c). other roles stay since their tags didn't change. + m.SetTags("artist-a/album-a/track-1.flac", func(tags *mockfs.TagInfo) { + normtag.Set(tags.Tags, normtag.Composers, "comp-a", "comp-c") + }) + m.ScanAndClean() + + require.NoError(t, m.DB().Preload("Contributors.Artist").Where("tag_title=?", "track-1").First(&track).Error) + got = got[:0] + for _, c := range track.Contributors { + got = append(got, pair{Role: c.Role, Artist: c.Artist.Name}) + } + assert.ElementsMatch(t, []pair{ + {db.ContributorRoleComposer, "comp-a"}, + {db.ContributorRoleComposer, "comp-c"}, + {db.ContributorRoleRemixer, "rem-a"}, + {db.ContributorRoleLyricist, "lyr-a"}, + {db.ContributorRoleProducer, "prod-a"}, + }, got) +} + func TestSymlinkedAlbum(t *testing.T) { t.Parallel() m := mockfs.NewWithDirs(t, []string{"scan"}) diff --git a/server/ctrladmin/ctrl.go b/server/ctrladmin/ctrl.go index 3722f7223..e174a5f39 100644 --- a/server/ctrladmin/ctrl.go +++ b/server/ctrladmin/ctrl.go @@ -27,6 +27,7 @@ import ( "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/handlerutil" "go.senan.xyz/gonic/lastfm" + "go.senan.xyz/gonic/ldap" "go.senan.xyz/gonic/podcast" "go.senan.xyz/gonic/scanner" "go.senan.xyz/gonic/server/ctrladmin/adminui" @@ -48,11 +49,13 @@ type Controller struct { podcasts *podcast.Podcasts lastfmClient *lastfm.Client resolveProxyPath ProxyPathResolver + ldapConfig ldap.Config + ldapStore ldap.LDAPStore } type ProxyPathResolver func(in string) string -func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts *podcast.Podcasts, lastfmClient *lastfm.Client, resolveProxyPath ProxyPathResolver) (*Controller, error) { +func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts *podcast.Podcasts, lastfmClient *lastfm.Client, resolveProxyPath ProxyPathResolver, ldapConfig ldap.Config, ldapStore ldap.LDAPStore) (*Controller, error) { c := Controller{ ServeMux: http.NewServeMux(), @@ -62,6 +65,8 @@ func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts podcasts: podcasts, lastfmClient: lastfmClient, resolveProxyPath: resolveProxyPath, + ldapConfig: ldapConfig, + ldapStore: ldapStore, } resp := respHandler(adminui.TemplatesFS, resolveProxyPath) diff --git a/server/ctrladmin/handlers_raw.go b/server/ctrladmin/handlers_raw.go index 49d9e2a84..4099651f7 100644 --- a/server/ctrladmin/handlers_raw.go +++ b/server/ctrladmin/handlers_raw.go @@ -1,28 +1,48 @@ package ctrladmin import ( + "log" "net/http" "github.com/gorilla/sessions" + "go.senan.xyz/gonic/ldap" ) func (c *Controller) ServeLoginDo(w http.ResponseWriter, r *http.Request) { session := r.Context().Value(CtxSession).(*sessions.Session) username := r.FormValue("username") password := r.FormValue("password") + user := c.dbc.GetUserByName(username) if username == "" || password == "" { sessAddFlashW(session, []string{"please provide username and password"}) sessLogSave(session, w, r) http.Redirect(w, r, r.Referer(), http.StatusSeeOther) return } - user := c.dbc.GetUserByName(username) - if user == nil || password != user.Password { + + if c.ldapConfig.IsSetup() { + ok, err := ldap.CheckLDAPcreds(username, password, c.dbc, c.ldapConfig, c.ldapStore) + if err != nil { + log.Println("Failed to check LDAP credentials:", err) + sessAddFlashW(session, []string{"failed to check LDAP credentials"}) + sessLogSave(session, w, r) + http.Redirect(w, r, r.Referer(), http.StatusSeeOther) + return + } else if !ok { + sessAddFlashW(session, []string{"invalid username / password"}) + sessLogSave(session, w, r) + http.Redirect(w, r, r.Referer(), http.StatusSeeOther) + return + } + } else if user == nil || user.Password != password { sessAddFlashW(session, []string{"invalid username / password"}) sessLogSave(session, w, r) http.Redirect(w, r, r.Referer(), http.StatusSeeOther) return } + + user = c.dbc.GetUserByName(username) + // put the user name into the session. future endpoints after this one // are wrapped with WithUserSession() which will get the name from the // session and put the row into the request context diff --git a/server/ctrlsubsonic/ctrl.go b/server/ctrlsubsonic/ctrl.go index a548909eb..424335366 100644 --- a/server/ctrlsubsonic/ctrl.go +++ b/server/ctrlsubsonic/ctrl.go @@ -10,6 +10,7 @@ import ( "io" "log" "net/http" + "strings" "time" "go.senan.xyz/gonic/db" @@ -18,6 +19,7 @@ import ( "go.senan.xyz/gonic/infocache/artistinfocache" "go.senan.xyz/gonic/jukebox" "go.senan.xyz/gonic/lastfm" + "go.senan.xyz/gonic/ldap" "go.senan.xyz/gonic/playlist" "go.senan.xyz/gonic/podcast" "go.senan.xyz/gonic/scanner" @@ -72,7 +74,7 @@ type Controller struct { resolveProxyPath ProxyPathResolver } -func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPath string, cacheAudioPath string, cacheCoverPath string, jukebox *jukebox.Jukebox, playlistStore *playlist.Store, scrobblers []scrobble.Scrobbler, podcasts *podcast.Podcasts, transcoder transcode.Transcoder, lastFMClient *lastfm.Client, artistInfoCache *artistinfocache.ArtistInfoCache, albumInfoCache *albuminfocache.AlbumInfoCache, tagReader tags.Reader, resolveProxyPath ProxyPathResolver) (*Controller, error) { +func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPath string, cacheAudioPath string, cacheCoverPath string, jukebox *jukebox.Jukebox, playlistStore *playlist.Store, scrobblers []scrobble.Scrobbler, podcasts *podcast.Podcasts, transcoder transcode.Transcoder, lastFMClient *lastfm.Client, artistInfoCache *artistinfocache.ArtistInfoCache, albumInfoCache *albuminfocache.AlbumInfoCache, tagReader tags.Reader, resolveProxyPath ProxyPathResolver, ldapConfig ldap.Config, ldapStore ldap.LDAPStore) (*Controller, error) { c := Controller{ ServeMux: http.NewServeMux(), @@ -98,7 +100,7 @@ func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPa chain := handlerutil.Chain( withParams, withRequiredParams, - withUser(dbc), + withUser(dbc, ldapConfig, ldapStore), ) chainRaw := handlerutil.Chain( chain, @@ -229,39 +231,82 @@ func withRequiredParams(next http.Handler) http.Handler { }) } -func withUser(dbc *db.DB) handlerutil.Middleware { +func withUser(dbc *db.DB, ldapConfig ldap.Config, ldapStore ldap.LDAPStore) handlerutil.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { params := r.Context().Value(CtxParams).(params.Params) // ignoring errors here, a middleware has already ensured they exist username, _ := params.Get("u") - password, _ := params.Get("p") + passwordHex, _ := params.Get("p") token, _ := params.Get("t") salt, _ := params.Get("s") passwordAuth := token == "" && salt == "" - tokenAuth := password == "" + tokenAuth := passwordHex == "" if tokenAuth == passwordAuth { _ = writeResp(w, r, spec.NewError(10, "please provide `t` and `s`, or just `p`")) return } + + var password string + if passwordAuth && passwordHex != "" { + if strings.HasPrefix(passwordHex, "enc:") { + raw := strings.TrimPrefix(passwordHex, "enc:") + decoded, err := hex.DecodeString(raw) + if err != nil { + log.Println("Failed to decode hex password:", err) + _ = writeResp(w, r, spec.NewError(40, "invalid password encoding")) + return + } + password = string(decoded) + } else { + // if not prefixed with "enc:", treat as plain text + password = passwordHex + } + } + user := dbc.GetUserByName(username) + + if ldapConfig.IsSetup() { + // Complete auth using LDAP + log.Println("Authenticating using LDAP ...") + + ok, err := ldap.CheckLDAPcreds(username, password, dbc, ldapConfig, ldapStore) + if err != nil { + log.Println("Failed to check LDAP creds:", err) + _ = writeResp(w, r, spec.NewError(40, "invalid password")) + return + } + + if !ok { + _ = writeResp(w, r, spec.NewError(40, "invalid password")) + return + } + + withUser := context.WithValue(r.Context(), CtxUser, user) + next.ServeHTTP(w, r.WithContext(withUser)) + return + } + + log.Println("Authenticating using built-in ...") if user == nil { - _ = writeResp(w, r, spec.NewError(40, - "invalid username %q", username)) + _ = writeResp(w, r, spec.NewError(40, "invalid password")) return } + var credsOk bool if tokenAuth { credsOk = checkCredsToken(user.Password, token, salt) } else { credsOk = checkCredsBasic(user.Password, password) } + if !credsOk { _ = writeResp(w, r, spec.NewError(40, "invalid password")) return } + withUser := context.WithValue(r.Context(), CtxUser, user) next.ServeHTTP(w, r.WithContext(withUser)) }) diff --git a/server/ctrlsubsonic/handlers_bookmark.go b/server/ctrlsubsonic/handlers_bookmark.go index 8c0490b6b..f472b63e8 100644 --- a/server/ctrlsubsonic/handlers_bookmark.go +++ b/server/ctrlsubsonic/handlers_bookmark.go @@ -44,6 +44,7 @@ func (c *Controller) ServeGetBookmarks(r *http.Request) *spec.Response { Preload("Album"). Preload("Album.Artists"). Preload("Artists"). + Preload("Contributors.Artist"). Find(&track, "id=?", bookmark.EntryID). Error if err != nil { diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index 5ac0e32c5..995d954ff 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -113,6 +113,7 @@ func (c *Controller) ServeGetAlbum(r *http.Request) *spec.Response { return db. Order("tracks.tag_disc_number, tracks.tag_track_number"). Preload("Artists"). + Preload("Contributors.Artist"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID) }). @@ -300,6 +301,7 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response { Preload("Album.Artists"). Preload("Genres"). Preload("Artists"). + Preload("Contributors.Artist"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID) switch { @@ -479,6 +481,7 @@ func (c *Controller) ServeGetSongsByGenre(r *http.Request) *spec.Response { Preload("Album"). Preload("Album.Artists"). Preload("Artists"). + Preload("Contributors.Artist"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). Offset(params.GetOrInt("offset", 0)). @@ -564,6 +567,7 @@ func (c *Controller) ServeGetStarredTwo(r *http.Request) *spec.Response { Preload("Album"). Preload("Album.Artists"). Preload("Artists"). + Preload("Contributors.Artist"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID) if m := getMusicFolder(c.musicPaths, params); m != "" { @@ -637,6 +641,7 @@ func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response { Where("artists.id=?", artist.ID). Preload("Album"). Preload("Artists"). + Preload("Contributors.Artist"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). Group("tracks.id"). @@ -746,6 +751,7 @@ func getSimilarSongsFromTrack(c *Controller, id specid.ID, params params.Params, Select("tracks.*"). Preload("Album"). Preload("Artists"). + Preload("Contributors.Artist"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). Where("tracks.tag_title IN (?)", similarTrackNames). @@ -799,6 +805,7 @@ func getSimilarSongsFromArtist(c *Controller, id specid.ID, params params.Params err = c.dbc. Preload("Album"). Preload("Artists"). + Preload("Contributors.Artist"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). Joins("JOIN track_artists ON track_artists.track_id=tracks.id"). @@ -867,6 +874,7 @@ func getSimilarSongsFromAlbum(c *Controller, id specid.ID, params params.Params, Select("tracks.*"). Preload("Album"). Preload("Artists"). + Preload("Contributors.Artist"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). Where("tracks.tag_title IN (?)", similarTrackNames). diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index 1a1ab1562..6bb9983a2 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -293,6 +293,7 @@ func (c *Controller) ServeGetSong(r *http.Request) *spec.Response { Preload("Album"). Preload("Album.Artists"). Preload("Artists"). + Preload("Contributors.Artist"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). First(&track). @@ -320,6 +321,7 @@ func (c *Controller) ServeGetRandomSongs(r *http.Request) *spec.Response { Preload("Album"). Preload("Album.Artists"). Preload("Artists"). + Preload("Contributors.Artist"). Preload("TrackStar", "user_id=?", user.ID). Preload("TrackRating", "user_id=?", user.ID). Joins("JOIN albums ON tracks.album_id=albums.id"). @@ -398,7 +400,7 @@ func (c *Controller) ServeJukebox(r *http.Request) *spec.Response { // nolint:go switch id.Type { case specid.Track: var track db.Track - if err := c.dbc.Where("id=?", id.Value).Preload("Album").Preload("Album.Artists").Preload("Artists").Find(&track).Error; err != nil { + if err := c.dbc.Where("id=?", id.Value).Preload("Album").Preload("Album.Artists").Preload("Artists").Preload("Contributors.Artist").Find(&track).Error; err != nil { return nil, fmt.Errorf("load track: %w", err) } ret = append(ret, spec.NewTrackByTags(&track, track.Album)) diff --git a/server/ctrlsubsonic/spec/construct_by_tags.go b/server/ctrlsubsonic/spec/construct_by_tags.go index fa0e7f542..8fef055da 100644 --- a/server/ctrlsubsonic/spec/construct_by_tags.go +++ b/server/ctrlsubsonic/spec/construct_by_tags.go @@ -3,7 +3,9 @@ package spec import ( "cmp" "path/filepath" + "slices" "sort" + "strings" "go.senan.xyz/gonic/db" ) @@ -118,12 +120,8 @@ func NewTrackByTags(t *db.Track, album *db.Album) *TrackChild { ret.UserRating = t.TrackRating.Rating } - sort.Slice(t.Artists, func(i, j int) bool { - return t.Artists[i].ID < t.Artists[j].ID - }) - sort.Slice(album.Artists, func(i, j int) bool { - return album.Artists[i].ID < album.Artists[j].ID - }) + slices.SortFunc(t.Artists, func(a, b *db.Artist) int { return cmp.Compare(a.ID, b.ID) }) + slices.SortFunc(album.Artists, func(a, b *db.Artist) int { return cmp.Compare(a.ID, b.ID) }) switch { case len(t.Artists) > 0: @@ -145,6 +143,29 @@ func NewTrackByTags(t *db.Track, album *db.Album) *TrackChild { for _, a := range album.Artists { ret.AlbumArtists = append(ret.AlbumArtists, &ArtistRef{ID: a.SID(), Name: a.Name}) } + + slices.SortStableFunc(t.Contributors, func(a, b *db.TrackContributor) int { + return cmp.Or( + cmp.Compare(a.Role, b.Role), + cmp.Compare(a.ArtistID, b.ArtistID), + ) + }) + + var composers []string + for _, c := range t.Contributors { + if c.Artist == nil { + continue + } + ret.Contributors = append(ret.Contributors, &Contributor{ + Role: string(c.Role), + Artist: &ArtistRef{ID: c.Artist.SID(), Name: c.Artist.Name}, + }) + if c.Role == db.ContributorRoleComposer { + composers = append(composers, c.Artist.Name) + } + } + ret.DisplayComposer = strings.Join(composers, ", ") + if t.ReplayGainTrackGain != 0 || t.ReplayGainAlbumGain != 0 { ret.ReplayGain = &ReplayGain{ TrackGain: t.ReplayGainTrackGain, diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go index 022c24a5c..b23d2ba96 100644 --- a/server/ctrlsubsonic/spec/spec.go +++ b/server/ctrlsubsonic/spec/spec.go @@ -125,6 +125,12 @@ type GenreRef struct { Name string `xml:"name,attr" json:"name"` } +// https://opensubsonic.netlify.app/docs/responses/contributor/ +type Contributor struct { + Role string `xml:"role,attr" json:"role"` + Artist *ArtistRef `xml:"artist" json:"artist"` +} + type DiscTitle struct { Disc int `xml:"disc,attr" json:"disc"` Title string `xml:"title,attr" json:"title"` @@ -204,6 +210,9 @@ type TrackChild struct { AlbumArtists []*ArtistRef `xml:"albumArtists" json:"albumArtists"` AlbumDisplayArtist string `xml:"displayAlbumArtist,attr" json:"displayAlbumArtist"` + Contributors []*Contributor `xml:"contributors" json:"contributors"` + DisplayComposer string `xml:"displayComposer,attr" json:"displayComposer"` + Bitrate int `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"` ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"` CoverID *specid.ID `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` diff --git a/server/ctrlsubsonic/testdata/test_get_album_list_random b/server/ctrlsubsonic/testdata/test_get_album_list_random index d4128f478..238d7f2a3 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_list_random +++ b/server/ctrlsubsonic/testdata/test_get_album_list_random @@ -8,17 +8,17 @@ "albumList": { "album": [ { - "id": "al-11", + "id": "al-8", "created": "2019-11-30T00:00:00Z", - "artist": "artist-2", + "artist": "artist-1", "artists": null, "displayArtist": "", - "title": "album-0", - "album": "album-0", - "parent": "al-10", + "title": "album-1", + "album": "album-1", + "parent": "al-6", "isDir": true, - "coverArt": "al-11", - "name": "album-0", + "coverArt": "al-8", + "name": "album-1", "songCount": 3, "duration": 300, "playCount": 0, @@ -27,16 +27,16 @@ "discTitles": null }, { - "id": "al-13", + "id": "al-5", "created": "2019-11-30T00:00:00Z", - "artist": "artist-2", + "artist": "artist-0", "artists": null, "displayArtist": "", "title": "album-2", "album": "album-2", - "parent": "al-10", + "parent": "al-2", "isDir": true, - "coverArt": "al-13", + "coverArt": "al-5", "name": "album-2", "songCount": 3, "duration": 300, @@ -45,6 +45,25 @@ "releaseTypes": [], "discTitles": null }, + { + "id": "al-11", + "created": "2019-11-30T00:00:00Z", + "artist": "artist-2", + "artists": null, + "displayArtist": "", + "title": "album-0", + "album": "album-0", + "parent": "al-10", + "isDir": true, + "coverArt": "al-11", + "name": "album-0", + "songCount": 3, + "duration": 300, + "playCount": 0, + "isCompilation": false, + "releaseTypes": [], + "discTitles": null + }, { "id": "al-7", "created": "2019-11-30T00:00:00Z", @@ -65,16 +84,16 @@ "discTitles": null }, { - "id": "al-5", + "id": "al-9", "created": "2019-11-30T00:00:00Z", - "artist": "artist-0", + "artist": "artist-1", "artists": null, "displayArtist": "", "title": "album-2", "album": "album-2", - "parent": "al-2", + "parent": "al-6", "isDir": true, - "coverArt": "al-5", + "coverArt": "al-9", "name": "album-2", "songCount": 3, "duration": 300, @@ -84,17 +103,17 @@ "discTitles": null }, { - "id": "al-4", + "id": "al-3", "created": "2019-11-30T00:00:00Z", "artist": "artist-0", "artists": null, "displayArtist": "", - "title": "album-1", - "album": "album-1", + "title": "album-0", + "album": "album-0", "parent": "al-2", "isDir": true, - "coverArt": "al-4", - "name": "album-1", + "coverArt": "al-3", + "name": "album-0", "songCount": 3, "duration": 300, "playCount": 0, @@ -103,16 +122,16 @@ "discTitles": null }, { - "id": "al-8", + "id": "al-4", "created": "2019-11-30T00:00:00Z", - "artist": "artist-1", + "artist": "artist-0", "artists": null, "displayArtist": "", "title": "album-1", "album": "album-1", - "parent": "al-6", + "parent": "al-2", "isDir": true, - "coverArt": "al-8", + "coverArt": "al-4", "name": "album-1", "songCount": 3, "duration": 300, @@ -122,16 +141,16 @@ "discTitles": null }, { - "id": "al-9", + "id": "al-13", "created": "2019-11-30T00:00:00Z", - "artist": "artist-1", + "artist": "artist-2", "artists": null, "displayArtist": "", "title": "album-2", "album": "album-2", - "parent": "al-6", + "parent": "al-10", "isDir": true, - "coverArt": "al-9", + "coverArt": "al-13", "name": "album-2", "songCount": 3, "duration": 300, @@ -158,25 +177,6 @@ "isCompilation": false, "releaseTypes": [], "discTitles": null - }, - { - "id": "al-3", - "created": "2019-11-30T00:00:00Z", - "artist": "artist-0", - "artists": null, - "displayArtist": "", - "title": "album-0", - "album": "album-0", - "parent": "al-2", - "isDir": true, - "coverArt": "al-3", - "name": "album-0", - "songCount": 3, - "duration": 300, - "playCount": 0, - "isCompilation": false, - "releaseTypes": [], - "discTitles": null } ] } diff --git a/server/ctrlsubsonic/testdata/test_get_album_list_two_random b/server/ctrlsubsonic/testdata/test_get_album_list_two_random index 881d8fea8..12daad19b 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_list_two_random +++ b/server/ctrlsubsonic/testdata/test_get_album_list_two_random @@ -8,15 +8,15 @@ "albumList2": { "album": [ { - "id": "al-12", + "id": "al-8", "created": "2019-11-30T00:00:00Z", - "artistId": "ar-3", - "artist": "artist-2", - "artists": [{ "id": "ar-3", "name": "artist-2" }], - "displayArtist": "artist-2", + "artistId": "ar-2", + "artist": "artist-1", + "artists": [{ "id": "ar-2", "name": "artist-1" }], + "displayArtist": "artist-1", "title": "album-1", "album": "album-1", - "coverArt": "al-12", + "coverArt": "al-8", "name": "album-1", "songCount": 3, "duration": 300, @@ -29,16 +29,16 @@ "discTitles": [] }, { - "id": "al-5", + "id": "al-11", "created": "2019-11-30T00:00:00Z", - "artistId": "ar-1", - "artist": "artist-0", - "artists": [{ "id": "ar-1", "name": "artist-0" }], - "displayArtist": "artist-0", - "title": "album-2", - "album": "album-2", - "coverArt": "al-5", - "name": "album-2", + "artistId": "ar-3", + "artist": "artist-2", + "artists": [{ "id": "ar-3", "name": "artist-2" }], + "displayArtist": "artist-2", + "title": "album-0", + "album": "album-0", + "coverArt": "al-11", + "name": "album-0", "songCount": 3, "duration": 300, "playCount": 0, @@ -50,16 +50,16 @@ "discTitles": [] }, { - "id": "al-9", + "id": "al-7", "created": "2019-11-30T00:00:00Z", "artistId": "ar-2", "artist": "artist-1", "artists": [{ "id": "ar-2", "name": "artist-1" }], "displayArtist": "artist-1", - "title": "album-2", - "album": "album-2", - "coverArt": "al-9", - "name": "album-2", + "title": "album-0", + "album": "album-0", + "coverArt": "al-7", + "name": "album-0", "songCount": 3, "duration": 300, "playCount": 0, @@ -92,16 +92,16 @@ "discTitles": [] }, { - "id": "al-11", + "id": "al-12", "created": "2019-11-30T00:00:00Z", "artistId": "ar-3", "artist": "artist-2", "artists": [{ "id": "ar-3", "name": "artist-2" }], "displayArtist": "artist-2", - "title": "album-0", - "album": "album-0", - "coverArt": "al-11", - "name": "album-0", + "title": "album-1", + "album": "album-1", + "coverArt": "al-12", + "name": "album-1", "songCount": 3, "duration": 300, "playCount": 0, @@ -113,16 +113,16 @@ "discTitles": [] }, { - "id": "al-7", + "id": "al-13", "created": "2019-11-30T00:00:00Z", - "artistId": "ar-2", - "artist": "artist-1", - "artists": [{ "id": "ar-2", "name": "artist-1" }], - "displayArtist": "artist-1", - "title": "album-0", - "album": "album-0", - "coverArt": "al-7", - "name": "album-0", + "artistId": "ar-3", + "artist": "artist-2", + "artists": [{ "id": "ar-3", "name": "artist-2" }], + "displayArtist": "artist-2", + "title": "album-2", + "album": "album-2", + "coverArt": "al-13", + "name": "album-2", "songCount": 3, "duration": 300, "playCount": 0, @@ -134,16 +134,16 @@ "discTitles": [] }, { - "id": "al-8", + "id": "al-9", "created": "2019-11-30T00:00:00Z", "artistId": "ar-2", "artist": "artist-1", "artists": [{ "id": "ar-2", "name": "artist-1" }], "displayArtist": "artist-1", - "title": "album-1", - "album": "album-1", - "coverArt": "al-8", - "name": "album-1", + "title": "album-2", + "album": "album-2", + "coverArt": "al-9", + "name": "album-2", "songCount": 3, "duration": 300, "playCount": 0, @@ -155,16 +155,16 @@ "discTitles": [] }, { - "id": "al-4", + "id": "al-5", "created": "2019-11-30T00:00:00Z", "artistId": "ar-1", "artist": "artist-0", "artists": [{ "id": "ar-1", "name": "artist-0" }], "displayArtist": "artist-0", - "title": "album-1", - "album": "album-1", - "coverArt": "al-4", - "name": "album-1", + "title": "album-2", + "album": "album-2", + "coverArt": "al-5", + "name": "album-2", "songCount": 3, "duration": 300, "playCount": 0, @@ -176,16 +176,16 @@ "discTitles": [] }, { - "id": "al-13", + "id": "al-4", "created": "2019-11-30T00:00:00Z", - "artistId": "ar-3", - "artist": "artist-2", - "artists": [{ "id": "ar-3", "name": "artist-2" }], - "displayArtist": "artist-2", - "title": "album-2", - "album": "album-2", - "coverArt": "al-13", - "name": "album-2", + "artistId": "ar-1", + "artist": "artist-0", + "artists": [{ "id": "ar-1", "name": "artist-0" }], + "displayArtist": "artist-0", + "title": "album-1", + "album": "album-1", + "coverArt": "al-4", + "name": "album-1", "songCount": 3, "duration": 300, "playCount": 0, diff --git a/server/ctrlsubsonic/testdata/test_get_album_with_cover b/server/ctrlsubsonic/testdata/test_get_album_with_cover index 69400cbe2..f19b6f5f7 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_with_cover +++ b/server/ctrlsubsonic/testdata/test_get_album_with_cover @@ -33,6 +33,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", @@ -61,6 +63,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", @@ -89,6 +93,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", diff --git a/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks b/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks index dbaa316ec..eff0a6b60 100644 --- a/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks +++ b/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks @@ -20,6 +20,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", @@ -48,6 +50,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", @@ -76,6 +80,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", diff --git a/server/ctrlsubsonic/testdata/test_get_music_directory_without_tracks b/server/ctrlsubsonic/testdata/test_get_music_directory_without_tracks index 83b3b0e11..e96fff829 100644 --- a/server/ctrlsubsonic/testdata/test_get_music_directory_without_tracks +++ b/server/ctrlsubsonic/testdata/test_get_music_directory_without_tracks @@ -17,6 +17,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-3", "created": "2019-11-30T00:00:00Z", "isDir": true, @@ -34,6 +36,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-4", "created": "2019-11-30T00:00:00Z", "isDir": true, @@ -51,6 +55,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-5", "created": "2019-11-30T00:00:00Z", "isDir": true, diff --git a/server/ctrlsubsonic/testdata/test_search_three_q_tra b/server/ctrlsubsonic/testdata/test_search_three_q_tra index 4c1e7556a..b21caa2d4 100644 --- a/server/ctrlsubsonic/testdata/test_search_three_q_tra +++ b/server/ctrlsubsonic/testdata/test_search_three_q_tra @@ -17,6 +17,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", @@ -47,6 +49,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", @@ -77,6 +81,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", @@ -107,6 +113,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-4", @@ -137,6 +145,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-4", @@ -167,6 +177,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-4", @@ -197,6 +209,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-5", @@ -227,6 +241,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-5", @@ -257,6 +273,8 @@ "displayArtist": "artist-0", "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], "displayAlbumArtist": "artist-0", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-5", @@ -287,6 +305,8 @@ "displayArtist": "artist-1", "albumArtists": [{ "id": "ar-2", "name": "artist-1" }], "displayAlbumArtist": "artist-1", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-7", @@ -317,6 +337,8 @@ "displayArtist": "artist-1", "albumArtists": [{ "id": "ar-2", "name": "artist-1" }], "displayAlbumArtist": "artist-1", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-7", @@ -347,6 +369,8 @@ "displayArtist": "artist-1", "albumArtists": [{ "id": "ar-2", "name": "artist-1" }], "displayAlbumArtist": "artist-1", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-7", @@ -377,6 +401,8 @@ "displayArtist": "artist-1", "albumArtists": [{ "id": "ar-2", "name": "artist-1" }], "displayAlbumArtist": "artist-1", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-8", @@ -407,6 +433,8 @@ "displayArtist": "artist-1", "albumArtists": [{ "id": "ar-2", "name": "artist-1" }], "displayAlbumArtist": "artist-1", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-8", @@ -437,6 +465,8 @@ "displayArtist": "artist-1", "albumArtists": [{ "id": "ar-2", "name": "artist-1" }], "displayAlbumArtist": "artist-1", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-8", @@ -467,6 +497,8 @@ "displayArtist": "artist-1", "albumArtists": [{ "id": "ar-2", "name": "artist-1" }], "displayAlbumArtist": "artist-1", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-9", @@ -497,6 +529,8 @@ "displayArtist": "artist-1", "albumArtists": [{ "id": "ar-2", "name": "artist-1" }], "displayAlbumArtist": "artist-1", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-9", @@ -527,6 +561,8 @@ "displayArtist": "artist-1", "albumArtists": [{ "id": "ar-2", "name": "artist-1" }], "displayAlbumArtist": "artist-1", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-9", @@ -557,6 +593,8 @@ "displayArtist": "artist-2", "albumArtists": [{ "id": "ar-3", "name": "artist-2" }], "displayAlbumArtist": "artist-2", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-11", @@ -587,6 +625,8 @@ "displayArtist": "artist-2", "albumArtists": [{ "id": "ar-3", "name": "artist-2" }], "displayAlbumArtist": "artist-2", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-11", diff --git a/server/ctrlsubsonic/testdata/test_search_two_q_alb b/server/ctrlsubsonic/testdata/test_search_two_q_alb index 9f1dfa0e5..5b16c82b9 100644 --- a/server/ctrlsubsonic/testdata/test_search_two_q_alb +++ b/server/ctrlsubsonic/testdata/test_search_two_q_alb @@ -14,6 +14,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-3", "created": "2019-11-30T00:00:00Z", "isDir": true, @@ -31,6 +33,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-4", "created": "2019-11-30T00:00:00Z", "isDir": true, @@ -48,6 +52,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-5", "created": "2019-11-30T00:00:00Z", "isDir": true, @@ -65,6 +71,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-7", "created": "2019-11-30T00:00:00Z", "isDir": true, @@ -82,6 +90,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-8", "created": "2019-11-30T00:00:00Z", "isDir": true, @@ -99,6 +109,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-9", "created": "2019-11-30T00:00:00Z", "isDir": true, @@ -116,6 +128,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-11", "created": "2019-11-30T00:00:00Z", "isDir": true, @@ -133,6 +147,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-12", "created": "2019-11-30T00:00:00Z", "isDir": true, @@ -150,6 +166,8 @@ "displayArtist": "", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "coverArt": "al-13", "created": "2019-11-30T00:00:00Z", "isDir": true, diff --git a/server/ctrlsubsonic/testdata/test_search_two_q_tra b/server/ctrlsubsonic/testdata/test_search_two_q_tra index 710b92350..ad70ce735 100644 --- a/server/ctrlsubsonic/testdata/test_search_two_q_tra +++ b/server/ctrlsubsonic/testdata/test_search_two_q_tra @@ -17,6 +17,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", @@ -45,6 +47,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", @@ -73,6 +77,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-3", @@ -101,6 +107,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-4", @@ -129,6 +137,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-4", @@ -157,6 +167,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-4", @@ -185,6 +197,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-5", @@ -213,6 +227,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-5", @@ -241,6 +257,8 @@ "displayArtist": "artist-0", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-5", @@ -269,6 +287,8 @@ "displayArtist": "artist-1", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-7", @@ -297,6 +317,8 @@ "displayArtist": "artist-1", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-7", @@ -325,6 +347,8 @@ "displayArtist": "artist-1", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-7", @@ -353,6 +377,8 @@ "displayArtist": "artist-1", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-8", @@ -381,6 +407,8 @@ "displayArtist": "artist-1", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-8", @@ -409,6 +437,8 @@ "displayArtist": "artist-1", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-8", @@ -437,6 +467,8 @@ "displayArtist": "artist-1", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-9", @@ -465,6 +497,8 @@ "displayArtist": "artist-1", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-9", @@ -493,6 +527,8 @@ "displayArtist": "artist-1", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-9", @@ -521,6 +557,8 @@ "displayArtist": "artist-2", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-11", @@ -549,6 +587,8 @@ "displayArtist": "artist-2", "albumArtists": null, "displayAlbumArtist": "", + "contributors": null, + "displayComposer": "", "bitRate": 100, "contentType": "audio/flac", "coverArt": "al-11", diff --git a/tags/tags.go b/tags/tags.go index d6ac2f843..abf84227f 100644 --- a/tags/tags.go +++ b/tags/tags.go @@ -33,6 +33,15 @@ const ( FallbackGenre = "Unknown Genre" ) +func FirstValues(p Tags, keys ...string) []string { + for _, k := range keys { + if v := normtag.Values(p, k); len(v) > 0 { + return v + } + } + return nil +} + func MustAlbum(p Tags) string { if r := normtag.Get(p, normtag.Album); r != "" { return r @@ -48,10 +57,7 @@ func MustArtist(p Tags) string { } func MustArtists(p Tags) []string { - if r := normtag.Values(p, normtag.Artists); len(r) > 0 { - return r - } - if r := normtag.Values(p, normtag.Artist); len(r) > 0 { + if r := FirstValues(p, normtag.Artists, normtag.Artist); len(r) > 0 { return r } return []string{FallbackArtist} @@ -65,10 +71,7 @@ func MustAlbumArtist(p Tags) string { } func MustAlbumArtists(p Tags) []string { - if r := normtag.Values(p, normtag.AlbumArtists); len(r) > 0 { - return r - } - if r := normtag.Values(p, normtag.AlbumArtist); len(r) > 0 { + if r := FirstValues(p, normtag.AlbumArtists, normtag.AlbumArtist); len(r) > 0 { return r } return []string{MustArtist(p)} @@ -82,10 +85,7 @@ func MustGenre(p Tags) string { } func MustGenres(p Tags) []string { - if r := normtag.Values(p, normtag.Genres); len(r) > 0 { - return r - } - if r := normtag.Values(p, normtag.Genre); len(r) > 0 { + if r := FirstValues(p, normtag.Genres, normtag.Genre); len(r) > 0 { return r } return []string{FallbackGenre}