diff --git a/conf/configuration.go b/conf/configuration.go index 0b8b7bb7c..9f6599f7c 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -215,7 +215,7 @@ func init() { viper.SetDefault("reverseproxywhitelist", "") viper.SetDefault("scanner.extractor", "taglib") - viper.SetDefault("scanner.genreseparators", ";/") + viper.SetDefault("scanner.genreseparators", ";/,") viper.SetDefault("agents", "lastfm,spotify") viper.SetDefault("lastfm.enabled", true) diff --git a/model/genres.go b/model/genres.go index d8300b31c..f7da78bcb 100644 --- a/model/genres.go +++ b/model/genres.go @@ -3,8 +3,8 @@ package model type Genre struct { ID string `json:"id" orm:"column(id)"` Name string - SongCount int - AlbumCount int + SongCount int `json:"-"` + AlbumCount int `json:"-"` } type Genres []Genre diff --git a/model/mediafile.go b/model/mediafile.go index 59afe4f81..bb5d863a0 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -28,6 +28,7 @@ type MediaFile struct { Duration float32 `json:"duration"` BitRate int `json:"bitRate"` Genre string `json:"genre"` + Genres Genres `json:"genres"` FullText string `json:"fullText"` SortTitle string `json:"sortTitle,omitempty"` SortAlbumName string `json:"sortAlbumName,omitempty"` diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index 0adb4cdb5..d1bf41406 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -24,15 +24,22 @@ func NewGenreRepository(ctx context.Context, o orm.Ormer) model.GenreRepository } func (r *genreRepository) GetAll() (model.Genres, error) { - sq := Select("genre as name", "count(distinct album_id) as album_count", "count(distinct id) as song_count"). - From("media_file").GroupBy("genre") + sq := Select("*", + "(select count(1) from album where album.genre = genre.name) as album_count", + "count(distinct f.media_file_id) as song_count"). + From(r.tableName). + // TODO Use relation table + // LeftJoin("album_genres a on a.genre_id = genre.id"). + LeftJoin("media_file_genres f on f.genre_id = genre.id"). + GroupBy("genre.id") res := model.Genres{} err := r.queryAll(sq, &res) return res, err } func (r *genreRepository) Put(m *model.Genre) error { - _, err := r.put(m.ID, m) + id, err := r.put(m.ID, m) + m.ID = id return err } diff --git a/persistence/genre_repository_test.go b/persistence/genre_repository_test.go index e1e8aabd6..d86cf1b85 100644 --- a/persistence/genre_repository_test.go +++ b/persistence/genre_repository_test.go @@ -21,7 +21,9 @@ var _ = Describe("GenreRepository", func() { It("returns all records", func() { genres, err := repo.GetAll() Expect(err).To(BeNil()) - Expect(genres).To(ContainElement(model.Genre{Name: "Rock", AlbumCount: 2, SongCount: 2})) - Expect(genres).To(ContainElement(model.Genre{Name: "Electronic", AlbumCount: 1, SongCount: 2})) + Expect(genres).To(ConsistOf( + model.Genre{ID: "gn-1", Name: "Electronic", AlbumCount: 1, SongCount: 2}, + model.Genre{ID: "gn-2", Name: "Rock", AlbumCount: 2, SongCount: 3}, + )) }) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 5b22589e2..758df2039 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -37,28 +37,34 @@ func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileReposito return r } -func (r mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { +func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { return r.count(r.newSelectWithAnnotation("media_file.id"), options...) } -func (r mediaFileRepository) Exists(id string) (bool, error) { +func (r *mediaFileRepository) Exists(id string) (bool, error) { return r.exists(Select().Where(Eq{"id": id})) } -func (r mediaFileRepository) Put(m *model.MediaFile) error { +func (r *mediaFileRepository) Put(m *model.MediaFile) error { m.FullText = getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist, m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle) + genres := m.Genres + m.Genres = nil + defer func() { m.Genres = genres }() _, err := r.put(m.ID, m) - return err + if err != nil { + return err + } + return r.updateGenres(m.ID, r.tableName, genres) } -func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { +func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { sql := r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*") return r.withBookmark(sql, "media_file.id") } -func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) { - sel := r.selectMediaFile().Where(Eq{"id": id}) +func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) { + sel := r.selectMediaFile().Where(Eq{"media_file.id": id}) var res model.MediaFiles if err := r.queryAll(sel, &res); err != nil { return nil, err @@ -66,24 +72,33 @@ func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) { if len(res) == 0 { return nil, model.ErrNotFound } - return &res[0], nil + err := r.loadMediaFileGenres(&res) + return &res[0], err } -func (r mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { - sq := r.selectMediaFile(options...) +func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { + sq := r.selectMediaFile(options...). + LeftJoin("media_file_genres mfg on media_file.id = mfg.media_file_id"). + LeftJoin("genre on mfg.genre_id = genre.id"). + GroupBy("media_file.id") res := model.MediaFiles{} err := r.queryAll(sq, &res) + if err != nil { + return nil, err + } + err = r.loadMediaFileGenres(&res) return res, err } -func (r mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, error) { - sel := r.selectMediaFile(model.QueryOptions{Sort: "album"}).Where(Eq{"album_id": albumId}) - res := model.MediaFiles{} - err := r.queryAll(sel, &res) - return res, err +func (r *mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, error) { + options := model.QueryOptions{ + Filters: Eq{"album_id": albumId}, + Sort: "album", + } + return r.GetAll(options) } -func (r mediaFileRepository) FindByPath(path string) (*model.MediaFile, error) { +func (r *mediaFileRepository) FindByPath(path string) (*model.MediaFile, error) { sel := r.selectMediaFile().Where(Eq{"path": path}) var res model.MediaFiles if err := r.queryAll(sel, &res); err != nil { @@ -109,7 +124,7 @@ func pathStartsWith(path string) Eq { } // FindAllByPath only return mediafiles that are direct children of requested path -func (r mediaFileRepository) FindAllByPath(path string) (model.MediaFiles, error) { +func (r *mediaFileRepository) FindAllByPath(path string) (model.MediaFiles, error) { // Query by path based on https://stackoverflow.com/a/13911906/653632 path = cleanPath(path) pathLen := utf8.RuneCountInString(path) @@ -124,7 +139,7 @@ func (r mediaFileRepository) FindAllByPath(path string) (model.MediaFiles, error } // FindPathsRecursively returns a list of all subfolders of basePath, recursively -func (r mediaFileRepository) FindPathsRecursively(basePath string) ([]string, error) { +func (r *mediaFileRepository) FindPathsRecursively(basePath string) ([]string, error) { path := cleanPath(basePath) // Query based on https://stackoverflow.com/a/38330814/653632 sel := r.newSelect().Columns(fmt.Sprintf("distinct rtrim(path, replace(path, '%s', ''))", string(os.PathSeparator))). @@ -134,7 +149,7 @@ func (r mediaFileRepository) FindPathsRecursively(basePath string) ([]string, er return res, err } -func (r mediaFileRepository) deleteNotInPath(basePath string) error { +func (r *mediaFileRepository) deleteNotInPath(basePath string) error { path := cleanPath(basePath) sel := Delete(r.tableName).Where(NotEq(pathStartsWith(path))) c, err := r.executeSQL(sel) @@ -146,28 +161,29 @@ func (r mediaFileRepository) deleteNotInPath(basePath string) error { return err } -func (r mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.MediaFiles, error) { - sq := r.selectMediaFile(options...).Where("starred = true") - starred := model.MediaFiles{} - err := r.queryAll(sq, &starred) - return starred, err +func (r *mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.MediaFiles, error) { + if len(options) == 0 { + options = []model.QueryOptions{{}} + } + options[0].Filters = Eq{"starred": true} + return r.GetAll(options...) } // TODO Keep order when paginating -func (r mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) { - sq := r.selectMediaFile(options...) - sq = sq.OrderBy("RANDOM()") - results := model.MediaFiles{} - err := r.queryAll(sq, &results) - return results, err +func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) { + if len(options) == 0 { + options = []model.QueryOptions{{}} + } + options[0].Sort = "random()" + return r.GetAll(options...) } -func (r mediaFileRepository) Delete(id string) error { +func (r *mediaFileRepository) Delete(id string) error { return r.delete(Eq{"id": id}) } // DeleteByPath delete from the DB all mediafiles that are direct children of path -func (r mediaFileRepository) DeleteByPath(basePath string) (int64, error) { +func (r *mediaFileRepository) DeleteByPath(basePath string) (int64, error) { path := cleanPath(basePath) pathLen := utf8.RuneCountInString(path) del := Delete(r.tableName). @@ -177,39 +193,39 @@ func (r mediaFileRepository) DeleteByPath(basePath string) (int64, error) { return r.executeSQL(del) } -func (r mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) { +func (r *mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) { results := model.MediaFiles{} err := r.doSearch(q, offset, size, &results, "title") return results, err } -func (r mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { +func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { return r.CountAll(r.parseRestOptions(options...)) } -func (r mediaFileRepository) Read(id string) (interface{}, error) { +func (r *mediaFileRepository) Read(id string) (interface{}, error) { return r.Get(id) } -func (r mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { return r.GetAll(r.parseRestOptions(options...)) } -func (r mediaFileRepository) EntityName() string { +func (r *mediaFileRepository) EntityName() string { return "mediafile" } -func (r mediaFileRepository) NewInstance() interface{} { +func (r *mediaFileRepository) NewInstance() interface{} { return &model.MediaFile{} } -func (r mediaFileRepository) Save(entity interface{}) (string, error) { +func (r *mediaFileRepository) Save(entity interface{}) (string, error) { mf := entity.(*model.MediaFile) err := r.Put(mf) return mf.ID, err } -func (r mediaFileRepository) Update(entity interface{}, cols ...string) error { +func (r *mediaFileRepository) Update(entity interface{}, cols ...string) error { mf := entity.(*model.MediaFile) return r.Put(mf) } diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 834bb77f3..8d0cbbc46 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" "github.com/astaxie/beego/orm" "github.com/google/uuid" "github.com/navidrome/navidrome/log" @@ -149,6 +150,17 @@ var _ = Describe("MediaRepository", func() { Expect(mr.FindAllByPath(P("/music/overlap"))).To(HaveLen(1)) }) + It("filters by genre", func() { + Expect(mr.GetAll(model.QueryOptions{ + Sort: "genre.name asc, title asc", + Filters: squirrel.Eq{"genre.name": "Rock"}, + })).To(Equal(model.MediaFiles{ + songDayInALife, + songAntenna, + songComeTogether, + })) + }) + Context("Annotations", func() { It("increments play count when the tracks does not have annotations", func() { id := "incplay.firsttime" diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 3aeb39a3b..62585bd5f 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -25,11 +25,17 @@ func TestPersistence(t *testing.T) { conf.Server.DbPath = "file::memory:?cache=shared" _ = orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath) db.EnsureLatestVersion() - log.SetLevel(log.LevelCritical) + log.SetLevel(log.LevelError) RegisterFailHandler(Fail) RunSpecs(t, "Persistence Suite") } +var ( + genreElectronic = model.Genre{ID: "gn-1", Name: "Electronic"} + genreRock = model.Genre{ID: "gn-2", Name: "Rock"} + testGenres = model.Genres{genreElectronic, genreRock} +) + var ( artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: " kraftwerk"} artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: " beatles the"} @@ -51,10 +57,10 @@ var ( ) var ( - songDayInALife = model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: " a beatles day in life peppers sgt the"} - songComeTogether = model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"} - songRadioactivity = model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"} - songAntenna = model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk"} + songDayInALife = model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/sgt/a day.mp3"), FullText: " a beatles day in life peppers sgt the"} + songComeTogether = model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"} + songRadioactivity = model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Genres: model.Genres{genreElectronic}, Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"} + songAntenna = model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock}, Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk"} testSongs = model.MediaFiles{ songDayInALife, songComeTogether, @@ -87,6 +93,16 @@ var _ = Describe("Initialize test DB", func() { o := orm.NewOrm() ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid"}) + + gr := NewGenreRepository(ctx, o) + for i := range testGenres { + g := testGenres[i] + err := gr.Put(&g) + if err != nil { + panic(err) + } + } + mr := NewMediaFileRepository(ctx, o) for i := range testSongs { s := testSongs[i] diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 1db5684d3..91d258142 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -141,6 +141,7 @@ func (r *playlistRepository) loadTracks(pls *model.Playlist) error { if err != nil { log.Error("Error loading playlist tracks", "playlist", pls.Name, "id", pls.ID) } + err = r.loadMediaFileGenres(&pls.Tracks) return err } diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go index 66dd8b4ad..65123cabd 100644 --- a/persistence/playqueue_repository.go +++ b/persistence/playqueue_repository.go @@ -119,7 +119,7 @@ func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFil mfRepo := NewMediaFileRepository(r.ctx, r.ormer) trackMap := map[string]model.MediaFile{} for i := range chunks { - idsFilter := Eq{"id": chunks[i]} + idsFilter := Eq{"media_file.id": chunks[i]} tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter}) if err != nil { u := loggedUser(r.ctx) diff --git a/persistence/playqueue_repository_test.go b/persistence/playqueue_repository_test.go index f2adb76f2..49089682a 100644 --- a/persistence/playqueue_repository_test.go +++ b/persistence/playqueue_repository_test.go @@ -42,13 +42,13 @@ var _ = Describe("PlayQueueRepository", func() { By("Storing a new playqueue for the same user") - new := aPlayQueue("user1", songRadioactivity.ID, 321, songAntenna, songRadioactivity) - Expect(repo.Store(new)).To(BeNil()) + another := aPlayQueue("user1", songRadioactivity.ID, 321, songAntenna, songRadioactivity) + Expect(repo.Store(another)).To(BeNil()) actual, err = repo.Retrieve("user1") Expect(err).To(BeNil()) - AssertPlayQueue(new, actual) + AssertPlayQueue(another, actual) Expect(countPlayQueues(repo, "user1")).To(Equal(1)) }) }) diff --git a/persistence/sql_genres.go b/persistence/sql_genres.go new file mode 100644 index 000000000..a23089c0e --- /dev/null +++ b/persistence/sql_genres.go @@ -0,0 +1,56 @@ +package persistence + +import ( + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" +) + +func (r *sqlRepository) updateGenres(id string, tableName string, genres model.Genres) error { + var ids []string + for _, g := range genres { + ids = append(ids, g.ID) + } + del := Delete(tableName + "_genres").Where( + And{Eq{tableName + "_id": id}, Eq{"genre_id": ids}}) + _, err := r.executeSQL(del) + if err != nil { + return err + } + + if len(genres) == 0 { + return nil + } + ins := Insert(tableName+"_genres").Columns("genre_id", tableName+"_id") + for _, g := range genres { + ins = ins.Values(g.ID, id) + } + _, err = r.executeSQL(ins) + return err +} + +func (r *sqlRepository) loadMediaFileGenres(mfs *model.MediaFiles) error { + var ids []string + m := map[string]*model.MediaFile{} + for i := range *mfs { + mf := &(*mfs)[i] + ids = append(ids, mf.ID) + m[mf.ID] = mf + } + + sql := Select("g.*", "mg.media_file_id").From("genre g").Join("media_file_genres mg on mg.genre_id = g.id"). + Where(Eq{"mg.media_file_id": ids}).OrderBy("mg.media_file_id", "mg.rowid") + var genres []struct { + model.Genre + MediaFileId string + } + + err := r.queryAll(sql, &genres) + if err != nil { + return err + } + for _, g := range genres { + mf := m[g.MediaFileId] + mf.Genres = append(mf.Genres, g.Genre) + } + return nil +} diff --git a/scanner/cached_genre_repository.go b/scanner/cached_genre_repository.go new file mode 100644 index 000000000..8423a5b74 --- /dev/null +++ b/scanner/cached_genre_repository.go @@ -0,0 +1,45 @@ +package scanner + +import ( + "context" + "strings" + "time" + + "github.com/ReneKroon/ttlcache/v2" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +func newCachedGenreRepository(ctx context.Context, repo model.GenreRepository) model.GenreRepository { + r := &cachedGenreRepo{ + GenreRepository: repo, + ctx: ctx, + } + genres, err := repo.GetAll() + if err != nil { + log.Error(ctx, "Could not load genres from DB", err) + return repo + } + + r.cache = ttlcache.NewCache() + for _, g := range genres { + _ = r.cache.Set(strings.ToLower(g.Name), g.ID) + } + + return r +} + +type cachedGenreRepo struct { + model.GenreRepository + cache *ttlcache.Cache + ctx context.Context +} + +func (r *cachedGenreRepo) Put(g *model.Genre) error { + id, err := r.cache.GetByLoader(strings.ToLower(g.Name), func(key string) (interface{}, time.Duration, error) { + err := r.GenreRepository.Put(g) + return g.ID, 24 * time.Hour, err + }) + g.ID = id.(string) + return err +} diff --git a/scanner/mapping.go b/scanner/mapping.go index 860a3328b..0fe4d22a5 100644 --- a/scanner/mapping.go +++ b/scanner/mapping.go @@ -10,6 +10,7 @@ import ( "github.com/kennygrant/sanitize" "github.com/microcosm-cc/bluemonday" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/scanner/metadata" @@ -19,10 +20,15 @@ import ( type mediaFileMapper struct { rootFolder string policy *bluemonday.Policy + genres model.GenreRepository } -func newMediaFileMapper(rootFolder string) *mediaFileMapper { - return &mediaFileMapper{rootFolder: rootFolder, policy: bluemonday.UGCPolicy()} +func newMediaFileMapper(rootFolder string, genres model.GenreRepository) *mediaFileMapper { + return &mediaFileMapper{ + rootFolder: rootFolder, + policy: bluemonday.UGCPolicy(), + genres: genres, + } } func (s *mediaFileMapper) toMediaFile(md *metadata.Tags) model.MediaFile { @@ -36,9 +42,7 @@ func (s *mediaFileMapper) toMediaFile(md *metadata.Tags) model.MediaFile { mf.Artist = s.mapArtistName(md) mf.AlbumArtistID = s.albumArtistID(md) mf.AlbumArtist = s.mapAlbumArtistName(md) - if len(md.Genres()) > 0 { - mf.Genre = md.Genres()[0] - } + mf.Genre, mf.Genres = s.mapGenres(md.Genres()) mf.Compilation = md.Compilation() mf.Year = md.Year() mf.TrackNumber, _ = md.TrackNumber() @@ -131,3 +135,32 @@ func (s *mediaFileMapper) artistID(md *metadata.Tags) string { func (s *mediaFileMapper) albumArtistID(md *metadata.Tags) string { return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapAlbumArtistName(md))))) } + +func (s *mediaFileMapper) mapGenres(genres []string) (string, model.Genres) { + var result model.Genres + unique := map[string]struct{}{} + var all []string + for i := range genres { + gs := strings.FieldsFunc(genres[i], func(r rune) bool { + return strings.IndexRune(conf.Server.Scanner.GenreSeparators, r) != -1 + }) + for j := range gs { + g := strings.TrimSpace(gs[j]) + key := strings.ToLower(g) + if _, ok := unique[key]; ok { + continue + } + all = append(all, g) + unique[key] = struct{}{} + } + } + for _, g := range all { + genre := model.Genre{Name: g} + _ = s.genres.Put(&genre) + result = append(result, genre) + } + if len(result) == 0 { + return "", nil + } + return result[0].Name, result +} diff --git a/scanner/mapping_test.go b/scanner/mapping_test.go index 1141d946c..01c2cf782 100644 --- a/scanner/mapping_test.go +++ b/scanner/mapping_test.go @@ -1,7 +1,12 @@ package scanner import ( + "context" + + "github.com/astaxie/beego/orm" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) @@ -21,4 +26,40 @@ var _ = Describe("mapping", func() { Expect(sanitizeFieldForSorting("Õ Blésq Blom")).To(Equal("Blesq Blom")) }) }) + + Describe("mapGenres", func() { + var mapper *mediaFileMapper + var gr model.GenreRepository + var ctx context.Context + BeforeEach(func() { + ctx = context.Background() + o := orm.NewOrm() + gr = persistence.NewGenreRepository(ctx, o) + gr = newCachedGenreRepository(ctx, gr) + mapper = newMediaFileMapper("/", gr) + }) + + It("returns empty if no genres are available", func() { + g, gs := mapper.mapGenres(nil) + Expect(g).To(BeEmpty()) + Expect(gs).To(BeEmpty()) + }) + + It("returns genres", func() { + g, gs := mapper.mapGenres([]string{"Rock", "Electronic"}) + Expect(g).To(Equal("Rock")) + Expect(gs).To(HaveLen(2)) + Expect(gs[0].Name).To(Equal("Rock")) + Expect(gs[1].Name).To(Equal("Electronic")) + }) + + It("parses multi-valued genres", func() { + g, gs := mapper.mapGenres([]string{"Rock;Dance", "Electronic", "Rock"}) + Expect(g).To(Equal("Rock")) + Expect(gs).To(HaveLen(3)) + Expect(gs[0].Name).To(Equal("Rock")) + Expect(gs[1].Name).To(Equal("Dance")) + Expect(gs[2].Name).To(Equal("Electronic")) + }) + }) }) diff --git a/scanner/metadata/metadata.go b/scanner/metadata/metadata.go index bbfd72831..1f4245f01 100644 --- a/scanner/metadata/metadata.go +++ b/scanner/metadata/metadata.go @@ -13,7 +13,6 @@ import ( "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils" ) type Extractor interface { @@ -138,7 +137,7 @@ func (t *Tags) getAllTagValues(tagNames ...string) []string { values = append(values, v...) } } - return utils.UniqueStrings(values) + return values } func (t *Tags) getSortTag(originalTag string, tagNamess ...string) string { diff --git a/scanner/metadata/metadata_test.go b/scanner/metadata/metadata_test.go index d62115e6b..9f98fa120 100644 --- a/scanner/metadata/metadata_test.go +++ b/scanner/metadata/metadata_test.go @@ -66,7 +66,7 @@ var _ = Describe("Tags", func() { md := &Tags{} md.tags = map[string][]string{ "genre": {"Rock", "Pop"}, - "_genre": {"New Wave", "Rock"}, + "_genre": {"New Wave"}, } md.custom = map[string][]string{"genre": {"_genre"}} diff --git a/scanner/metadata/taglib_test.go b/scanner/metadata/taglib_test.go index f949c89a7..8fc07f6a4 100644 --- a/scanner/metadata/taglib_test.go +++ b/scanner/metadata/taglib_test.go @@ -19,7 +19,7 @@ var _ = Describe("taglibExtractor", func() { Expect(m.Artist()).To(Equal("Artist")) Expect(m.AlbumArtist()).To(Equal("Album Artist")) Expect(m.Compilation()).To(BeTrue()) - Expect(m.Genres()).To(ConsistOf("Rock")) + Expect(m.Genres()).To(ConsistOf("Rock", "Rock")) Expect(m.Year()).To(Equal(2014)) n, t := m.TrackNumber() Expect(n).To(Equal(2)) diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go index 09691ddbd..fe5eb58d8 100644 --- a/scanner/scanner_suite_test.go +++ b/scanner/scanner_suite_test.go @@ -3,6 +3,10 @@ package scanner import ( "testing" + "github.com/astaxie/beego/orm" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo" @@ -11,6 +15,9 @@ import ( func TestScanner(t *testing.T) { tests.Init(t, true) + conf.Server.DbPath = "file::memory:?cache=shared" + _ = orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath) + db.EnsureLatestVersion() log.SetLevel(log.LevelCritical) RegisterFailHandler(Fail) RunSpecs(t, "Scanner Suite") diff --git a/scanner/tag_scanner.go b/scanner/tag_scanner.go index 12ed29721..2f8dabeff 100644 --- a/scanner/tag_scanner.go +++ b/scanner/tag_scanner.go @@ -20,16 +20,15 @@ import ( type TagScanner struct { rootFolder string ds model.DataStore - mapper *mediaFileMapper + cacheWarmer core.CacheWarmer plsSync *playlistSync cnt *counters - cacheWarmer core.CacheWarmer + mapper *mediaFileMapper } func NewTagScanner(rootFolder string, ds model.DataStore, cacheWarmer core.CacheWarmer) *TagScanner { return &TagScanner{ rootFolder: rootFolder, - mapper: newMediaFileMapper(rootFolder), plsSync: newPlaylistSync(ds), ds: ds, cacheWarmer: cacheWarmer, @@ -83,6 +82,8 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time, prog allFSDirs := dirMap{} var changedDirs []string s.cnt = &counters{} + genres := newCachedGenreRepository(ctx, s.ds.Genre(ctx)) + s.mapper = newMediaFileMapper(s.rootFolder, genres) foldersFound, walkerError := s.getRootFolderWalker(ctx) for { diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 2be2c6f7b..a0561ba16 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -71,8 +71,8 @@ func AlbumsByYear(fromYear, toYear int) Options { func SongsByGenre(genre string) Options { return Options{ - Sort: "genre asc, title asc", - Filters: squirrel.Eq{"genre": genre}, + Sort: "genre.name asc, title asc", + Filters: squirrel.Eq{"genre.name": genre}, } } @@ -82,7 +82,7 @@ func SongsByRandom(genre string, fromYear, toYear int) Options { } ff := squirrel.And{} if genre != "" { - ff = append(ff, squirrel.Eq{"genre": genre}) + ff = append(ff, squirrel.Eq{"genre.name": genre}) } if fromYear != 0 { ff = append(ff, squirrel.GtOrEq{"year": fromYear})