diff --git a/cmd/gonic/main.go b/cmd/gonic/main.go index 50cf50d..a4386f8 100644 --- a/cmd/gonic/main.go +++ b/cmd/gonic/main.go @@ -34,6 +34,7 @@ func main() { confScanInterval := set.Int("scan-interval", 0, "interval (in minutes) to automatically scan music (optional)") confJukeboxEnabled := set.Bool("jukebox-enabled", false, "whether the subsonic jukebox api should be enabled (optional)") confProxyPrefix := set.String("proxy-prefix", "", "url path prefix to use if behind proxy. eg '/gonic' (optional)") + confGenreSplit := set.String("genre-split", "\n", "character or string to split genre tag data on (optional)") confShowVersion := set.Bool("version", false, "show gonic version") _ = set.String("config-path", "", "path to config (optional)") @@ -85,6 +86,7 @@ func main() { CachePath: *confCachePath, CoverCachePath: coverCachePath, ProxyPrefix: *confProxyPrefix, + GenreSplit: *confGenreSplit, }) var g run.Group diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index 8dfac2f..38bfd3b 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -82,7 +82,7 @@ func (c *Controller) ServeGetAlbum(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"). Preload("TagArtist"). - Preload("TagGenre"). + Preload("Genres"). Preload("Tracks", func(db *gorm.DB) *gorm.DB { return db.Order("tracks.tag_disc_number, tracks.tag_track_number") }). @@ -123,8 +123,9 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { params.GetOrInt("toYear", 2200)) q = q.Order("tag_year") case "byGenre": - q = q.Joins("JOIN genres ON albums.tag_genre_id=genres.id AND genres.name=?", - params.GetOr("genre", "Unknown Genre")) + genre, _ := params.Get("genre") + q = q.Joins("JOIN album_genres ON album_genres.album_id=albums.id") + q = q.Joins("JOIN genres ON genres.id=album_genres.genre_id AND genres.name=?", genre) case "frequent": user := r.Context().Value(CtxUser).(*db.User) q = q.Joins("JOIN plays ON albums.id=plays.album_id AND plays.user_id=?", @@ -291,8 +292,8 @@ func (c *Controller) ServeGetGenres(r *http.Request) *spec.Response { var genres []*db.Genre c.DB. Select(`*, - (SELECT count(id) FROM albums WHERE tag_genre_id=genres.id) album_count, - (SELECT count(id) FROM tracks WHERE tag_genre_id=genres.id) track_count`). + (SELECT count(1) FROM album_genres WHERE genre_id=genres.id) album_count, + (SELECT count(1) FROM track_genres WHERE genre_id=genres.id) track_count`). Group("genres.id"). Find(&genres) sub := spec.NewResponse() @@ -316,7 +317,8 @@ func (c *Controller) ServeGetSongsByGenre(r *http.Request) *spec.Response { var tracks []*db.Track c.DB. Joins("JOIN albums ON tracks.album_id=albums.id"). - Joins("JOIN genres ON tracks.tag_genre_id=genres.id AND genres.name=?", genre). + 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"). Offset(params.GetOrInt("offset", 0)). Limit(params.GetOrInt("count", 10)). diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index b795bbb..187c8b8 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -197,9 +197,9 @@ func (c *Controller) ServeGetRandomSongs(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) var tracks []*db.Track q := c.DB.DB. - Joins("JOIN albums ON tracks.album_id=albums.id"). Limit(params.GetOrInt("size", 10)). Preload("Album"). + Joins("JOIN albums ON tracks.album_id=albums.id"). Order(gorm.Expr("random()")) if year, err := params.GetInt("fromYear"); err == nil { q = q.Where("albums.tag_year >= ?", year) @@ -208,10 +208,8 @@ func (c *Controller) ServeGetRandomSongs(r *http.Request) *spec.Response { q = q.Where("albums.tag_year <= ?", year) } if genre, err := params.Get("genre"); err == nil { - q = q.Joins( - "JOIN genres ON tracks.tag_genre_id=genres.id AND genres.name=?", - genre, - ) + q = q.Joins("JOIN track_genres ON track_genres.track_id=tracks.id") + q = q.Joins("JOIN genres ON genres.id=track_genres.genre_id AND genres.name=?", genre) } q.Find(&tracks) sub := spec.NewResponse() diff --git a/server/ctrlsubsonic/spec/construct_by_tags.go b/server/ctrlsubsonic/spec/construct_by_tags.go index 179acc1..c997b4c 100644 --- a/server/ctrlsubsonic/spec/construct_by_tags.go +++ b/server/ctrlsubsonic/spec/construct_by_tags.go @@ -2,6 +2,7 @@ package spec import ( "path" + "strings" "go.senan.xyz/gonic/server/db" ) @@ -13,11 +14,9 @@ func NewAlbumByTags(a *db.Album, artist *db.Artist) *Album { Name: a.TagTitle, Year: a.TagYear, TrackCount: a.ChildCount, + Genre: strings.Join(a.GenreStrings(), ", "), Duration: a.Duration, } - if a.TagGenre != nil { - ret.Genre = a.TagGenre.Name - } if a.Cover != "" { ret.CoverID = a.SID() } @@ -47,6 +46,7 @@ func NewTrackByTags(t *db.Track, album *db.Album) *TrackChild { ), Album: album.TagTitle, AlbumID: album.SID(), + Genre: strings.Join(t.GenreStrings(), ", "), Duration: t.Length, Bitrate: t.Bitrate, Type: "music", diff --git a/server/ctrlsubsonic/specid/ids.go b/server/ctrlsubsonic/specid/ids.go index e6cd71b..710187f 100644 --- a/server/ctrlsubsonic/specid/ids.go +++ b/server/ctrlsubsonic/specid/ids.go @@ -42,12 +42,16 @@ func New(in string) (ID, error) { if err != nil { return ID{}, fmt.Errorf("%q: %w", partValue, ErrNotAnInt) } - for _, acc := range []IDT{Artist, Album, Track} { - if partType == string(acc) { - return ID{Type: acc, Value: val}, nil - } + switch IDT(partType) { + case Artist: + return ID{Type: Artist, Value: val}, nil + case Album: + return ID{Type: Album, Value: val}, nil + case Track: + return ID{Type: Track, Value: val}, nil + default: + return ID{}, fmt.Errorf("%q: %w", partType, ErrBadPrefix) } - return ID{}, fmt.Errorf("%q: %w", partType, ErrBadPrefix) } func (i ID) String() string { diff --git a/server/db/db.go b/server/db/db.go index 6945125..41061ef 100644 --- a/server/db/db.go +++ b/server/db/db.go @@ -76,6 +76,7 @@ func New(path string) (*DB, error) { migrateAddGenre(), migrateUpdateTranscodePrefIDX(), migrateAddAlbumIDX(), + migrateMultiGenre(), )) if err = migr.Migrate(); err != nil { return nil, fmt.Errorf("migrating to latest version: %w", err) diff --git a/server/db/migrations.go b/server/db/migrations.go index 609762c..b7518ed 100644 --- a/server/db/migrations.go +++ b/server/db/migrations.go @@ -61,14 +61,14 @@ func migrateMergePlaylist() gormigrate.Migration { return nil } return tx.Exec(` - UPDATE playlists - SET items=( SELECT group_concat(track_id) FROM ( - SELECT track_id - FROM playlist_items - WHERE playlist_items.playlist_id=playlists.id - ORDER BY created_at - ) ); - DROP TABLE playlist_items;`, + UPDATE playlists + SET items=( SELECT group_concat(track_id) FROM ( + SELECT track_id + FROM playlist_items + WHERE playlist_items.playlist_id=playlists.id + ORDER BY created_at + ) ); + DROP TABLE playlist_items;`, ). Error }, @@ -117,8 +117,8 @@ func migrateUpdateTranscodePrefIDX() gormigrate.Migration { return nil } step := tx.Exec(` - ALTER TABLE transcode_preferences RENAME TO transcode_preferences_orig; - `) + ALTER TABLE transcode_preferences RENAME TO transcode_preferences_orig; + `) if err := step.Error; err != nil { return fmt.Errorf("step rename: %w", err) } @@ -129,11 +129,11 @@ func migrateUpdateTranscodePrefIDX() gormigrate.Migration { return fmt.Errorf("step create: %w", err) } step = tx.Exec(` - INSERT INTO transcode_preferences (user_id, client, profile) - SELECT user_id, client, profile - FROM transcode_preferences_orig; - DROP TABLE transcode_preferences_orig; - `) + INSERT INTO transcode_preferences (user_id, client, profile) + SELECT user_id, client, profile + FROM transcode_preferences_orig; + DROP TABLE transcode_preferences_orig; + `) if err := step.Error; err != nil { return fmt.Errorf("step copy: %w", err) } @@ -153,3 +153,19 @@ func migrateAddAlbumIDX() gormigrate.Migration { }, } } + +func migrateMultiGenre() gormigrate.Migration { + return gormigrate.Migration{ + ID: "202012151806", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate( + Track{}, + Album{}, + Genre{}, + TrackGenre{}, + AlbumGenre{}, + ). + Error + }, + } +} diff --git a/server/db/model.go b/server/db/model.go index 09e8d2e..3473b15 100644 --- a/server/db/model.go +++ b/server/db/model.go @@ -61,12 +61,10 @@ func (a *Artist) IndexName() string { } type Genre struct { - ID int `gorm:"primary_key"` - Name string `gorm:"not null; unique_index"` - Albums []*Album `gorm:"foreignkey:TagGenreID"` - AlbumCount int `sql:"-"` - Tracks []*Track `gorm:"foreignkey:TagGenreID"` - TrackCount int `sql:"-"` + ID int `gorm:"primary_key"` + Name string `gorm:"not null; unique_index"` + AlbumCount int `sql:"-"` + TrackCount int `sql:"-"` } type Track struct { @@ -78,18 +76,17 @@ type Track struct { 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"` - Size int `gorm:"not null" 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"` - TagGenre *Genre - TagGenreID int `sql:"default: null; type:int REFERENCES genres(id)"` - TagBrainzID string `sql:"default: null"` + ArtistID int `gorm:"not null" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` + Genres []*Genre `gorm:"many2many:track_genres"` + Size int `gorm:"not null" 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"` } func (t *Track) SID() *specid.ID { @@ -128,6 +125,14 @@ func (t *Track) RelPath() string { ) } +func (t *Track) GenreStrings() []string { + var strs []string + for _, genre := range t.Genres { + strs = append(strs, genre.Name) + } + return strs +} + type User struct { ID int `gorm:"primary_key"` CreatedAt time.Time @@ -160,12 +165,11 @@ type Album struct { RightPath string `gorm:"not null; unique_index:idx_left_path_right_path" sql:"default: null"` RightPathUDec string `sql:"default: null"` Parent *Album - ParentID int `sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` - Cover string `sql:"default: null"` + ParentID int `sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` + 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"` - TagGenre *Genre - TagGenreID int `sql:"default: null; type:int"` + 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"` @@ -192,6 +196,14 @@ func (a *Album) IndexRightPath() string { return a.RightPath } +func (a *Album) GenreStrings() []string { + var strs []string + for _, genre := range a.Genres { + strs = append(strs, genre.Name) + } + return strs +} + type Playlist struct { ID int `gorm:"primary_key"` CreatedAt time.Time @@ -243,3 +255,17 @@ type TranscodePreference struct { Client string `gorm:"not null; unique_index:idx_user_id_client" sql:"default: null"` Profile string `gorm:"not null" sql:"default: null"` } + +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"` + Genre *Genre + GenreID int `gorm:"not null; unique_index:idx_track_id_genre_id" sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"` +} + +type AlbumGenre struct { + Album *Album + AlbumID int `gorm:"not null; unique_index:idx_album_id_genre_id" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` + Genre *Genre + GenreID int `gorm:"not null; unique_index:idx_album_id_genre_id" sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"` +} diff --git a/server/scanner/scanner.go b/server/scanner/scanner.go index ba86e9e..c082789 100644 --- a/server/scanner/scanner.go +++ b/server/scanner/scanner.go @@ -58,9 +58,10 @@ func SetScanning() func() { } type Scanner struct { - db *db.DB - musicPath string - isFull bool + db *db.DB + musicPath string + isFull bool + genreSplit string // these two are for the transaction we do for every folder. // the boolean is there so we dont begin or commit multiple // times in the handle folder or post children callback @@ -78,10 +79,11 @@ type Scanner struct { seenTracksNew int // n tracks not seen before } -func New(musicPath string, db *db.DB) *Scanner { +func New(musicPath string, db *db.DB, genreSplit string) *Scanner { return &Scanner{ - db: db, - musicPath: musicPath, + db: db, + musicPath: musicPath, + genreSplit: genreSplit, } } @@ -368,6 +370,7 @@ func (s *Scanner) handleTrack(it *item) error { s.trTx = s.db.Begin() s.trTxOpen = true } + // ** begin set track basics track := &db.Track{} defer func() { @@ -404,16 +407,9 @@ func (s *Scanner) handleTrack(it *item) error { track.TagBrainzID = trTags.BrainzID() track.Length = trTags.Length() // these two should be calculated track.Bitrate = trTags.Bitrate() // ...from the file instead of tags + // ** begin set album artist basics - artistName := func() string { - if r := trTags.AlbumArtist(); r != "" { - return r - } - if r := trTags.Artist(); r != "" { - return r - } - return "Unknown Artist" - }() + artistName := firstTag("Unknown Artist", trTags.AlbumArtist, trTags.Artist) artist := &db.Artist{} err = s.trTx. Select("id"). @@ -428,43 +424,66 @@ func (s *Scanner) handleTrack(it *item) error { } } track.ArtistID = artist.ID + // ** begin set genre - genreName := func() string { - if r := trTags.Genre(); r != "" { - return r - } - return "Unknown Genre" - }() - genre := &db.Genre{} - err = s.trTx. - Select("id"). - Where("name=?", genreName). - First(genre). - Error - if gorm.IsRecordNotFoundError(err) { - genre.Name = genreName - if err := s.trTx.Save(genre).Error; err != nil { - return fmt.Errorf("writing genres table: %w", err) + genreTag := firstTag("Unknown Genre", trTags.Genre) + genres := strings.Split(genreTag, s.genreSplit) + genreIDs := []int{} + for _, genreName := range genres { + // TODO insert or ignore + genre := &db.Genre{} + err = s.trTx. + Select("id"). + Where("name=?", genreName). + First(genre). + Error + if gorm.IsRecordNotFoundError(err) { + genre.Name = genreName + if err := s.trTx.Save(genre).Error; err != nil { + return fmt.Errorf("writing genres table: %w", err) + } } + genreIDs = append(genreIDs, genre.ID) } - track.TagGenreID = genre.ID + // ** begin save the track if err := s.trTx.Save(track).Error; err != nil { return fmt.Errorf("writing track table: %w", err) } + for _, genreID := range genreIDs { + trackGenre := &db.TrackGenre{TrackID: track.ID, GenreID: genreID} + if err := s.trTx.Save(trackGenre).Error; err != nil { + return fmt.Errorf("writing track table: %w", err) + } + } s.seenTracksNew++ + // ** begin set album if this is the first track in the folder folder := s.curFolders.Peek() if !folder.ReceivedPaths || folder.ReceivedTags { // the folder hasn't been modified or already has it's tags return nil } + for _, genreID := range genreIDs { + albumGenre := &db.AlbumGenre{AlbumID: folder.ID, GenreID: genreID} + if err := s.trTx.Save(albumGenre).Error; err != nil { + return fmt.Errorf("writing album table: %w", err) + } + } folder.TagTitle = trTags.Album() folder.TagTitleUDec = decoded(trTags.Album()) folder.TagBrainzID = trTags.AlbumBrainzID() folder.TagYear = trTags.Year() folder.TagArtistID = artist.ID - folder.TagGenreID = genre.ID folder.ReceivedTags = true return nil } + +func firstTag(fallback string, tags ...func() string) string { + for _, f := range tags { + if tag := f(); tag != "" { + return tag + } + } + return fallback +} diff --git a/server/server.go b/server/server.go index e720dce..22840b7 100644 --- a/server/server.go +++ b/server/server.go @@ -26,6 +26,7 @@ type Options struct { CachePath string CoverCachePath string ProxyPrefix string + GenreSplit string } type Server struct { @@ -40,7 +41,7 @@ func New(opts Options) *Server { opts.MusicPath = filepath.Clean(opts.MusicPath) opts.CachePath = filepath.Clean(opts.CachePath) // ** begin controllers - scanner := scanner.New(opts.MusicPath, opts.DB) + scanner := scanner.New(opts.MusicPath, opts.DB, opts.GenreSplit) jukebox := jukebox.New(opts.MusicPath) // the base controller, it's fields/middlewares are embedded/used by the // other two admin ui and subsonic controllers