package persistence import ( "cmp" "context" "encoding/json" "errors" "fmt" "maps" "os" "slices" "strings" "time" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" "github.com/pocketbase/dbx" ) type artistRepository struct { sqlRepository indexGroups utils.IndexGroups } type dbArtist struct { *model.Artist `structs:",flatten"` SimilarArtists string `structs:"-" json:"-"` LibraryStatsJSON string `structs:"-" json:"-"` } type dbSimilarArtist struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` } func (a *dbArtist) PostScan() error { a.Artist.Stats = make(map[model.Role]model.ArtistStats) if a.LibraryStatsJSON != "" { var rawLibStats map[string]map[string]map[string]int64 if err := json.Unmarshal([]byte(a.LibraryStatsJSON), &rawLibStats); err != nil { return fmt.Errorf("parsing artist stats from db: %w", err) } for _, stats := range rawLibStats { // Sum all libraries roles stats for key, stat := range stats { // Aggregate stats into the main Artist.Stats map artistStats := model.ArtistStats{ SongCount: int(stat["m"]), AlbumCount: int(stat["a"]), Size: stat["s"], } // Store total stats into the main attributes if key == "total" { a.Artist.Size += artistStats.Size a.Artist.SongCount += artistStats.SongCount a.Artist.AlbumCount += artistStats.AlbumCount } role := model.RoleFromString(key) if role == model.RoleInvalid { continue } current := a.Artist.Stats[role] current.Size += artistStats.Size current.SongCount += artistStats.SongCount current.AlbumCount += artistStats.AlbumCount a.Artist.Stats[role] = current } } } a.Artist.SimilarArtists = nil if a.SimilarArtists == "" { return nil } var sa []dbSimilarArtist if err := json.Unmarshal([]byte(a.SimilarArtists), &sa); err != nil { return fmt.Errorf("parsing similar artists from db: %w", err) } for _, s := range sa { a.Artist.SimilarArtists = append(a.Artist.SimilarArtists, model.Artist{ ID: s.ID, Name: s.Name, }) } return nil } func (a *dbArtist) PostMapArgs(m map[string]any) error { sa := make([]dbSimilarArtist, 0) for _, s := range a.Artist.SimilarArtists { sa = append(sa, dbSimilarArtist{ID: s.ID, Name: s.Name}) } similarArtists, _ := json.Marshal(sa) m["similar_artists"] = string(similarArtists) // When adding a derived column here, also add it to the scanner's artist Put column list // in phase_1_folders.go, or rescans will never update it (how search_normalized went stale). m["full_text"] = formatFullText(a.Name, a.SortArtistName) m["search_normalized"] = str.NormalizeForFTS(a.Name) // Do not override the sort_artist_name and mbz_artist_id fields if they are empty // TODO: Better way to handle this? if v, ok := m["sort_artist_name"]; !ok || v.(string) == "" { delete(m, "sort_artist_name") } if v, ok := m["mbz_artist_id"]; !ok || v.(string) == "" { delete(m, "mbz_artist_id") } return nil } type dbArtists []dbArtist func (dba dbArtists) toModels() model.Artists { res := make(model.Artists, len(dba)) for i := range dba { res[i] = *dba[i].Artist } return res } func NewArtistRepository(db dbx.Builder) model.ArtistRepository { r := &artistRepository{} r.db = db r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups) r.tableName = "artist" // To be used by the idFilter below r.registerModel(&model.Artist{}, map[string]filterFunc{ "id": idFilter(r.tableName), "name": fullTextFilter(r.tableName, "mbz_artist_id"), "starred": annotationBoolFilter("starred"), "has_rating": annotationBoolFilter("rating"), "role": roleFilter, "missing": booleanFilter, "library_id": artistLibraryIdFilter, }) r.setSortMappings(map[string]string{ //nolint:gosec "name": "order_artist_name", "starred_at": "starred, starred_at", "rated_at": "rating, rated_at", "song_count": "stats->>'total'->>'m'", "album_count": "stats->>'total'->>'a'", "size": "stats->>'total'->>'s'", // Stats by credits that are currently available "maincredit_song_count": "sum(stats->>'maincredit'->>'m')", "maincredit_album_count": "sum(stats->>'maincredit'->>'a')", "maincredit_size": "sum(stats->>'maincredit'->>'s')", }) return r } func roleFilter(_ string, role any) Sqlizer { if role, ok := role.(string); ok { if safe, ok := sanitizeArtistStatsRole(role); ok && safe != "total" { return Expr("JSON_EXTRACT(library_artist.stats, '$." + safe + ".m') IS NOT NULL") } } return Eq{"1": 2} } // sanitizeArtistStatsRole allowlists values interpolated into JSON paths for artist // stats (filter and sort). "total" is the aggregate key stored by the scanner. // Unknown values must not reach SQL string concatenation. func sanitizeArtistStatsRole(role string) (string, bool) { if role == "" || role == "total" { return "total", true } if _, ok := model.AllRoles[role]; ok { return role, true } return "", false } // artistLibraryIdFilter filters artists based on library access through the library_artist table func artistLibraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_artist.library_id": value} } // applyLibraryFilterToArtistQuery applies library filtering to artist queries through the library_artist junction table func (r *artistRepository) applyLibraryFilterToArtistQuery(ctx context.Context, query SelectBuilder) SelectBuilder { user := loggedUser(ctx) // Join with library_artist first to ensure only artists with content in libraries are included // Exclude artists with empty stats (no actual content in the library) query = query.Join("library_artist on library_artist.artist_id = artist.id") //query = query.Join("library_artist on library_artist.artist_id = artist.id AND library_artist.stats != '{}'") // Admin users see all artists from all libraries, no additional filtering needed if user.ID != invalidUserId && !user.IsAdmin { // Apply library filtering only for non-admin users by joining with their accessible libraries query = query.Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID) } return query } func (r *artistRepository) selectArtist(ctx context.Context, options ...model.QueryOptions) SelectBuilder { // Stats Format: {"1": {"albumartist": {"m": 10, "a": 5, "s": 1024}, "artist": {...}}, "2": {...}} query := r.newSelect(ctx, options...).Columns("artist.*", "JSON_GROUP_OBJECT(library_artist.library_id, JSONB(library_artist.stats)) as library_stats_json") query = r.applyLibraryFilterToArtistQuery(ctx, query) query = query.GroupBy("artist.id") return r.withAnnotation(ctx, query, "artist.id") } func (r *artistRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { query := r.newSelect(ctx) query = r.applyLibraryFilterToArtistQuery(ctx, query) // Only the annotation join is gated; the library_artist join above (and its count(distinct)) // must stay, since an artist can span multiple libraries. if filtersNeedAnnotation(r.applyFilters(query, options...)) { query = r.withAnnotation(ctx, query, "artist.id") } return r.count(ctx, query, options...) } // Exists checks if an artist with the given ID exists in the database and is accessible by the current user. func (r *artistRepository) Exists(ctx context.Context, id string) (bool, error) { // Create a query using the same library filtering logic as selectArtist() query := r.newSelect(ctx).Columns("count(distinct artist.id) as exist").Where(Eq{"artist.id": id}) query = r.applyLibraryFilterToArtistQuery(ctx, query) var res struct{ Exist int64 } err := r.queryOne(ctx, query, &res) return res.Exist > 0, err } func (r *artistRepository) Put(ctx context.Context, a *model.Artist, colsToUpdate ...string) error { dba := &dbArtist{Artist: a} dba.CreatedAt = new(time.Now()) dba.UpdatedAt = dba.CreatedAt _, err := r.put(ctx, dba.ID, dba, colsToUpdate...) return err } func (r *artistRepository) UpdateExternalInfo(ctx context.Context, a *model.Artist) error { dba := &dbArtist{Artist: a} _, err := r.put(ctx, a.ID, dba, "biography", "small_image_url", "medium_image_url", "large_image_url", "similar_artists", "external_url", "external_info_updated_at") return err } func (r *artistRepository) Get(ctx context.Context, id string) (*model.Artist, error) { sel := r.selectArtist(ctx).Where(Eq{"artist.id": id}) var dba dbArtists if err := r.queryAll(ctx, sel, &dba); err != nil { return nil, err } if len(dba) == 0 { return nil, model.ErrNotFound } res := dba.toModels() r.hydrateArtwork(ctx, res) return &res[0], nil } func (r *artistRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Artists, error) { sel := r.selectArtist(ctx, options...) var dba dbArtists err := r.queryAll(ctx, sel, &dba) if err != nil { return nil, err } res := dba.toModels() r.hydrateArtwork(ctx, res) return res, err } // getAllIDs returns just the artist IDs for the same row set as GetAll, skipping the // heavy stats columns and JSON post-processing. func (r *artistRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { sq := r.applyLibraryFilterToArtistQuery(ctx, r.newSelect(ctx, options...).Columns("artist.id")).GroupBy("artist.id") if filtersNeedAnnotation(sq) { sq = r.withAnnotation(ctx, sq, "artist.id") } ids := []string{} err := r.queryAllSlice(ctx, sq, &ids) return ids, err } // hydrateArtwork fills each artist's ImageHash/ImageAbsent from one batched item_artwork lookup. func (r *artistRepository) hydrateArtwork(ctx context.Context, artists model.Artists) { hydrateItems(ctx, r.db, model.KindArtistArtwork, artists, func(a *model.Artist) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) } func (r *artistRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.ArtistCursor, error) { ids, err := r.getAllIDs(ctx, options...) if err != nil { return nil, err } opts := chunkOptions(options, "artist.id") return model.ArtistCursor(streamByIDs(ids, func(chunk []string) (model.Artists, error) { return r.GetAll(ctx, opts(chunk)) })), nil } func (r *artistRepository) getIndexKey(a model.Artist) string { source := a.OrderArtistName if conf.Server.PreferSortTags { source = cmp.Or(a.SortArtistName, a.OrderArtistName) } name := strings.ToLower(source) for k, v := range r.indexGroups { if strings.HasPrefix(name, strings.ToLower(k)) { return v } } return "#" } // GetIndex returns a list of artists grouped by the first letter of their name, or by the index group if configured. // It can filter by roles and libraries, and optionally include artists that are missing (i.e., have no albums). // TODO Cache the index (recalculate at scan time) func (r *artistRepository) GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { // Validate library IDs. If no library IDs are provided, return an empty index. if len(libraryIds) == 0 { return nil, nil } options := model.QueryOptions{Sort: "name"} if len(roles) > 0 { roleFilters := slice.Map(roles, func(r model.Role) Sqlizer { return roleFilter("role", r.String()) }) options.Filters = Or(roleFilters) } if !includeMissing { if options.Filters == nil { options.Filters = Eq{"artist.missing": false} } else { options.Filters = And{options.Filters, Eq{"artist.missing": false}} } } libFilter := artistLibraryIdFilter("library_id", libraryIds) if options.Filters == nil { options.Filters = libFilter } else { options.Filters = And{options.Filters, libFilter} } artists, err := r.GetAll(ctx, options) if err != nil { return nil, err } var result model.ArtistIndexes for k, v := range slice.Group(artists, r.getIndexKey) { result = append(result, model.ArtistIndex{ID: k, Artists: v}) } slices.SortFunc(result, func(a, b model.ArtistIndex) int { return cmp.Compare(a.ID, b.ID) }) return result, nil } func (r *artistRepository) purgeEmpty(ctx context.Context) error { orphanFilter := "id not in (select artist_id from album_artists)" // Collect uploaded image filenames before deleting sel := Select("uploaded_image").From(r.tableName). Where(orphanFilter). Where("uploaded_image != ''") var imageFiles []string if err := r.queryAllSlice(ctx, sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) { return fmt.Errorf("collecting artist images for cleanup: %w", err) } // Delete orphan artists del := Delete(r.tableName).Where(orphanFilter) c, err := r.executeSQL(ctx, del) if err != nil { return fmt.Errorf("purging empty artists: %w", err) } if c > 0 { log.Debug(ctx, "Purged empty artists", "totalDeleted", c) } if len(imageFiles) == 0 { return nil } // Best-effort cleanup of uploaded image files log.Debug(ctx, "Cleaning up artist images", "totalImages", len(imageFiles)) for _, filename := range imageFiles { path := model.UploadedImagePath(consts.EntityArtist, filename) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { log.Warn(ctx, "Failed to remove artist image during GC", "path", path, err) } } return nil } // markOrphansMissing flags as missing any non-missing artist with no library_artist row, keeping the // search fast-path's `missing = false` filter correct (see searchCfg). Called wherever such a row can // be dropped: RefreshStats cleanup and library deletion cascade. func (r *artistRepository) markOrphansMissing(ctx context.Context) error { _, err := r.executeSQL(ctx, Expr( "update artist set missing = true where missing = false "+ "and not exists (select 1 from library_artist where library_artist.artist_id = artist.id)")) if err != nil { return fmt.Errorf("marking orphaned artists missing: %w", err) } return nil } // markMissing marks artists as missing if all their albums are missing. func (r *artistRepository) markMissing(ctx context.Context) error { q := Expr(` with artists_with_non_missing_albums as ( select distinct aa.artist_id from album_artists aa join album a on aa.album_id = a.id where a.missing = false ) update artist set missing = (artist.id not in (select artist_id from artists_with_non_missing_albums)); `) _, err := r.executeSQL(ctx, q) if err != nil { return fmt.Errorf("marking missing artists: %w", err) } return nil } // RefreshPlayCounts updates the play count and last play date annotations for all artists, based // on the media files associated with them. func (r *artistRepository) RefreshPlayCounts(ctx context.Context) (int64, error) { query := Expr(` with play_counts as ( select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date from media_file join annotation on item_id = media_file.id left join json_tree(participants, '$.artist') as jt where atom is not null and key = 'id' group by user_id, atom ) insert into annotation (user_id, item_id, item_type, play_count, play_date) select user_id, artist_id, 'artist', total_play_count, last_play_date from play_counts where total_play_count > 0 on conflict (user_id, item_id, item_type) do update set play_count = excluded.play_count, play_date = excluded.play_date; `) return r.executeSQL(ctx, query) } // RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time. // When allArtists is true, it refreshes stats for all artists. It processes artists in batches to handle potentially large updates. // This method now calculates per-library statistics and stores them in the library_artist junction table. func (r *artistRepository) RefreshStats(ctx context.Context, allArtists bool) (int64, error) { var allTouchedArtistIDs []string if allArtists { // Refresh stats for all artists allArtistsQuerySQL := `SELECT DISTINCT id FROM artist WHERE id <> ''` if err := r.db.NewQuery(allArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil { return 0, fmt.Errorf("fetching all artist IDs: %w", err) } log.Debug(ctx, "RefreshStats: Refreshing all artists.", "count", len(allTouchedArtistIDs)) } else { // Only refresh artists with updated timestamps touchedArtistsQuerySQL := ` SELECT DISTINCT id FROM artist WHERE updated_at > (SELECT last_scan_at FROM library ORDER BY last_scan_at ASC LIMIT 1) ` if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil { return 0, fmt.Errorf("fetching touched artist IDs: %w", err) } log.Debug(ctx, "RefreshStats: Refreshing touched artists.", "count", len(allTouchedArtistIDs)) } if len(allTouchedArtistIDs) == 0 { log.Debug(ctx, "RefreshStats: No artists to update.") return 0, nil } // Template for the batch update with placeholder markers that we'll replace // This now calculates per-library statistics and stores them in library_artist.stats batchUpdateStatsSQL := ` WITH artist_role_counters AS ( SELECT mfa.artist_id, mf.library_id, mfa.role, count(DISTINCT mf.album_id) AS album_count, count(DISTINCT mf.id) AS count, sum(mf.size) AS size FROM media_file_artists mfa JOIN media_file mf ON mfa.media_file_id = mf.id WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders GROUP BY mfa.artist_id, mf.library_id, mfa.role ), artist_total_counters AS ( SELECT mfa.artist_id, mf.library_id, 'total' AS role, count(DISTINCT mf.album_id) AS album_count, count(DISTINCT mf.id) AS count, sum(mf.size) AS size FROM media_file_artists mfa JOIN media_file mf ON mfa.media_file_id = mf.id WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders GROUP BY mfa.artist_id, mf.library_id ), artist_participant_counter AS ( SELECT mfa.artist_id, mf.library_id, 'maincredit' AS role, count(DISTINCT mf.album_id) AS album_count, count(DISTINCT mf.id) AS count, sum(mf.size) AS size FROM media_file_artists mfa JOIN media_file mf ON mfa.media_file_id = mf.id WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders AND mfa.role IN ('albumartist', 'artist') GROUP BY mfa.artist_id, mf.library_id ), combined_counters AS ( SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters UNION ALL SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters UNION ALL SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter ), library_artist_counters AS ( SELECT artist_id, library_id, json_group_object( role, json_object('a', album_count, 'm', count, 's', size) ) AS counters FROM combined_counters GROUP BY artist_id, library_id ) UPDATE library_artist SET stats = coalesce((SELECT counters FROM library_artist_counters lac WHERE lac.artist_id = library_artist.artist_id AND lac.library_id = library_artist.library_id), '{}') WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders var totalRowsAffected int64 = 0 const batchSize = 1000 batchCounter := 0 for artistIDBatch := range slice.CollectChunks(slices.Values(allTouchedArtistIDs), batchSize) { batchCounter++ log.Trace(ctx, "RefreshStats: Processing batch", "batchNum", batchCounter, "batchSize", len(artistIDBatch)) // Create placeholders for each ID in the IN clauses placeholders := make([]string, len(artistIDBatch)) for i := range artistIDBatch { placeholders[i] = "?" } // Don't add extra parentheses, the IN clause already expects them in SQL syntax inClause := strings.Join(placeholders, ",") // Replace the placeholder markers with actual SQL placeholders batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 4) // Create a single parameter array with all IDs (repeated 4 times for each IN clause) // We need to repeat each ID 4 times (once for each IN clause) args := make([]any, 4*len(artistIDBatch)) for idx, id := range artistIDBatch { for i := range 4 { startIdx := i * len(artistIDBatch) args[startIdx+idx] = id } } // Now use Expr with the expanded SQL and all parameters sqlizer := Expr(batchSQL, args...) rowsAffected, err := r.executeSQL(ctx, sqlizer) if err != nil { return totalRowsAffected, fmt.Errorf("executing batch update for artist stats (batch %d): %w", batchCounter, err) } totalRowsAffected += rowsAffected } // Remove library_artist entries for artists that no longer have any content in a library. cleanupSQL := Delete("library_artist").Where("stats = '{}'") cleanupRows, err := r.executeSQL(ctx, cleanupSQL) if err != nil { log.Warn(ctx, "Failed to cleanup empty library_artist entries", err) } else { if cleanupRows > 0 { log.Debug(ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) } // Reconcile orphans whenever the cleanup removed rows, and on a full refresh so a full scan // also heals any left by older versions. if cleanupRows > 0 || allArtists { if err := r.markOrphansMissing(ctx); err != nil { log.Warn(ctx, "Failed to mark orphaned artists missing after library_artist cleanup", err) } } } log.Debug(ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected) return totalRowsAffected, nil } // searchCfg builds the per-search config. scope is the set of library IDs the rowid Phase 1 must // restrict artists to, or nil to skip the filter (fast-path). See [artistRepository.searchScope]. func (r *artistRepository) searchCfg(scope []int) searchConfig { return searchConfig{ // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist NaturalOrder: "artist.id", OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"}, MBIDFields: []string{"mbz_artist_id"}, // scope==nil is the fast-path: no filter (and orphans must not exist — see markOrphansMissing). // Otherwise the join-free [artistLibraryFilter]. LibraryFilter: func(query SelectBuilder) SelectBuilder { if scope == nil { return query } return query.Where(artistLibraryFilter(scope)) }, } } // artistLibraryFilter restricts artists to the given libraries via a correlated EXISTS over the // library_artist junction, staying join-free so it can scope the join-free search Phase 1 (a JOIN // would fan out rowids and corrupt offset pagination). The inner LIMIT 1 is load-bearing: it stops // SQLite from flattening the EXISTS back into a fan-out join, while still using the // (library_id, artist_id) UNIQUE autoindex. func artistLibraryFilter(libraryIDs []int) Sqlizer { if len(libraryIDs) == 0 { return Eq{"1": 2} // match nothing, without a degenerate `IN ()` subquery } sub, args, _ := Select("1").From("library_artist"). Where(And{ Expr("library_artist.artist_id = artist.id"), Eq{"library_artist.library_id": libraryIDs}, }).Limit(1).ToSql() return Expr("EXISTS ("+sub+")", args...) } func (r *artistRepository) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.Artists, error) { var opts model.QueryOptions if len(options) > 0 { opts = options[0] } // Artists have no library_id column, so the library_id filter callers pass (same as albums/songs) // can't be applied directly: consume it and realize it as a join-free Phase-1 scope (searchCfg). scope := r.searchScope(ctx, opts.Filters) if isLibraryIDFilter(opts.Filters) { opts.Filters = nil } var res dbArtists err := r.doSearch(ctx, r.selectArtist(ctx, opts), q, &res, r.searchCfg(scope), opts) if err != nil { return nil, fmt.Errorf("searching artist %q: %w", q, err) } artists := res.toModels() r.hydrateArtwork(ctx, artists) return artists, nil } // searchScope returns the library IDs the search must be restricted to, or nil to skip the filter // entirely (the fast-path: the user sees everything the search could return, so a filter would be // pure O(offset) overhead). It intersects the requested libraries with what the user can see. func (r *artistRepository) searchScope(ctx context.Context, filter Sqlizer) []int { visible, err := r.visibleLibraryIDs(ctx) if err != nil { return r.requestedLibraryIDs(filter) // fail safe: narrow to the request rather than widen } requested := r.requestedLibraryIDs(filter) if requested == nil { // No explicit request: scope to the visible set, unless the user sees everything. if r.userSeesAllLibraries(ctx, visible) { return nil } return visible } // Narrow unless the request already covers everything the user can see. Compare by membership, // not length: the requested IDs may contain duplicates. requestedSet := slice.ToSet(requested) if slices.ContainsFunc(visible, func(id int) bool { _, ok := requestedSet[id]; return !ok }) { return requested } return nil } // requestedLibraryIDs extracts the []int from an Eq{"library_id": ids} filter, or nil if filter is // not that shape. func (r *artistRepository) requestedLibraryIDs(filter Sqlizer) []int { eq, ok := filter.(Eq) if !ok { return nil } ids, _ := eq["library_id"].([]int) return ids } // isLibraryIDFilter reports whether the filter is an Eq carrying a library_id key, so Search can // consume it before it reaches the bare artist table (which has no library_id column). func isLibraryIDFilter(filter Sqlizer) bool { eq, ok := filter.(Eq) if !ok { return false } _, ok = eq["library_id"] return ok } func (r *artistRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) } func (r *artistRepository) Read(ctx context.Context, id string) (*model.Artist, error) { return r.Get(ctx, id) } // sortMappingsForRole copies the shared mappings so a role-specific sort never leaks into them. // Anything sanitizeArtistStatsRole rejects falls back to the "total" aggregate. func (r *artistRepository) sortMappingsForRole(options ...rest.QueryOptions) map[string]string { role := "total" if len(options) > 0 { if v, ok := options[0].Filters["role"].(string); ok { if safe, ok := sanitizeArtistStatsRole(v); ok { role = safe } } } mappings := maps.Clone(r.sortMappings) mappings["song_count"] = "sum(stats->>'" + role + "'->>'m')" mappings["album_count"] = "sum(stats->>'" + role + "'->>'a')" mappings["size"] = "sum(stats->>'" + role + "'->>'s')" return mappings } func (r *artistRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Artist, error) { scoped := *r scoped.sortMappings = r.sortMappingsForRole(options...) return scoped.GetAll(ctx, scoped.parseRestOptions(ctx, options...)) } var _ model.ArtistRepository = (*artistRepository)(nil) var _ rest.Repository[model.Artist] = (*artistRepository)(nil)