quintodrome/core/artwork/reader_disc.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

259 lines
7.3 KiB
Go
Raw Normal View History

feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
package artwork
import (
"context"
"crypto/md5"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type discArtworkReader struct {
cacheKey
a *artwork
album model.Album
discNumber int
imgFiles []string
discFolders map[string]bool
isMultiFolder bool
firstTrackPath string
updatedAt *time.Time
}
func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*discArtworkReader, error) {
albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID)
if err != nil {
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
}
al, err := a.ds.Album(ctx).Get(albumID)
if err != nil {
return nil, err
}
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, a.ds, *al)
if err != nil {
return nil, err
}
// Query mediafiles for this album + disc to find folder associations and first track
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Sort: "track_number",
Order: "ASC",
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
})
if err != nil {
return nil, err
}
// Build disc folder set and find first track
discFolders := make(map[string]bool)
var firstTrackPath string
allFolderIDs := make(map[string]bool)
for _, mf := range mfs {
allFolderIDs[mf.FolderID] = true
if firstTrackPath == "" {
firstTrackPath = mf.Path
}
}
// Resolve folder IDs to absolute paths
if len(allFolderIDs) > 0 {
folderIDs := make([]string, 0, len(allFolderIDs))
for id := range allFolderIDs {
folderIDs = append(folderIDs, id)
}
folders, err := a.ds.Folder(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"folder.id": folderIDs},
})
if err != nil {
return nil, err
}
for _, f := range folders {
discFolders[f.AbsolutePath()] = true
}
}
isMultiFolder := len(al.FolderIDs) > 1
r := &discArtworkReader{
a: a,
album: *al,
discNumber: discNumber,
imgFiles: imgFiles,
discFolders: discFolders,
isMultiFolder: isMultiFolder,
firstTrackPath: core.AbsolutePath(ctx, a.ds, al.LibraryID, firstTrackPath),
updatedAt: imagesUpdatedAt,
}
r.cacheKey.artID = artID
if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) {
r.cacheKey.lastUpdate = *r.updatedAt
} else {
r.cacheKey.lastUpdate = al.UpdatedAt
}
return r, nil
}
func (d *discArtworkReader) Key() string {
hash := md5.Sum([]byte(conf.Server.DiscArtPriority))
return fmt.Sprintf(
"%s.%x",
d.cacheKey.Key(),
hash,
)
}
func (d *discArtworkReader) LastUpdated() time.Time {
return d.album.UpdatedAt
}
func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
var ff = d.fromDiscArtPriority(ctx, d.a.ffmpeg, conf.Server.DiscArtPriority)
// Fallback to album cover art
albumArtID := model.NewArtworkID(model.KindAlbumArtwork, d.album.ID, &d.album.UpdatedAt)
ff = append(ff, fromAlbum(ctx, d.a, albumArtID))
return selectImageReader(ctx, d.cacheKey.artID, ff...)
}
func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
var ff []sourceFunc
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
ff = append(ff, fromTag(ctx, d.firstTrackPath), fromFFmpegTag(ctx, ffmpeg, d.firstTrackPath))
case pattern == "external":
// Not supported for disc art, silently ignore
case pattern == "discsubtitle":
if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" {
ff = append(ff, d.fromDiscSubtitle(ctx, subtitle))
}
case len(d.imgFiles) > 0:
ff = append(ff, d.fromExternalFile(ctx, pattern))
}
}
return ff
}
// fromDiscSubtitle returns a sourceFunc that matches image files whose stem
// (filename without extension) equals the disc subtitle (case-insensitive).
func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc {
return func() (io.ReadCloser, string, error) {
for _, file := range d.imgFiles {
_, name := filepath.Split(file)
stem := strings.TrimSuffix(name, filepath.Ext(name))
if !strings.EqualFold(stem, subtitle) {
continue
}
f, err := os.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
}
return nil, "", fmt.Errorf("disc %d: no image file matching subtitle %q", d.discNumber, subtitle)
}
}
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
// globMetaChars holds the substitution metacharacters understood by
// filepath.Match. The '\' escape character is intentionally excluded:
// disc art patterns come from user config and never include escaped
// metachars in practice, and treating '\' as a metachar would misalign
// the literal-prefix extraction in extractDiscNumber.
const globMetaChars = "*?["
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
// extractDiscNumber parses the disc number from a filename matched by a
// filepath.Match-style glob pattern.
//
// Both pattern and filename must already be lowercased by the caller, which
// is also expected to have verified that filepath.Match(pattern, filename)
// is true before calling this function.
func extractDiscNumber(pattern, filename string) (int, bool) {
metaIdx := strings.IndexAny(pattern, globMetaChars)
if metaIdx < 0 {
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
return 0, false
}
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
prefix := pattern[:metaIdx]
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
if !strings.HasPrefix(filename, prefix) {
return 0, false
}
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
start := len(prefix)
end := start
for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' {
end++
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
}
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
if end == start {
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
return 0, false
}
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
num, err := strconv.Atoi(filename[start:end])
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
if err != nil {
return 0, false
}
return num, true
}
// fromExternalFile returns a sourceFunc that matches image files against a glob
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
// pattern. A numbered filename whose number equals the target disc wins over
// any unnumbered candidate; callers must pass a lowercase pattern.
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
isLiteral := !strings.ContainsAny(pattern, globMetaChars)
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
return func() (io.ReadCloser, string, error) {
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
var fallbacks []string
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
for _, file := range d.imgFiles {
_, name := filepath.Split(file)
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
name = strings.ToLower(name)
match, err := filepath.Match(pattern, name)
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
if err != nil {
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
continue
}
if !match {
continue
}
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
if !isLiteral {
if num, hasNum := extractDiscNumber(pattern, name); hasNum {
if num != d.discNumber {
continue
}
f, err := os.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
}
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
}
if d.isMultiFolder && !d.discFolders[filepath.Dir(file)] {
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
continue
}
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
fallbacks = append(fallbacks, file)
}
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
fix(artwork): allow shared disc art from unnumbered filenames in single-folder albums (#5344) * test(artwork): expect shared disc art for unnumbered filenames in single-folder albums * fix(artwork): match unnumbered disc art for every disc in single-folder albums * test(artwork): verify shared disc art resolves for every disc number * test(artwork): regression guard for numbered disc filter with mixed filenames * test(artwork): verify DiscArtPriority order decides numbered vs shared disc art * test(artwork): strengthen regression guard to exercise both disc art branches * refactor(artwork): simplify disc art matching and drop redundant comments - Lowercase the pattern and filename once in fromExternalFile and pass lowered values into extractDiscNumber, eliminating the duplicate strings.ToLower calls inside that helper. - Drop narrating comments in reader_disc.go and reader_disc_test.go that duplicated information already conveyed by nearby code or doc comments. * fix(artwork): prefer numbered disc art over shared fallback within a pattern Review feedback: with files [disc.jpg, disc1.jpg, disc2.jpg] in a single folder, the previous single-folder fall-through returned the first match in imgFiles order. Because compareImageFiles sorts 'disc' before 'disc1' and 'disc2', disc.jpg would mask the per-disc numbered files for every disc, regressing the behavior from before the shared-disc-art change. Within a single pattern the loop now records the first viable unnumbered candidate as a fallback and keeps scanning for a numbered match equal to the target disc. Numbered matches still win immediately; the shared file is only returned when no numbered match for the target disc exists. Also drops the redundant strings.ToLower(pattern) at the top of fromExternalFile; fromDiscArtPriority already lowercases the whole priority string before splitting, so the function contract is now 'pattern must be lowercase' (documented on the function). * refactor(artwork): trim disc art matching comments and table-drive tests Doc comment on fromExternalFile is trimmed to the one non-obvious contract (caller must pre-lowercase the pattern) plus the headline behavior; the bulleted restatement of the branch logic went away. Two inline comments that narrated what the code already shows are also gone. Hoisting a `hasWildcard := strings.ContainsRune(pattern, '*')` check out of the loop avoids per-iteration extractDiscNumber calls for literal patterns (e.g. `shellac.png`) and lets the loop break as soon as a viable fallback is found, since literal patterns can never be beaten by a numbered match. Wildcard patterns keep the original scan-to-end-for-numbered-match behavior. The two regression tests added in the previous commit were structurally identical apart from discNumber/expected, so they are collapsed into a DescribeTable with two entries — matching the existing table style used for extractDiscNumber tests in the same file. * fix(artwork): support '?' and '[...]' wildcards in disc art patterns filepath.Match understands three glob metacharacters ('*', '?', '[') but extractDiscNumber only looked for '*'. A pattern like 'disc?.jpg' or 'cd[12].jpg' would therefore be treated as unnumbered, and every disc of a multi-disc album would resolve to the same (first-sorted) file instead of the per-disc numbered art. extractDiscNumber now finds the literal prefix of the pattern by scanning for the first '*', '?', or '[' (via strings.IndexAny), strips it from the filename, and parses the leading digits that follow. The standalone filepath.Match check is dropped; HasPrefix plus the leading-digits requirement is enough to reject non-matches, and the caller already verifies the glob match before calling. fromExternalFile's literal-pattern optimization is widened correspondingly: a pattern is treated as literal only when it contains none of '*', '?', '['. Any wildcard form now keeps the scan-to-end behavior so a numbered match can beat a fallback. Adds table entries for both the extractDiscNumber parser and the fromExternalFile higher-level behavior, covering '?' and '[...]' patterns as well as a literal-pattern baseline. * refactor(artwork): tidy extractDiscNumber after glob-wildcard support - Name the '*?[' charset as globMetaChars, used by both extractDiscNumber and fromExternalFile so the two call sites can't drift. - Trim the extractDiscNumber doc comment: keep the non-obvious caller contract, drop the algorithm narration. - Replace the byte-slice digit accumulator with a direct filename slice fed to strconv.Atoi. - Rename the four new non-'*' wildcard Entry descriptions so they read like the existing extractDiscNumber table ('pattern, target → expected') instead of the ambiguous 'disc 1' shorthand. * fix(artwork): retry remaining fallbacks when the first one fails to open Review feedback: the previous shape remembered only the first unnumbered candidate and fell through to a generic error if os.Open failed on it, even though other matching unnumbered files in imgFiles could have succeeded. The pre-PR code was more resilient because it looped and continued on open failure. fromExternalFile now collects every viable unnumbered candidate into a slice during the scan, then tries them in order after the loop, mirroring the pre-PR retry-on-open-failure behavior. Numbered matches still return immediately on first success and skip the candidate list entirely — an open failure on a numbered match means no other file has that number anyway. Also: - globMetaChars doc comment now notes that '\' escape is intentionally excluded (filepath.Match supports it but treating it as a metachar here would misalign extractDiscNumber's literal-prefix extraction with no benefit for realistic config patterns). - The 'cover.jpg doesn't match disc*.*' Entry in the extractDiscNumber table is renamed to 'cover.jpg with disc*.* (no prefix match)' to reflect that the test now exercises the HasPrefix defensive guard, not the removed internal filepath.Match check. Regression test added: a single-folder album with a deleted first candidate file resolves to the second candidate. * fix(artwork): scan all literal-pattern matches so fallback retry works Review feedback: the 'break on first literal match' optimization assumed only one file in imgFiles could match a literal basename, but filepath.Match compares basenames only — multiple folders can contribute files with the same basename, and the fallback-list retry in 5d79f751c is defeated if the loop breaks after recording just the first one. Removing the break makes literal and wildcard patterns follow the same scan-to-end path, preserving the retry-on-open-failure resilience regained in 5d79f751c. The efficiency cost is negligible — imgFiles is 5-20 entries per album and this is a cache-miss path.
2026-04-11 17:19:57 -08:00
for _, file := range fallbacks {
feat(artwork): add per-disc cover art support (#5182) * feat(artwork): add KindDiscArtwork and ParseDiscArtworkID Add new disc artwork kind with 'dc' prefix for per-disc cover art support. The composite ID format is albumID:discNumber, parsed by the new ParseDiscArtworkID helper. * feat(conf): add DiscArtPriority configuration option Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover art is resolved, following the same pattern as CoverArtPriority and ArtistArtPriority. * feat(artwork): implement extractDiscNumber helper Extracts disc number from filenames based on glob patterns by parsing leading digits from the wildcard-matched portion. Used for matching disc-specific artwork files like disc1.jpg. * feat(artwork): implement fromDiscExternalFile source function Disc-aware variant of fromExternalFile that filters image files by disc number (extracted from filename) or folder association (for multi-folder albums). * feat(artwork): implement discArtworkReader Resolves disc artwork using DiscArtPriority config patterns. Supports glob patterns with disc number extraction, embedded images from first track, and falls back to album cover art. Handles both multi-folder and single-folder multi-disc albums. * feat(artwork): register disc artwork reader in dispatcher Add KindDiscArtwork case to getArtworkReader switch, routing disc artwork requests to the new discArtworkReader. * feat(subsonic): add CoverArt field to DiscTitle response Implements OpenSubsonic PR #220: optional cover art ID in DiscTitle responses for per-disc artwork support. * feat(subsonic): populate CoverArt in DiscTitle responses Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum) that clients can use with getCoverArt to retrieve per-disc artwork. * style: fix file permission in test to satisfy gosec * feat(ui): add disc cover art display and lightbox functionality Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify disc artwork code - Add DiscArtworkID constructor to encapsulate the "albumID:discNumber" format in one place - Convert fromDiscExternalFile to a method on discArtworkReader, reducing parameter count from 6 to 2 - Remove unused rootFolder field from discArtworkReader * style: fix prettier formatting in subsonic index * style(ui): move cursor style to makeStyles in SongDatagrid * feat(artwork): add discsubtitle option to DiscArtPriority Allow matching disc cover art by the disc's subtitle/name. When the "discsubtitle" keyword is in the priority list, image files whose stem matches the disc subtitle (case-insensitive) are used. This is useful for box sets with named discs (e.g., "The Blue Disc.jpg"). * feat(configuration): update discartpriority to include cover art options Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 14:33:18 -08:00
f, err := os.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
}
return nil, "", fmt.Errorf("disc %d: pattern '%s' not matched by files", d.discNumber, pattern)
}
}