quintodrome/core/artwork/artwork.go

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

135 lines
4.2 KiB
Go
Raw Normal View History

package artwork
import (
"context"
2022-12-19 11:34:21 -09:00
"errors"
_ "image/gif"
"io"
2022-12-24 21:25:10 -09:00
"time"
"github.com/navidrome/navidrome/consts"
refactor: external_metadata -> external.Provider (#3903) * tests for TopSongs Signed-off-by: Deluan <deluan@navidrome.org> * convert to Ginkgo Signed-off-by: Deluan <deluan@navidrome.org> * consolidate tests Signed-off-by: Deluan <deluan@navidrome.org> * rename external metadata -wip Signed-off-by: Deluan <deluan@navidrome.org> * rename external metadata to extdata.Provider Signed-off-by: Deluan <deluan@navidrome.org> * refactor tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * refactor test helpers Signed-off-by: Deluan <deluan@navidrome.org> * remove reflection Signed-off-by: Deluan <deluan@navidrome.org> * use mock.Mock Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * fix Signed-off-by: Deluan <deluan@navidrome.org> * receive Agents interface in Provider constructor Signed-off-by: Deluan <deluan@navidrome.org> * use mock for Agents Signed-off-by: Deluan <deluan@navidrome.org> * tests for SimilarSongs Signed-off-by: Deluan <deluan@navidrome.org> * remove duplication Signed-off-by: Deluan <deluan@navidrome.org> * ArtistImage tests Signed-off-by: Deluan <deluan@navidrome.org> * AlbumImage tests Signed-off-by: Deluan <deluan@navidrome.org> * fix provider error handling Signed-off-by: Deluan <deluan@navidrome.org> * UpdateAlbumInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * UpdateAlbumInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * UpdateArtistInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * clean up Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * fix test descriptions Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor: rename extdata package to external Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-08 17:11:09 -08:00
"github.com/navidrome/navidrome/core/external"
2022-12-20 08:25:47 -09:00
"github.com/navidrome/navidrome/core/ffmpeg"
2022-12-19 11:34:21 -09:00
"github.com/navidrome/navidrome/log"
2020-01-23 15:44:08 -09:00
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
2022-12-20 07:27:40 -09:00
"github.com/navidrome/navidrome/utils/cache"
2020-08-21 07:33:23 -08:00
_ "golang.org/x/image/webp"
)
var ErrUnavailable = errors.New("artwork unavailable")
type Artwork interface {
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
}
refactor: external_metadata -> external.Provider (#3903) * tests for TopSongs Signed-off-by: Deluan <deluan@navidrome.org> * convert to Ginkgo Signed-off-by: Deluan <deluan@navidrome.org> * consolidate tests Signed-off-by: Deluan <deluan@navidrome.org> * rename external metadata -wip Signed-off-by: Deluan <deluan@navidrome.org> * rename external metadata to extdata.Provider Signed-off-by: Deluan <deluan@navidrome.org> * refactor tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * refactor test helpers Signed-off-by: Deluan <deluan@navidrome.org> * remove reflection Signed-off-by: Deluan <deluan@navidrome.org> * use mock.Mock Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * fix Signed-off-by: Deluan <deluan@navidrome.org> * receive Agents interface in Provider constructor Signed-off-by: Deluan <deluan@navidrome.org> * use mock for Agents Signed-off-by: Deluan <deluan@navidrome.org> * tests for SimilarSongs Signed-off-by: Deluan <deluan@navidrome.org> * remove duplication Signed-off-by: Deluan <deluan@navidrome.org> * ArtistImage tests Signed-off-by: Deluan <deluan@navidrome.org> * AlbumImage tests Signed-off-by: Deluan <deluan@navidrome.org> * fix provider error handling Signed-off-by: Deluan <deluan@navidrome.org> * UpdateAlbumInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * UpdateAlbumInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * UpdateArtistInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * clean up Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * fix test descriptions Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor: rename extdata package to external Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-08 17:11:09 -08:00
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
}
type artwork struct {
refactor: external_metadata -> external.Provider (#3903) * tests for TopSongs Signed-off-by: Deluan <deluan@navidrome.org> * convert to Ginkgo Signed-off-by: Deluan <deluan@navidrome.org> * consolidate tests Signed-off-by: Deluan <deluan@navidrome.org> * rename external metadata -wip Signed-off-by: Deluan <deluan@navidrome.org> * rename external metadata to extdata.Provider Signed-off-by: Deluan <deluan@navidrome.org> * refactor tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * refactor test helpers Signed-off-by: Deluan <deluan@navidrome.org> * remove reflection Signed-off-by: Deluan <deluan@navidrome.org> * use mock.Mock Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * fix Signed-off-by: Deluan <deluan@navidrome.org> * receive Agents interface in Provider constructor Signed-off-by: Deluan <deluan@navidrome.org> * use mock for Agents Signed-off-by: Deluan <deluan@navidrome.org> * tests for SimilarSongs Signed-off-by: Deluan <deluan@navidrome.org> * remove duplication Signed-off-by: Deluan <deluan@navidrome.org> * ArtistImage tests Signed-off-by: Deluan <deluan@navidrome.org> * AlbumImage tests Signed-off-by: Deluan <deluan@navidrome.org> * fix provider error handling Signed-off-by: Deluan <deluan@navidrome.org> * UpdateAlbumInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * UpdateAlbumInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * UpdateArtistInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * clean up Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * fix test descriptions Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor: rename extdata package to external Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-08 17:11:09 -08:00
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
provider external.Provider
2020-07-24 09:30:27 -08:00
}
2022-12-27 07:36:23 -09:00
type artworkReader interface {
cache.Item
LastUpdated() time.Time
Reader(ctx context.Context) (io.ReadCloser, string, error)
}
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artID, err := a.getArtworkId(ctx, id)
if err == nil {
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
2022-12-20 07:27:40 -09:00
}
if errors.Is(err, ErrUnavailable) {
if artID.Kind == model.KindArtistArtwork {
reader, _ = resources.FS().Open(consts.PlaceholderArtistArt)
} else {
reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt)
}
return reader, consts.ServerStart, nil
}
return reader, lastUpdate, err
}
2022-12-20 07:27:40 -09:00
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artReader, err := a.getArtworkReader(ctx, artID, size, square)
2022-12-19 13:07:29 -09:00
if err != nil {
2022-12-27 08:54:51 -09:00
return nil, time.Time{}, err
2022-12-19 11:34:21 -09:00
}
2022-12-27 07:36:23 -09:00
r, err := a.cache.Get(ctx, artReader)
2022-12-28 06:19:52 -09:00
if err != nil {
2023-02-02 06:36:27 -09:00
if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) {
log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err)
2022-12-28 06:19:52 -09:00
}
return nil, time.Time{}, err
2022-12-22 09:53:49 -09:00
}
2022-12-28 06:19:52 -09:00
return r, artReader.LastUpdated(), nil
2022-12-27 08:54:51 -09:00
}
type coverArtGetter interface {
CoverArtID() model.ArtworkID
}
func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) {
if id == "" {
return model.ArtworkID{}, ErrUnavailable
}
artID, err := model.ParseArtworkID(id)
if err == nil {
return artID, nil
}
log.Trace(ctx, "ArtworkID invalid. Trying to figure out kind based on the ID", "id", id)
entity, err := model.GetEntityByID(ctx, a.ds, id)
if err != nil {
return model.ArtworkID{}, err
}
if e, ok := entity.(coverArtGetter); ok {
artID = e.CoverArtID()
}
switch e := entity.(type) {
2022-12-30 19:52:53 -09:00
case *model.Artist:
log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name)
case *model.Album:
log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist)
case *model.MediaFile:
log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album)
case *model.Playlist:
log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name)
}
return artID, nil
}
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) {
2022-12-27 08:54:51 -09:00
var artReader artworkReader
var err error
if size > 0 || square {
artReader, err = resizedFromOriginal(ctx, a, artID, size, square)
2022-12-27 08:54:51 -09:00
} else {
switch artID.Kind {
2022-12-30 19:52:53 -09:00
case model.KindArtistArtwork:
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
2022-12-27 08:54:51 -09:00
case model.KindAlbumArtwork:
refactor: external_metadata -> external.Provider (#3903) * tests for TopSongs Signed-off-by: Deluan <deluan@navidrome.org> * convert to Ginkgo Signed-off-by: Deluan <deluan@navidrome.org> * consolidate tests Signed-off-by: Deluan <deluan@navidrome.org> * rename external metadata -wip Signed-off-by: Deluan <deluan@navidrome.org> * rename external metadata to extdata.Provider Signed-off-by: Deluan <deluan@navidrome.org> * refactor tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * refactor test helpers Signed-off-by: Deluan <deluan@navidrome.org> * remove reflection Signed-off-by: Deluan <deluan@navidrome.org> * use mock.Mock Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * fix Signed-off-by: Deluan <deluan@navidrome.org> * receive Agents interface in Provider constructor Signed-off-by: Deluan <deluan@navidrome.org> * use mock for Agents Signed-off-by: Deluan <deluan@navidrome.org> * tests for SimilarSongs Signed-off-by: Deluan <deluan@navidrome.org> * remove duplication Signed-off-by: Deluan <deluan@navidrome.org> * ArtistImage tests Signed-off-by: Deluan <deluan@navidrome.org> * AlbumImage tests Signed-off-by: Deluan <deluan@navidrome.org> * fix provider error handling Signed-off-by: Deluan <deluan@navidrome.org> * UpdateAlbumInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * UpdateAlbumInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * UpdateArtistInfo tests - wip Signed-off-by: Deluan <deluan@navidrome.org> * clean up Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * fix test descriptions Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor: rename extdata package to external Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-08 17:11:09 -08:00
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
2022-12-27 08:54:51 -09:00
case model.KindMediaFileArtwork:
artReader, err = newMediafileArtworkReader(ctx, a, artID)
case model.KindPlaylistArtwork:
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
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
case model.KindDiscArtwork:
artReader, err = newDiscArtworkReader(ctx, a, artID)
feat(ui): add cover art support for internet radio stations (#5229) * feat(artwork): add KindRadioArtwork and EntityRadio constant * feat(model): add UploadedImage field and artwork methods to Radio * feat(model): add Radio to GetEntityByID lookup chain * feat(db): add uploaded_image column to radio table * feat(artwork): add radio artwork reader with uploaded image fallback * feat(api): add radio image upload/delete endpoints * feat(ui): add radio artwork ID prefix to getCoverArtUrl * feat(ui): add cover art display and upload to RadioEdit * feat(ui): add cover art thumbnails to radio list * feat(ui): prefer artwork URL in radio player helper * refactor: remove redundant code in radio artwork - Remove duplicate Avatar rendering in RadioList by reusing CoverArtField - Remove redundant UpdatedAt assignment in radio image handlers (already set by repository Put) * refactor(ui): extract shared useImageLoadingState hook Move image loading/error/lightbox state management into a shared useImageLoadingState hook in common/. Consolidates duplicated logic from AlbumDetails, PlaylistDetails, RadioEdit, and artist detail views. * feat(ui): use radio placeholder icon when no uploaded image Remove album placeholder fallback from radio artwork reader so radios without an uploaded image return ErrUnavailable. On the frontend, show the internet-radio-icon.svg placeholder instead of requesting server artwork when no image is uploaded, allowing favicon fallback in the player. * refactor(ui): update defaultOff fields in useSelectedFields for RadioList Signed-off-by: Deluan <deluan@navidrome.org> * fix: address code review feedback - Add missing alt attribute to CardMedia in RadioEdit for accessibility - Fix UpdateInternetRadio to preserve UploadedImage field by fetching existing radio before updating (prevents Subsonic API from clearing custom artwork) - Add Reader() level tests to verify ErrUnavailable is returned when radio has no uploaded image * refactor: add colsToUpdate to RadioRepository.Put Use the base sqlRepository.put with column filtering instead of hand-rolled SQL. UpdateInternetRadio now specifies only the Subsonic API fields, preventing UploadedImage from being cleared. Image upload/delete handlers specify only UploadedImage. * fix: ensure UpdatedAt is included in colsToUpdate for radio Put --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-18 14:57:33 -08:00
case model.KindRadioArtwork:
artReader, err = newRadioArtworkReader(ctx, a, artID)
2022-12-27 08:54:51 -09:00
default:
return nil, ErrUnavailable
2022-12-27 08:54:51 -09:00
}
}
return artReader, err
2022-12-22 09:53:49 -09:00
}