Add multiple genres to MediaFile

This commit is contained in:
Deluan 2021-07-16 11:03:28 -04:00 committed by Deluan Quintão
parent 7cd3a8ba67
commit 39da741a80
21 changed files with 309 additions and 72 deletions

View file

@ -215,7 +215,7 @@ func init() {
viper.SetDefault("reverseproxywhitelist", "") viper.SetDefault("reverseproxywhitelist", "")
viper.SetDefault("scanner.extractor", "taglib") viper.SetDefault("scanner.extractor", "taglib")
viper.SetDefault("scanner.genreseparators", ";/") viper.SetDefault("scanner.genreseparators", ";/,")
viper.SetDefault("agents", "lastfm,spotify") viper.SetDefault("agents", "lastfm,spotify")
viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.enabled", true)

View file

@ -3,8 +3,8 @@ package model
type Genre struct { type Genre struct {
ID string `json:"id" orm:"column(id)"` ID string `json:"id" orm:"column(id)"`
Name string Name string
SongCount int SongCount int `json:"-"`
AlbumCount int AlbumCount int `json:"-"`
} }
type Genres []Genre type Genres []Genre

View file

@ -28,6 +28,7 @@ type MediaFile struct {
Duration float32 `json:"duration"` Duration float32 `json:"duration"`
BitRate int `json:"bitRate"` BitRate int `json:"bitRate"`
Genre string `json:"genre"` Genre string `json:"genre"`
Genres Genres `json:"genres"`
FullText string `json:"fullText"` FullText string `json:"fullText"`
SortTitle string `json:"sortTitle,omitempty"` SortTitle string `json:"sortTitle,omitempty"`
SortAlbumName string `json:"sortAlbumName,omitempty"` SortAlbumName string `json:"sortAlbumName,omitempty"`

View file

@ -24,15 +24,22 @@ func NewGenreRepository(ctx context.Context, o orm.Ormer) model.GenreRepository
} }
func (r *genreRepository) GetAll() (model.Genres, error) { 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"). sq := Select("*",
From("media_file").GroupBy("genre") "(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{} res := model.Genres{}
err := r.queryAll(sq, &res) err := r.queryAll(sq, &res)
return res, err return res, err
} }
func (r *genreRepository) Put(m *model.Genre) error { 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 return err
} }

View file

@ -21,7 +21,9 @@ var _ = Describe("GenreRepository", func() {
It("returns all records", func() { It("returns all records", func() {
genres, err := repo.GetAll() genres, err := repo.GetAll()
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(genres).To(ContainElement(model.Genre{Name: "Rock", AlbumCount: 2, SongCount: 2})) Expect(genres).To(ConsistOf(
Expect(genres).To(ContainElement(model.Genre{Name: "Electronic", AlbumCount: 1, SongCount: 2})) model.Genre{ID: "gn-1", Name: "Electronic", AlbumCount: 1, SongCount: 2},
model.Genre{ID: "gn-2", Name: "Rock", AlbumCount: 2, SongCount: 3},
))
}) })
}) })

View file

