navidrome/persistence/artist_repository.go
Deluan Quintão b293b96256
refactor(persistence): stateless repositories with per-call context (#6149)
* refactor(persistence): adopt generic deluan/rest repository API

Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.

* refactor(persistence): base repository helpers take a context

* refactor(persistence): LibraryRepository takes a context per call

* refactor(persistence): PropertyRepository takes a context per call

* refactor(persistence): UserPropsRepository takes a context per call

* refactor(persistence): TranscodingRepository takes a context per call

* refactor(persistence): ShareRepository takes a context per call

* refactor(persistence): PlayerRepository takes a context per call

* refactor(persistence): RadioRepository takes a context per call

* refactor(persistence): PlayQueueRepository takes a context per call

* refactor(persistence): Tag and Genre repositories take a context per call

* refactor(persistence): PluginRepository takes a context per call

* refactor(persistence): Scrobble repositories take a context per call

* refactor(persistence): FolderRepository takes a context per call

* refactor(persistence): Artwork repositories take a context per call

* refactor(persistence): UserRepository takes a context per call

* refactor(persistence): ArtistRepository takes a context per call

ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.

* test(persistence): assert artist role sort sanitization in ReadAll

* refactor(persistence): AlbumRepository takes a context per call

* test(persistence): pass the test context to album repository helpers

* refactor(persistence): MediaFileRepository takes a context per call

* refactor(persistence): Playlist repositories take a context per call

* refactor(persistence): build all repositories once per store

* refactor(core): REST repository wrappers are built once

* refactor(persistence): repositories are stateless

Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.

* chore(lint): skip containedctx in test files

* refactor: share simplifications from the stateless repositories sweep

Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.

* chore(core): assert REST wrappers implement Persistable

* chore: reformat imports

* perf(persistence): build repositories on first use

Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.

* fix(persistence): clean plugin references per deleted user

A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.

* fix(core): unload disabled plugins even when a user delete fails

A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.

* chore(deps): pin deluan/rest to v1.0.1

Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).

* test: use the spec context instead of context.Background()

Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.

* test: declare the spec context once per Describe

Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
2026-09-25 18:06:10 -04:00

754 lines
27 KiB
Go

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)