package scanner import ( "context" "fmt" "path/filepath" "strings" "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/slice" "golang.org/x/exp/maps" ) // refresher is responsible for rolling up mediafiles attributes into albums attributes, // and albums attributes into artists attributes. This is done by accumulating all album and artist IDs // found during scan, and "refreshing" the albums and artists when flush is called. // // The actual mappings happen in MediaFiles.ToAlbum() and Albums.ToAlbumArtist() type refresher struct { ds model.DataStore album map[string]struct{} artist map[string]struct{} dirMap dirMap cacheWarmer artwork.CacheWarmer } func newRefresher(ds model.DataStore, cw artwork.CacheWarmer, dirMap dirMap) *refresher { return &refresher{ ds: ds, album: map[string]struct{}{}, artist: map[string]struct{}{}, dirMap: dirMap, cacheWarmer: cw, } } func (r *refresher) accumulate(mf model.MediaFile) { if mf.AlbumID != "" { r.album[mf.AlbumID] = struct{}{} } if mf.AlbumArtistID != "" { r.artist[mf.AlbumArtistID] = struct{}{} } } func (r *refresher) flush(ctx context.Context) error { err := r.flushMap(ctx, r.album, "album", r.refreshAlbums) if err != nil { return err } r.album = map[string]struct{}{} err = r.flushMap(ctx, r.artist, "artist", r.refreshArtists) if err != nil { return err } r.artist = map[string]struct{}{} return nil } type refreshCallbackFunc = func(ctx context.Context, ids ...string) error func (r *refresher) flushMap(ctx context.Context, m map[string]struct{}, entity string, refresh refreshCallbackFunc) error { if len(m) == 0 { return nil } ids := maps.Keys(m) chunks := utils.BreakUpStringSlice(ids, 100) for _, chunk := range chunks { err := refresh(ctx, chunk...) if err != nil { log.Error(ctx, fmt.Sprintf("Error writing %ss to the DB", entity), err) return err } } return nil } func (r *refresher) refreshAlbums(ctx context.Context, ids ...string) error { mfs, err := r.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": ids}}) if err != nil { return err } if len(mfs) == 0 { return nil } repo := r.ds.Album(ctx) grouped := slice.Group(mfs, func(m model.MediaFile) string { return m.AlbumID }) for _, group := range grouped { songs := model.MediaFiles(group) a := songs.ToAlbum() var updatedAt time.Time a.ImageFiles, updatedAt = r.getImageFiles(songs.Dirs()) if updatedAt.After(a.UpdatedAt) { a.UpdatedAt = updatedAt } err := repo.Put(&a) if err != nil { return err } r.cacheWarmer.PreCache(a.CoverArtID()) } return nil } func (r *refresher) getImageFiles(dirs []string) (string, time.Time) { var imageFiles []string var updatedAt time.Time for _, dir := range dirs { stats := r.dirMap[dir] for _, img := range stats.Images { imageFiles = append(imageFiles, filepath.Join(dir, img)) } if stats.ImagesUpdatedAt.After(updatedAt) { updatedAt = stats.ImagesUpdatedAt } } return strings.Join(imageFiles, string(filepath.ListSeparator)), updatedAt } func (r *refresher) refreshArtists(ctx context.Context, ids ...string) error { albums, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_artist_id": ids}}) if err != nil { return err } if len(albums) == 0 { return nil } repo := r.ds.Artist(ctx) grouped := slice.Group(albums, func(al model.Album) string { return al.AlbumArtistID }) for _, group := range grouped { a := model.Albums(group).ToAlbumArtist() // Force a external metadata lookup on next access a.ExternalInfoUpdatedAt = time.Time{} err := repo.Put(&a) if err != nil { return err } r.cacheWarmer.PreCache(a.CoverArtID()) } return nil }