@ -37,28 +37,34 @@ func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileReposito
return r 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...) 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})) 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.FullText = getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist,
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle) 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) _, 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.*") sql := r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*")
return r.withBookmark(sql, "media_file.id") return r.withBookmark(sql, "media_file.id")
} }
func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) { func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) {
sel := r.selectMediaFile().Where(Eq{"id": id}) sel := r.selectMediaFile().Where(Eq{"media_file.id": id})
var res model.MediaFiles var res model.MediaFiles
if err := r.queryAll(sel, &res); err != nil { if err := r.queryAll(sel, &res); err != nil {
return nil, err return nil, err
@ -66,24 +72,33 @@ func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) {
if len(res) == 0 { if len(res) == 0 {
return nil, model.ErrNotFound 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) { func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
sq := r.selectMediaFile(options...) 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{} res := model.MediaFiles{}
err := r.queryAll(sq, &res) err := r.queryAll(sq, &res)
if err != nil {
return nil, err
}
err = r.loadMediaFileGenres(&res)
return res, err return res, err
} }
func (r mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, error) { func (r *mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, error) {
sel := r.selectMediaFile(model.QueryOptions{Sort: "album"}).Where(Eq{"album_id": albumId}) options := model.QueryOptions{
res := model.MediaFiles{} Filters: Eq{"album_id": albumId},
err := r.queryAll(sel, &res) Sort: "album",
return res, err }
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}) sel := r.selectMediaFile().Where(Eq{"path": path})
var res model.MediaFiles var res model.MediaFiles
if err := r.queryAll(sel, &res); err != nil { 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 // 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 // Query by path based on https://stackoverflow.com/a/13911906/653632
path = cleanPath(path) path = cleanPath(path)
pathLen := utf8.RuneCountInString(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 // 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) path := cleanPath(basePath)
// Query based on https://stackoverflow.com/a/38330814/653632 // Query based on https://stackoverflow.com/a/38330814/653632
sel := r.newSelect().Columns(fmt.Sprintf("distinct rtrim(path, replace(path, '%s', ''))", string(os.PathSeparator))). 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 return res, err
} }
func (r mediaFileRepository) deleteNotInPath(basePath string) error { func (r *mediaFileRepository) deleteNotInPath(basePath string) error {
path := cleanPath(basePath) path := cleanPath(basePath)
sel := Delete(r.tableName).Where(NotEq(pathStartsWith(path))) sel := Delete(r.tableName).Where(NotEq(pathStartsWith(path)))
c, err := r.executeSQL(sel) c, err := r.executeSQL(sel)
@ -146,28 +161,29 @@ func (r mediaFileRepository) deleteNotInPath(basePath string) error {
return err return err
} }
func (r mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.MediaFiles, error) { func (r *mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.MediaFiles, error) {
sq := r.selectMediaFile(options...).Where("starred = true") if len(options) == 0 {
starred := model.MediaFiles{} options = []model.QueryOptions{{}}
err := r.queryAll(sq, &starred) }
return starred, err options[0].Filters = Eq{"starred": true}
return r.GetAll(options...)
} }
// TODO Keep order when paginating // TODO Keep order when paginating
func (r mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) { func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
sq := r.selectMediaFile(options...) if len(options) == 0 {
sq = sq.OrderBy("RANDOM()") options = []model.QueryOptions{{}}
results := model.MediaFiles{} }
err := r.queryAll(sq, &results) options[0].Sort = "random()"
return results, err return r.GetAll(options...)
} }
func (r mediaFileRepository) Delete(id string) error { func (r *mediaFileRepository) Delete(id string) error {
return r.delete(Eq{"id": id}) return r.delete(Eq{"id": id})
} }
// DeleteByPath delete from the DB all mediafiles that are direct children of path // 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) path := cleanPath(basePath)
pathLen := utf8.RuneCountInString(path) pathLen := utf8.RuneCountInString(path)
del := Delete(r.tableName). del := Delete(r.tableName).
@ -177,39 +193,39 @@ func (r mediaFileRepository) DeleteByPath(basePath string) (int64, error) {
return r.executeSQL(del) 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{} results := model.MediaFiles{}
err := r.doSearch(q, offset, size, &results, "title") err := r.doSearch(q, offset, size, &results, "title")
return results, err 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...)) 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) 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...)) return r.GetAll(r.parseRestOptions(options...))
} }
func (r mediaFileRepository) EntityName() string { func (r *mediaFileRepository) EntityName() string {
return "mediafile" return "mediafile"
} }
func (r mediaFileRepository) NewInstance() interface{} { func (r *mediaFileRepository) NewInstance() interface{} {
return &model.MediaFile{} return &model.MediaFile{}
} }
func (r mediaFileRepository) Save(entity interface{}) (string, error) { func (r *mediaFileRepository) Save(entity interface{}) (string, error) {
mf := entity.(*model.MediaFile) mf := entity.(*model.MediaFile)
err := r.Put(mf) err := r.Put(mf)
return mf.ID, err 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) mf := entity.(*model.MediaFile)
return r.Put(mf) return r.Put(mf)
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"time" "time"
"github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm" "github.com/astaxie/beego/orm"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
@ -149,6 +150,17 @@ var _ = Describe("MediaRepository", func() {
Expect(mr.FindAllByPath(P("/music/overlap"))).To(HaveLen(1)) 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() { Context("Annotations", func() {
It("increments play count when the tracks does not have annotations", func() { It("increments play count when the tracks does not have annotations", func() {
id := "incplay.firsttime" id := "incplay.firsttime"

View file

@ -25,11 +25,17 @@ func TestPersistence(t *testing.T) {
conf.Server.DbPath = "file::memory:?cache=shared" conf.Server.DbPath = "file::memory:?cache=shared"
_ = orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath) _ = orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath)
db.EnsureLatestVersion() db.EnsureLatestVersion()
log.SetLevel(log.LevelCritical) log.SetLevel(log.LevelError)
RegisterFailHandler(Fail) RegisterFailHandler(Fail)
RunSpecs(t, "Persistence Suite") 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 ( var (
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: " kraftwerk"} artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: " beatles the"} artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: " beatles the"}
@ -51,10 +57,10 @@ var (
) )
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"} 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", Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"} 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", Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"} 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", Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk"} 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{ testSongs = model.MediaFiles{
songDayInALife, songDayInALife,
songComeTogether, songComeTogether,
@ -87,6 +93,16 @@ var _ = Describe("Initialize test DB", func() {
o := orm.NewOrm() o := orm.NewOrm()
ctx := log.NewContext(context.TODO()) ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid"}) 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) mr := NewMediaFileRepository(ctx, o)
for i := range testSongs { for i := range testSongs {
s := testSongs[i] s := testSongs[i]

View file

@ -141,6 +141,7 @@ func (r *playlistRepository) loadTracks(pls *model.Playlist) error {
if err != nil { if err != nil {
log.Error("Error loading playlist tracks", "playlist", pls.Name, "id", pls.ID) log.Error("Error loading playlist tracks", "playlist", pls.Name, "id", pls.ID)
} }
err = r.loadMediaFileGenres(&pls.Tracks)
return err return err
} }

View file

@ -119,7 +119,7 @@ func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFil
mfRepo := NewMediaFileRepository(r.ctx, r.ormer) mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
trackMap := map[string]model.MediaFile{} trackMap := map[string]model.MediaFile{}
for i := range chunks { for i := range chunks {
idsFilter := Eq{"id": chunks[i]} idsFilter := Eq{"media_file.id": chunks[i]}
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter}) tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter})
if err != nil { if err != nil {
u := loggedUser(r.ctx) u := loggedUser(r.ctx)

View file

@ -42,13 +42,13 @@ var _ = Describe("PlayQueueRepository", func() {
By("Storing a new playqueue for the same user") By("Storing a new playqueue for the same user")
new := aPlayQueue("user1", songRadioactivity.ID, 321, songAntenna, songRadioactivity) another := aPlayQueue("user1", songRadioactivity.ID, 321, songAntenna, songRadioactivity)
Expect(repo.Store(new)).To(BeNil()) Expect(repo.Store(another)).To(BeNil())
actual, err = repo.Retrieve("user1") actual, err = repo.Retrieve("user1")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
AssertPlayQueue(new, actual) AssertPlayQueue(another, actual)
Expect(countPlayQueues(repo, "user1")).To(Equal(1)) Expect(countPlayQueues(repo, "user1")).To(Equal(1))
}) })
}) })

56
persistence/sql_genres.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -10,6 +10,7 @@ import (
"github.com/kennygrant/sanitize" "github.com/kennygrant/sanitize"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/scanner/metadata" "github.com/navidrome/navidrome/scanner/metadata"
@ -19,10 +20,15 @@ import (
type mediaFileMapper struct { type mediaFileMapper struct {
rootFolder string rootFolder string
policy *bluemonday.Policy policy *bluemonday.Policy
genres model.GenreRepository
} }
func newMediaFileMapper(rootFolder string) *mediaFileMapper { func newMediaFileMapper(rootFolder string, genres model.GenreRepository) *mediaFileMapper {
return &mediaFileMapper{rootFolder: rootFolder, policy: bluemonday.UGCPolicy()} return &mediaFileMapper{
rootFolder: rootFolder,
policy: bluemonday.UGCPolicy(),
genres: genres,
}
} }
func (s *mediaFileMapper) toMediaFile(md *metadata.Tags) model.MediaFile { 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.Artist = s.mapArtistName(md)
mf.AlbumArtistID = s.albumArtistID(md) mf.AlbumArtistID = s.albumArtistID(md)
mf.AlbumArtist = s.mapAlbumArtistName(md) mf.AlbumArtist = s.mapAlbumArtistName(md)
if len(md.Genres()) > 0 { mf.Genre, mf.Genres = s.mapGenres(md.Genres())
mf.Genre = md.Genres()[0]
}
mf.Compilation = md.Compilation() mf.Compilation = md.Compilation()
mf.Year = md.Year() mf.Year = md.Year()
mf.TrackNumber, _ = md.TrackNumber() mf.TrackNumber, _ = md.TrackNumber()
@ -131,3 +135,32 @@ func (s *mediaFileMapper) artistID(md *metadata.Tags) string {
func (s *mediaFileMapper) albumArtistID(md *metadata.Tags) string { func (s *mediaFileMapper) albumArtistID(md *metadata.Tags) string {
return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapAlbumArtistName(md))))) 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
}

View file

@ -1,7 +1,12 @@
package scanner package scanner
import ( import (
"context"
"github.com/astaxie/beego/orm"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
. "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
@ -21,4 +26,40 @@ var _ = Describe("mapping", func() {
Expect(sanitizeFieldForSorting("Õ Blésq Blom")).To(Equal("Blesq Blom")) 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"))
})
})
}) })

View file

@ -13,7 +13,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils"
) )
type Extractor interface { type Extractor interface {
@ -138,7 +137,7 @@ func (t *Tags) getAllTagValues(tagNames ...string) []string {
values = append(values, v...) values = append(values, v...)
} }
} }
return utils.UniqueStrings(values) return values
} }
func (t *Tags) getSortTag(originalTag string, tagNamess ...string) string { func (t *Tags) getSortTag(originalTag string, tagNamess ...string) string {

View file

@ -66,7 +66,7 @@ var _ = Describe("Tags", func() {
md := &Tags{} md := &Tags{}
md.tags = map[string][]string{ md.tags = map[string][]string{
"genre": {"Rock", "Pop"}, "genre": {"Rock", "Pop"},
"_genre": {"New Wave", "Rock"}, "_genre": {"New Wave"},
} }
md.custom = map[string][]string{"genre": {"_genre"}} md.custom = map[string][]string{"genre": {"_genre"}}

View file

@ -19,7 +19,7 @@ var _ = Describe("taglibExtractor", func() {
Expect(m.Artist()).To(Equal("Artist")) Expect(m.Artist()).To(Equal("Artist"))
Expect(m.AlbumArtist()).To(Equal("Album Artist")) Expect(m.AlbumArtist()).To(Equal("Album Artist"))
Expect(m.Compilation()).To(BeTrue()) Expect(m.Compilation()).To(BeTrue())
Expect(m.Genres()).To(ConsistOf("Rock")) Expect(m.Genres()).To(ConsistOf("Rock", "Rock"))
Expect(m.Year()).To(Equal(2014)) Expect(m.Year()).To(Equal(2014))
n, t := m.TrackNumber() n, t := m.TrackNumber()
Expect(n).To(Equal(2)) Expect(n).To(Equal(2))

View file

@ -3,6 +3,10 @@ package scanner
import ( import (
"testing" "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/log"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo"
@ -11,6 +15,9 @@ import (
func TestScanner(t *testing.T) { func TestScanner(t *testing.T) {
tests.Init(t, true) 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) log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail) RegisterFailHandler(Fail)
RunSpecs(t, "Scanner Suite") RunSpecs(t, "Scanner Suite")

View file

@ -20,16 +20,15 @@ import (
type TagScanner struct { type TagScanner struct {
rootFolder string rootFolder string
ds model.DataStore ds model.DataStore
mapper *mediaFileMapper cacheWarmer core.CacheWarmer
plsSync *playlistSync plsSync *playlistSync
cnt *counters cnt *counters
cacheWarmer core.CacheWarmer mapper *mediaFileMapper
} }
func NewTagScanner(rootFolder string, ds model.DataStore, cacheWarmer core.CacheWarmer) *TagScanner { func NewTagScanner(rootFolder string, ds model.DataStore, cacheWarmer core.CacheWarmer) *TagScanner {
return &TagScanner{ return &TagScanner{
rootFolder: rootFolder, rootFolder: rootFolder,
mapper: newMediaFileMapper(rootFolder),
plsSync: newPlaylistSync(ds), plsSync: newPlaylistSync(ds),
ds: ds, ds: ds,
cacheWarmer: cacheWarmer, cacheWarmer: cacheWarmer,
@ -83,6 +82,8 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time, prog
allFSDirs := dirMap{} allFSDirs := dirMap{}
var changedDirs []string var changedDirs []string
s.cnt = &counters{} s.cnt = &counters{}
genres := newCachedGenreRepository(ctx, s.ds.Genre(ctx))
s.mapper = newMediaFileMapper(s.rootFolder, genres)
foldersFound, walkerError := s.getRootFolderWalker(ctx) foldersFound, walkerError := s.getRootFolderWalker(ctx)
for { for {

View file

@ -71,8 +71,8 @@ func AlbumsByYear(fromYear, toYear int) Options {
func SongsByGenre(genre string) Options { func SongsByGenre(genre string) Options {
return Options{ return Options{
Sort: "genre asc, title asc", Sort: "genre.name asc, title asc",
Filters: squirrel.Eq{"genre": genre}, Filters: squirrel.Eq{"genre.name": genre},
} }
} }
@ -82,7 +82,7 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
} }
ff := squirrel.And{} ff := squirrel.And{}
if genre != "" { if genre != "" {
ff = append(ff, squirrel.Eq{"genre": genre}) ff = append(ff, squirrel.Eq{"genre.name": genre})
} }
if fromYear != 0 { if fromYear != 0 {
ff = append(ff, squirrel.GtOrEq{"year": fromYear}) ff = append(ff, squirrel.GtOrEq{"year": fromYear})