diff --git a/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql b/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql new file mode 100644 index 000000000..1cd67dc32 --- /dev/null +++ b/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql @@ -0,0 +1,65 @@ +-- +goose Up +-- +goose StatementBegin +WITH artist_role_counters AS ( + SELECT jt.atom AS artist_id, + substr( + replace(jt.path, '$.', ''), + 1, + CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0 + THEN instr(replace(jt.path, '$.', ''), '[') - 1 + ELSE length(replace(jt.path, '$.', '')) + END + ) AS role, + count(DISTINCT mf.album_id) AS album_count, + count(mf.id) AS count, + sum(mf.size) AS size + FROM media_file mf + JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL + GROUP BY jt.atom, role +), +artist_total_counters AS ( + SELECT mfa.artist_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 + GROUP BY mfa.artist_id +), +artist_participant_counter AS ( + SELECT mfa.artist_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 + AND mfa.role IN ('albumartist', 'artist') + GROUP BY mfa.artist_id +), +combined_counters AS ( + SELECT artist_id, role, album_count, count, size FROM artist_role_counters + UNION + SELECT artist_id, role, album_count, count, size FROM artist_total_counters + UNION + SELECT artist_id, role, album_count, count, size FROM artist_participant_counter +), +artist_counters AS ( + SELECT artist_id AS id, + json_group_object( + replace(role, '"', ''), + json_object('a', album_count, 'm', count, 's', size) + ) AS counters + FROM combined_counters + GROUP BY artist_id +) +UPDATE artist +SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'), + updated_at = datetime(current_timestamp, 'localtime') +WHERE artist.id <> ''; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- +goose StatementEnd diff --git a/model/participants.go b/model/participants.go index 5f07bf42c..afbda10de 100644 --- a/model/participants.go +++ b/model/participants.go @@ -25,6 +25,8 @@ var ( RoleRemixer = Role{"remixer"} RoleDJMixer = Role{"djmixer"} RolePerformer = Role{"performer"} + // RoleMainCredit is a credit where the artist is an album artist or artist + RoleMainCredit = Role{"maincredit"} ) var AllRoles = map[string]Role{ @@ -41,6 +43,7 @@ var AllRoles = map[string]Role{ RoleRemixer.role: RoleRemixer, RoleDJMixer.role: RoleDJMixer, RolePerformer.role: RolePerformer, + RoleMainCredit.role: RoleMainCredit, } // Role represents the role of an artist in a track or album. diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 81dc2606c..977f0cb8b 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -124,6 +124,11 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi "song_count": "stats->>'total'->>'m'", "album_count": "stats->>'total'->>'a'", "size": "stats->>'total'->>'s'", + + // Stats by credits that are currently available + "maincredit_song_count": "stats->>'maincredit'->>'m'", + "maincredit_album_count": "stats->>'maincredit'->>'a'", + "maincredit_size": "stats->>'maincredit'->>'a'", }) return r } @@ -348,13 +353,27 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { 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 (TOTAL_IDS_PLACEHOLDER) -- Will replace with actual placeholders + WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders + GROUP BY mfa.artist_id + ), + artist_participant_counter AS ( + SELECT mfa.artist_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 ), combined_counters AS ( SELECT artist_id, role, album_count, count, size FROM artist_role_counters UNION SELECT artist_id, role, album_count, count, size FROM artist_total_counters + UNION + SELECT artist_id, role, album_count, count, size FROM artist_participant_counter ), artist_counters AS ( SELECT artist_id AS id, @@ -368,7 +387,7 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { UPDATE artist SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'), updated_at = datetime(current_timestamp, 'localtime') - WHERE artist.id IN (UPDATE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders + WHERE artist.id IN (ROLE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders var totalRowsAffected int64 = 0 const batchSize = 1000 @@ -387,21 +406,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { inClause := strings.Join(placeholders, ",") // Replace the placeholder markers with actual SQL placeholders - batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 1) - batchSQL = strings.Replace(batchSQL, "TOTAL_IDS_PLACEHOLDER", inClause, 1) - batchSQL = strings.Replace(batchSQL, "UPDATE_IDS_PLACEHOLDER", inClause, 1) + batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 4) - // Create a single parameter array with all IDs (repeated 3 times for each IN clause) - // We need to repeat each ID 3 times (once for each IN clause) - var args []interface{} - for _, id := range artistIDBatch { - args = append(args, id) // For ROLE_IDS_PLACEHOLDER - } - for _, id := range artistIDBatch { - args = append(args, id) // For TOTAL_IDS_PLACEHOLDER - } - for _, id := range artistIDBatch { - args = append(args, id) // For UPDATE_IDS_PLACEHOLDER + // 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 diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 600b87db6..db4e6ded1 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -397,7 +397,7 @@ func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artis if artist.PlayCount > 0 { dir.Played = artist.PlayDate } - dir.AlbumCount = int32(artist.AlbumCount) + dir.AlbumCount = getArtistAlbumCount(artist) dir.UserRating = int32(artist.Rating) if artist.Starred { dir.Starred = artist.StarredAt diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 39f324654..58834587d 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -77,18 +77,16 @@ func sortName(sortName, orderName string) string { return orderName } -func getArtistAlbumCount(a model.Artist) int32 { - albumStats := a.Stats[model.RoleAlbumArtist] - +func getArtistAlbumCount(a *model.Artist) int32 { // If ArtistParticipations are set, then `getArtist` will return albums - // where the artist is an album artist OR artist. While it may be an underestimate, - // guess the count by taking a max of the album artist and artist count. This is - // guaranteed to be <= the actual count. + // where the artist is an album artist OR artist. Use the custom stat + // main credit for this calculation. // Otherwise, return just the roles as album artist (precise) if conf.Server.Subsonic.ArtistParticipations { - artistStats := a.Stats[model.RoleArtist] - return int32(max(artistStats.AlbumCount, albumStats.AlbumCount)) + mainCreditStats := a.Stats[model.RoleMainCredit] + return int32(mainCreditStats.AlbumCount) } else { + albumStats := a.Stats[model.RoleAlbumArtist] return int32(albumStats.AlbumCount) } } @@ -111,7 +109,7 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { artist := responses.ArtistID3{ Id: a.ID, Name: a.Name, - AlbumCount: getArtistAlbumCount(a), + AlbumCount: getArtistAlbumCount(&a), CoverArt: a.CoverArtID().String(), ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600), UserRating: int32(a.Rating), diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index d703607ba..a4978237b 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -145,7 +145,7 @@ var _ = Describe("helpers", func() { model.RoleAlbumArtist: { AlbumCount: 3, }, - model.RoleArtist: { + model.RoleMainCredit: { AlbumCount: 4, }, }, @@ -153,13 +153,13 @@ var _ = Describe("helpers", func() { It("Handles album count without artist participations", func() { conf.Server.Subsonic.ArtistParticipations = false - result := getArtistAlbumCount(artist) + result := getArtistAlbumCount(&artist) Expect(result).To(Equal(int32(3))) }) It("Handles album count without with participations", func() { conf.Server.Subsonic.ArtistParticipations = true - result := getArtistAlbumCount(artist) + result := getArtistAlbumCount(&artist) Expect(result).To(Equal(int32(4))) }) }) diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index db8ed4566..c6dc832c1 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -96,10 +96,10 @@ const ArtistShowLayout = (props) => { let perPage = 0 let pagination = null - const count = Math.max( - record?.stats?.['albumartist']?.albumCount || 0, - record?.stats?.['artist']?.albumCount ?? 0, - ) + // Use the main credit count instead of total count, as this is a precise measure + // of the number of albums where the artist is credited as an album artist OR + // artist + const count = record?.stats?.['maincredit']?.albumCount || 0 if (count > maxPerPage) { perPage = Math.trunc(maxPerPage / perPageOptions[0]) * perPageOptions[0] diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 8f90e6bdf..7bd124ec6 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -124,7 +124,8 @@ "mixer": "Mixer |||| Mixers", "remixer": "Remixer |||| Remixers", "djmixer": "DJ Mixer |||| DJ Mixers", - "performer": "Performer |||| Performers" + "performer": "Performer |||| Performers", + "maincredit": "Album Artist or Artist |||| Album Artists or Artists" }, "actions": { "topSongs": "Top Songs",