feat(server): expose main credit stat to reflect only album artist | artist credit (#4268)
* attempt using artist | albumartist * add primary stats, expose to ND and Subsonic * response to feedback (1) * address feedback part 1 * fix docs and artist show * fix migration order --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
@@ -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
|
||||||
@@ -25,6 +25,8 @@ var (
|
|||||||
RoleRemixer = Role{"remixer"}
|
RoleRemixer = Role{"remixer"}
|
||||||
RoleDJMixer = Role{"djmixer"}
|
RoleDJMixer = Role{"djmixer"}
|
||||||
RolePerformer = Role{"performer"}
|
RolePerformer = Role{"performer"}
|
||||||
|
// RoleMainCredit is a credit where the artist is an album artist or artist
|
||||||
|
RoleMainCredit = Role{"maincredit"}
|
||||||
)
|
)
|
||||||
|
|
||||||
var AllRoles = map[string]Role{
|
var AllRoles = map[string]Role{
|
||||||
@@ -41,6 +43,7 @@ var AllRoles = map[string]Role{
|
|||||||
RoleRemixer.role: RoleRemixer,
|
RoleRemixer.role: RoleRemixer,
|
||||||
RoleDJMixer.role: RoleDJMixer,
|
RoleDJMixer.role: RoleDJMixer,
|
||||||
RolePerformer.role: RolePerformer,
|
RolePerformer.role: RolePerformer,
|
||||||
|
RoleMainCredit.role: RoleMainCredit,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role represents the role of an artist in a track or album.
|
// Role represents the role of an artist in a track or album.
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
|||||||
"song_count": "stats->>'total'->>'m'",
|
"song_count": "stats->>'total'->>'m'",
|
||||||
"album_count": "stats->>'total'->>'a'",
|
"album_count": "stats->>'total'->>'a'",
|
||||||
"size": "stats->>'total'->>'s'",
|
"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
|
return r
|
||||||
}
|
}
|
||||||
@@ -348,13 +353,27 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
|
|||||||
sum(mf.size) AS size
|
sum(mf.size) AS size
|
||||||
FROM media_file_artists mfa
|
FROM media_file_artists mfa
|
||||||
JOIN media_file mf ON mfa.media_file_id = mf.id
|
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
|
GROUP BY mfa.artist_id
|
||||||
),
|
),
|
||||||
combined_counters AS (
|
combined_counters AS (
|
||||||
SELECT artist_id, role, album_count, count, size FROM artist_role_counters
|
SELECT artist_id, role, album_count, count, size FROM artist_role_counters
|
||||||
UNION
|
UNION
|
||||||
SELECT artist_id, role, album_count, count, size FROM artist_total_counters
|
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 (
|
artist_counters AS (
|
||||||
SELECT artist_id AS id,
|
SELECT artist_id AS id,
|
||||||
@@ -368,7 +387,7 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
|
|||||||
UPDATE artist
|
UPDATE artist
|
||||||
SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'),
|
SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'),
|
||||||
updated_at = datetime(current_timestamp, 'localtime')
|
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
|
var totalRowsAffected int64 = 0
|
||||||
const batchSize = 1000
|
const batchSize = 1000
|
||||||
@@ -387,21 +406,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
|
|||||||
inClause := strings.Join(placeholders, ",")
|
inClause := strings.Join(placeholders, ",")
|
||||||
|
|
||||||
// Replace the placeholder markers with actual SQL placeholders
|
// Replace the placeholder markers with actual SQL placeholders
|
||||||
batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 1)
|
batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 4)
|
||||||
batchSQL = strings.Replace(batchSQL, "TOTAL_IDS_PLACEHOLDER", inClause, 1)
|
|
||||||
batchSQL = strings.Replace(batchSQL, "UPDATE_IDS_PLACEHOLDER", inClause, 1)
|
|
||||||
|
|
||||||
// Create a single parameter array with all IDs (repeated 3 times for each IN clause)
|
// Create a single parameter array with all IDs (repeated 4 times for each IN clause)
|
||||||
// We need to repeat each ID 3 times (once for each IN clause)
|
// We need to repeat each ID 4 times (once for each IN clause)
|
||||||
var args []interface{}
|
args := make([]any, 4*len(artistIDBatch))
|
||||||
for _, id := range artistIDBatch {
|
for idx, id := range artistIDBatch {
|
||||||
args = append(args, id) // For ROLE_IDS_PLACEHOLDER
|
for i := range 4 {
|
||||||
}
|
startIdx := i * len(artistIDBatch)
|
||||||
for _, id := range artistIDBatch {
|
args[startIdx+idx] = id
|
||||||
args = append(args, id) // For TOTAL_IDS_PLACEHOLDER
|
}
|
||||||
}
|
|
||||||
for _, id := range artistIDBatch {
|
|
||||||
args = append(args, id) // For UPDATE_IDS_PLACEHOLDER
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now use Expr with the expanded SQL and all parameters
|
// Now use Expr with the expanded SQL and all parameters
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artis
|
|||||||
if artist.PlayCount > 0 {
|
if artist.PlayCount > 0 {
|
||||||
dir.Played = artist.PlayDate
|
dir.Played = artist.PlayDate
|
||||||
}
|
}
|
||||||
dir.AlbumCount = int32(artist.AlbumCount)
|
dir.AlbumCount = getArtistAlbumCount(artist)
|
||||||
dir.UserRating = int32(artist.Rating)
|
dir.UserRating = int32(artist.Rating)
|
||||||
if artist.Starred {
|
if artist.Starred {
|
||||||
dir.Starred = artist.StarredAt
|
dir.Starred = artist.StarredAt
|
||||||
|
|||||||
@@ -77,18 +77,16 @@ func sortName(sortName, orderName string) string {
|
|||||||
return orderName
|
return orderName
|
||||||
}
|
}
|
||||||
|
|
||||||
func getArtistAlbumCount(a model.Artist) int32 {
|
func getArtistAlbumCount(a *model.Artist) int32 {
|
||||||
albumStats := a.Stats[model.RoleAlbumArtist]
|
|
||||||
|
|
||||||
// If ArtistParticipations are set, then `getArtist` will return albums
|
// If ArtistParticipations are set, then `getArtist` will return albums
|
||||||
// where the artist is an album artist OR artist. While it may be an underestimate,
|
// where the artist is an album artist OR artist. Use the custom stat
|
||||||
// guess the count by taking a max of the album artist and artist count. This is
|
// main credit for this calculation.
|
||||||
// guaranteed to be <= the actual count.
|
|
||||||
// Otherwise, return just the roles as album artist (precise)
|
// Otherwise, return just the roles as album artist (precise)
|
||||||
if conf.Server.Subsonic.ArtistParticipations {
|
if conf.Server.Subsonic.ArtistParticipations {
|
||||||
artistStats := a.Stats[model.RoleArtist]
|
mainCreditStats := a.Stats[model.RoleMainCredit]
|
||||||
return int32(max(artistStats.AlbumCount, albumStats.AlbumCount))
|
return int32(mainCreditStats.AlbumCount)
|
||||||
} else {
|
} else {
|
||||||
|
albumStats := a.Stats[model.RoleAlbumArtist]
|
||||||
return int32(albumStats.AlbumCount)
|
return int32(albumStats.AlbumCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +109,7 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 {
|
|||||||
artist := responses.ArtistID3{
|
artist := responses.ArtistID3{
|
||||||
Id: a.ID,
|
Id: a.ID,
|
||||||
Name: a.Name,
|
Name: a.Name,
|
||||||
AlbumCount: getArtistAlbumCount(a),
|
AlbumCount: getArtistAlbumCount(&a),
|
||||||
CoverArt: a.CoverArtID().String(),
|
CoverArt: a.CoverArtID().String(),
|
||||||
ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600),
|
ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600),
|
||||||
UserRating: int32(a.Rating),
|
UserRating: int32(a.Rating),
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ var _ = Describe("helpers", func() {
|
|||||||
model.RoleAlbumArtist: {
|
model.RoleAlbumArtist: {
|
||||||
AlbumCount: 3,
|
AlbumCount: 3,
|
||||||
},
|
},
|
||||||
model.RoleArtist: {
|
model.RoleMainCredit: {
|
||||||
AlbumCount: 4,
|
AlbumCount: 4,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -153,13 +153,13 @@ var _ = Describe("helpers", func() {
|
|||||||
|
|
||||||
It("Handles album count without artist participations", func() {
|
It("Handles album count without artist participations", func() {
|
||||||
conf.Server.Subsonic.ArtistParticipations = false
|
conf.Server.Subsonic.ArtistParticipations = false
|
||||||
result := getArtistAlbumCount(artist)
|
result := getArtistAlbumCount(&artist)
|
||||||
Expect(result).To(Equal(int32(3)))
|
Expect(result).To(Equal(int32(3)))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("Handles album count without with participations", func() {
|
It("Handles album count without with participations", func() {
|
||||||
conf.Server.Subsonic.ArtistParticipations = true
|
conf.Server.Subsonic.ArtistParticipations = true
|
||||||
result := getArtistAlbumCount(artist)
|
result := getArtistAlbumCount(&artist)
|
||||||
Expect(result).To(Equal(int32(4)))
|
Expect(result).To(Equal(int32(4)))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ const ArtistShowLayout = (props) => {
|
|||||||
let perPage = 0
|
let perPage = 0
|
||||||
let pagination = null
|
let pagination = null
|
||||||
|
|
||||||
const count = Math.max(
|
// Use the main credit count instead of total count, as this is a precise measure
|
||||||
record?.stats?.['albumartist']?.albumCount || 0,
|
// of the number of albums where the artist is credited as an album artist OR
|
||||||
record?.stats?.['artist']?.albumCount ?? 0,
|
// artist
|
||||||
)
|
const count = record?.stats?.['maincredit']?.albumCount || 0
|
||||||
|
|
||||||
if (count > maxPerPage) {
|
if (count > maxPerPage) {
|
||||||
perPage = Math.trunc(maxPerPage / perPageOptions[0]) * perPageOptions[0]
|
perPage = Math.trunc(maxPerPage / perPageOptions[0]) * perPageOptions[0]
|
||||||
|
|||||||
+2
-1
@@ -124,7 +124,8 @@
|
|||||||
"mixer": "Mixer |||| Mixers",
|
"mixer": "Mixer |||| Mixers",
|
||||||
"remixer": "Remixer |||| Remixers",
|
"remixer": "Remixer |||| Remixers",
|
||||||
"djmixer": "DJ Mixer |||| DJ Mixers",
|
"djmixer": "DJ Mixer |||| DJ Mixers",
|
||||||
"performer": "Performer |||| Performers"
|
"performer": "Performer |||| Performers",
|
||||||
|
"maincredit": "Album Artist or Artist |||| Album Artists or Artists"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"topSongs": "Top Songs",
|
"topSongs": "Top Songs",
|
||||||
|
|||||||
Reference in New Issue
Block a user