mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* test(jellyfin): use canonical ids in dto fixtures
Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape.
* test(jellyfin): use canonical ids in handler fixtures
Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape. Playlist entry positions
stay decimal, matching the integer playlist_tracks.id column.
* test(jellyfin): use canonical ids in e2e fixtures
Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape.
* test(jellyfin): use canonical ids in audiomuse fixtures
audiomuse_test.go passes ids as bare function args (mf(id, ...), call(query, user)) rather than
via ID: struct-literal fields, so the original grep-built file list missed it. Same conversion as
the rest of the fixtures: fake labels through id.NewHash via testID.
* test(jellyfin): convert remaining nonexistent-id sentinels in e2e tests
Reviewer swept for enc("literal") sites the brief's dto.EncodeID grep missed. These "does not
exist" fixtures must stay well-formed GUIDs under the strict codec, or the test degrades from
"resolves to nothing" to "empty path segment".
* refactor(jellyfin): emit real 128-bit GUIDs as item ids
Navidrome ids are now a canonical 22-char base62 encoding of exactly 128 bits, so they map
losslessly onto Jellyfin GUIDs. Previously the API hex-encoded the id string itself, producing
44 hex chars where Jellyfin uses 32.
Integer library ids, the synthetic playlists folder, and playlist entry positions (a
playlist_tracks.id, an integer column) aren't 128-bit values, so they get a reserved GUID space
tagged by kind. DecodeID is now strict: malformed input returns an empty string instead of
passing through unchanged.
BREAKING: Jellyfin clients see entirely new item ids.
* fix(jellyfin): 404 malformed playlist ids instead of silently creating
updatePlaylist decoded a malformed playlistId to "", the same sentinel core/playlists.Create
uses to mean "make a new playlist" — the overload createPlaylist deliberately relies on. A
malformed id now 404s before reaching Create.
Also tightens id-codec test fixtures: several tests set chi params to a raw canonical id, which
now decodes to "" and only passed because the fakes ignore the id argument; and a batch of
not-found sentinels now use well-formed-but-nonexistent GUIDs so they exercise the intended path
instead of the malformed-id path. READMEs "lossless" claim softened to note the reserved space.
* refactor(jellyfin): drop the id truncation workaround
Finamp's saved-queue packing keeps the first 16 bytes of each item id. That was lossy only
because our ids were 44 hex chars; now they are 32, so the packing round-trips exactly and the
server-side prefix recovery is dead code.
Removes an indexed range scan per restored queue and the ambiguous-prefix path that could
resolve to the wrong item.
* fix(jellyfin): emit ServerId and PlaySessionId in Jellyfin's id format
Jellyfin serializes GUIDs without dashes; ServerId was emitting the dashed UUID form. A
ServerId persisted before this change is normalized on read rather than rewritten.
PlaySessionId was emitting a raw internal id instead of the encoded form.
BREAKING: the ServerId change makes clients treat the server as new, so users re-login once.
* fix(jellyfin): 404 on undecodable id filters instead of widening the query
DecodeID collapsed an absent param and an undecodable one into the empty string, and downstream
an empty id means no filter. A client sending a stale pre-upgrade id therefore had its filter
silently dropped: ParentId, ArtistIds and AlbumArtistIds each returned the whole library instead
of a scoped result. Every existing client hits this on first launch after the id format changes.
Scalar id params now distinguish the two cases and report not-found. List-valued params already
failed closed. EncodeID logs a diagnostic when a non-empty id is not canonical, which should not
happen post-migration and would otherwise ship an unaddressable item silently.
* test(jellyfin): drop comments that restate the spec names
* refactor(jellyfin): decode reserved GUIDs from bytes, not hex strings
DecodeID already had the 16 decoded bytes, then re-derived the kind tag and payload by slicing the
hex string and parsing it a second time. Reading them off the byte slice matches how the format is
specified and removes the duplicate parse.
Bounding the payload inside encodeReserved gives both encoders the 32-char guarantee, which only
EncodePlaylistEntryID enforced before.
Playlist entries now decode through DecodePlaylistEntryID, which rejects other kinds. The tag was
being encoded and then discarded, so a song id passed as an EntryId reached RemoveTracks as a
playlist_tracks position.
Drops the per-field log.Warn from EncodeID: it sat in a leaf codec without a ctx and would emit
once per item per request on exactly the bad-data population it was meant to surface.
* refactor(jellyfin): make DecodeID report whether the id was decodable
DecodeID returned the empty string for both an absent param and an undecodable one, and
downstream an empty id means no filter. That conflation is what let a stale id widen /Items to
the whole library; it had been patched at two call sites, leaving three different policies for an
undecodable id in one package and ~16 handlers correct only because a repo Get("") happens to fail.
Returning (string, bool) makes the ambiguity unrepresentable, and the compiler forces each of the
~22 sites to decide. URL params share one itemIDParam helper that 404s; id lists go through
DecodeIDs, which is all-or-nothing because dropping bad entries would empty a list and make its
len() > 0 filter gate vanish — the original bug by another route.
A well-formed but unknown id is still 200 with zero results; only malformed ids 404. Malformed
ids now also 404 on the image and similar/instant-mix routes, which previously answered with a
placeholder or an empty list.
430 lines
15 KiB
Go
430 lines
15 KiB
Go
// Package e2e provides end-to-end integration tests for the Navidrome Jellyfin API.
|
|
//
|
|
// These tests exercise the full HTTP request/response cycle through the Jellyfin API router,
|
|
// using a real SQLite database and real repository implementations while stubbing out external
|
|
// services (artwork, streaming, transcoding) with spy/noop implementations.
|
|
//
|
|
// The harness mirrors server/subsonic/e2e (the Subsonic suite): BeforeSuite creates a temporary SQLite
|
|
// database, seeds two users (admin + regular) and one library backed by a fake in-memory
|
|
// filesystem, runs the scanner, and snapshots the golden DB. Each top-level Describe restores
|
|
// that snapshot and builds a fresh jellyfin.Router.
|
|
//
|
|
// # Seeded library (see buildTestFS)
|
|
//
|
|
// Rock/The Beatles/Abbey Road/ 01 Something (1969), 02 Come Together (1969)
|
|
// Rock/The Beatles/Help!/ 01 Help! (1965)
|
|
// Rock/Led Zeppelin/IV/ 01 Stairway To Heaven (1971)
|
|
// Jazz/Miles Davis/Kind of Blue/01 So What (1959)
|
|
// Pop/Solo Artist/Singles/ 01 Standalone Track (2020), 02 Duet (artist "Featured Guest")
|
|
//
|
|
// Totals: 7 songs, 5 albums, 4 album artists (+ 1 performer-only "Featured Guest" = 5 artists),
|
|
// 3 genres (Rock=4, Jazz=1, Pop=2).
|
|
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/core"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/core/artwork"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/core/external"
|
|
"github.com/navidrome/navidrome/core/lyrics"
|
|
"github.com/navidrome/navidrome/core/matcher"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/core/scrobbler"
|
|
"github.com/navidrome/navidrome/core/sonic"
|
|
"github.com/navidrome/navidrome/core/storage/storagetest"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/db"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/id"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/persistence"
|
|
"github.com/navidrome/navidrome/server/events"
|
|
"github.com/navidrome/navidrome/server/jellyfin"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
"github.com/navidrome/navidrome/tests"
|
|
"github.com/navidrome/navidrome/tests/harness"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
func TestJellyfinE2E(t *testing.T) {
|
|
tests.Init(t, false)
|
|
defer db.Close(t.Context())
|
|
log.SetLevel(log.LevelFatal)
|
|
RegisterFailHandler(Fail)
|
|
RunSpecs(t, "Jellyfin API E2E Suite")
|
|
}
|
|
|
|
// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same
|
|
// id shape production uses.
|
|
func testID(label string) string { return id.NewHash("jellyfin-test", label) }
|
|
|
|
// Easy aliases for the storagetest package
|
|
type _t = map[string]any
|
|
|
|
var (
|
|
template = storagetest.Template
|
|
track = storagetest.Track
|
|
)
|
|
|
|
// Shared test state
|
|
var (
|
|
ctx context.Context
|
|
ds *tests.MockDataStore
|
|
router http.Handler
|
|
streamerSpy *harness.SpyStreamer
|
|
artworkSpy *spyArtwork
|
|
providerFake *fakeExternalProvider
|
|
sonicProviderFake *fakeSonicProvider
|
|
goldenDB *harness.DB
|
|
dataFolder string
|
|
|
|
adminUser = model.User{
|
|
ID: testID("admin-1"),
|
|
UserName: "admin",
|
|
Name: "Admin User",
|
|
IsAdmin: true,
|
|
}
|
|
|
|
regularUser = model.User{
|
|
ID: testID("regular-1"),
|
|
UserName: "regular",
|
|
Name: "Regular User",
|
|
IsAdmin: false,
|
|
}
|
|
)
|
|
|
|
// buildTestFS creates the seeded test filesystem (see package doc for totals).
|
|
func buildTestFS() storagetest.FakeFS {
|
|
abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"})
|
|
help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"})
|
|
ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"})
|
|
kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz", "label": "Columbia"})
|
|
singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"})
|
|
|
|
return harness.CreateFS(fstest.MapFS{
|
|
// Track numbers are deliberately reversed vs. alphabetical title order (Something=1,
|
|
// Come Together=2) so tests can tell track-order sorting apart from title sorting.
|
|
"Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")),
|
|
"Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")),
|
|
"Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")),
|
|
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{
|
|
"lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold",
|
|
"replaygain_track_gain": "-3.50 dB",
|
|
"replaygain_album_gain": "-4.25 dB",
|
|
})),
|
|
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
|
|
"Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")),
|
|
// "Featured Guest" is the track artist here (album artist stays "Solo Artist"), so it's a
|
|
// performer but not an album artist — lets tests tell /Artists from /Artists/AlbumArtists.
|
|
"Pop/Solo Artist/Singles/02 - Duet.mp3": singles(track(2, "Duet", _t{"artist": "Featured Guest"})),
|
|
})
|
|
}
|
|
|
|
// --- Request helpers ---
|
|
|
|
// jReq performs a full HTTP round-trip as the given user (token auth) and returns the recorder.
|
|
func jReq(user model.User, method, path, body string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
var reader io.Reader
|
|
if body != "" {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
r := httptest.NewRequest(method, path, reader)
|
|
token, err := auth.CreateToken(&user)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
r.Header.Set("X-Emby-Token", token)
|
|
r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`)
|
|
if body != "" {
|
|
r.Header.Set("Content-Type", "application/json")
|
|
}
|
|
router.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// rawReq performs a request with no authentication (for public routes).
|
|
func rawReq(method, path, body string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
var reader io.Reader
|
|
if body != "" {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
r := httptest.NewRequest(method, path, reader)
|
|
if body != "" {
|
|
r.Header.Set("Content-Type", "application/json")
|
|
}
|
|
router.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
func get(path string) *httptest.ResponseRecorder { return jReq(adminUser, "GET", path, "") }
|
|
func getAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "GET", path, "") }
|
|
func post(path, body string) *httptest.ResponseRecorder { return jReq(adminUser, "POST", path, body) }
|
|
func postAs(u model.User, path, body string) *httptest.ResponseRecorder {
|
|
return jReq(u, "POST", path, body)
|
|
}
|
|
func del(path string) *httptest.ResponseRecorder { return jReq(adminUser, "DELETE", path, "") }
|
|
func delAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "DELETE", path, "") }
|
|
|
|
// upload performs an authenticated POST with a custom Content-Type and raw body (image upload).
|
|
func upload(user model.User, path, contentType string, body []byte) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", path, bytes.NewReader(body))
|
|
token, err := auth.CreateToken(&user)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
r.Header.Set("X-Emby-Token", token)
|
|
r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`)
|
|
r.Header.Set("Content-Type", contentType)
|
|
router.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// parseInto asserts a 200 and unmarshals the JSON body into target.
|
|
func parseInto(w *httptest.ResponseRecorder, target any) {
|
|
Expect(w.Code).To(Equal(http.StatusOK), "body: %s", w.Body.String())
|
|
Expect(json.Unmarshal(w.Body.Bytes(), target)).To(Succeed())
|
|
}
|
|
|
|
// queryResult asserts a 200 and returns the parsed QueryResult.
|
|
func queryResult(w *httptest.ResponseRecorder) dto.QueryResult {
|
|
var q dto.QueryResult
|
|
parseInto(w, &q)
|
|
return q
|
|
}
|
|
|
|
// createPlaylist creates a playlist as admin (encodedIds are the Jellyfin-encoded item ids a
|
|
// client would send) and returns its decoded Navidrome id.
|
|
func createPlaylist(name string, encodedIds []string) string {
|
|
return createPlaylistAs(adminUser, name, encodedIds...)
|
|
}
|
|
|
|
// createPlaylistAs creates a playlist owned by the given user and returns its decoded id.
|
|
func createPlaylistAs(user model.User, name string, encodedIds ...string) string {
|
|
if encodedIds == nil {
|
|
encodedIds = []string{}
|
|
}
|
|
body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
var res map[string]string
|
|
parseInto(postAs(user, "/Playlists", string(body)), &res)
|
|
Expect(res["Id"]).ToNot(BeEmpty())
|
|
id, ok := dto.DecodeID(res["Id"])
|
|
Expect(ok).To(BeTrue())
|
|
return id
|
|
}
|
|
|
|
// --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) ---
|
|
|
|
func enc(id string) string { return dto.EncodeID(id) }
|
|
|
|
// The seeded library is tiny, so the id lookups fetch-all and match by name in Go rather than
|
|
// guessing repository filter column names.
|
|
|
|
func albumID(name string) string {
|
|
albums, err := ds.Album(ctx).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, a := range albums {
|
|
if a.Name == name {
|
|
return a.ID
|
|
}
|
|
}
|
|
Fail("album not found: " + name)
|
|
return ""
|
|
}
|
|
|
|
func songID(title string) string {
|
|
mfs, err := ds.MediaFile(ctx).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, mf := range mfs {
|
|
if mf.Title == title {
|
|
return mf.ID
|
|
}
|
|
}
|
|
Fail("song not found: " + title)
|
|
return ""
|
|
}
|
|
|
|
func artistID(name string) string {
|
|
artists, err := ds.Artist(ctx).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, a := range artists {
|
|
if a.Name == name {
|
|
return a.ID
|
|
}
|
|
}
|
|
Fail("artist not found: " + name)
|
|
return ""
|
|
}
|
|
|
|
func genreID(name string) string {
|
|
genres, err := ds.Genre(ctx).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, g := range genres {
|
|
if g.Name == name {
|
|
return g.ID
|
|
}
|
|
}
|
|
Fail("genre not found: " + name)
|
|
return ""
|
|
}
|
|
|
|
// --- Suite lifecycle ---
|
|
|
|
var _ = BeforeSuite(func() {
|
|
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
|
dataFolder = filepath.Join(GinkgoT().TempDir(), "data")
|
|
Expect(os.MkdirAll(dataFolder, 0o755)).To(Succeed())
|
|
|
|
conf.Server.MusicFolder = "fake:///music"
|
|
conf.Server.DataFolder = conf.NewDir(dataFolder)
|
|
conf.Server.DevExternalScanner = false
|
|
|
|
buildTestFS()
|
|
goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser)
|
|
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
|
})
|
|
|
|
var _ = AfterSuite(func() {
|
|
db.Close(ctx)
|
|
})
|
|
|
|
// setupTestDB restores the golden snapshot and builds a fresh jellyfin.Router. Call from
|
|
// BeforeEach in each test container.
|
|
func setupTestDB() {
|
|
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
|
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.MusicFolder = "fake:///music"
|
|
conf.Server.DataFolder = conf.NewDir(dataFolder)
|
|
conf.Server.DevExternalScanner = false
|
|
conf.Server.DevEnableMediaFileProbe = false
|
|
|
|
goldenDB.Restore()
|
|
|
|
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
|
auth.Init(ds)
|
|
|
|
streamerSpy = &harness.SpyStreamer{}
|
|
artworkSpy = &spyArtwork{}
|
|
providerFake = &fakeExternalProvider{}
|
|
sonicProviderFake = &fakeSonicProvider{}
|
|
sonicSvc := sonic.New(ds, &fakeSonicLoader{provider: sonicProviderFake}, matcher.New(ds))
|
|
decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{})
|
|
router = jellyfin.New(
|
|
ds,
|
|
artworkSpy,
|
|
streamerSpy,
|
|
decider,
|
|
core.NewPlayers(ds),
|
|
scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
|
|
playlists.NewPlaylists(ds, artwork.NewUploader(ds)),
|
|
providerFake,
|
|
sonicSvc,
|
|
lyrics.NewLyrics(ds, nil),
|
|
events.NoopBroker(),
|
|
)
|
|
}
|
|
|
|
// fakeExternalProvider is a configurable stand-in for external.Provider. Tests set the return
|
|
// values they need; unset fields yield empty similar lists. Only the methods the Jellyfin API uses
|
|
// are overridden — the embedded interface panics for anything else, flagging unexpected calls.
|
|
type fakeExternalProvider struct {
|
|
external.Provider
|
|
similarArtists model.Artists
|
|
similarSongs model.MediaFiles
|
|
}
|
|
|
|
func (f *fakeExternalProvider) UpdateArtistInfo(_ context.Context, id string, _ int, _ bool) (*model.Artist, error) {
|
|
return &model.Artist{ID: id, SimilarArtists: f.similarArtists}, nil
|
|
}
|
|
|
|
func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
|
|
return f.similarSongs, nil
|
|
}
|
|
|
|
// fakeSonicLoader always advertises a SonicSimilarity provider so the AudioMuse endpoints are
|
|
// active in e2e; the provider it hands back returns test-configured results.
|
|
type fakeSonicLoader struct{ provider sonic.Provider }
|
|
|
|
func (f *fakeSonicLoader) PluginNames(capability string) []string {
|
|
if capability == "SonicSimilarity" {
|
|
return []string{"fake"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeSonicLoader) LoadSonicSimilarity(string) (sonic.Provider, bool) {
|
|
return f.provider, true
|
|
}
|
|
|
|
// fakeSonicProvider is a configurable stand-in for a sonic-similarity plugin. Tests set the
|
|
// agents.Song results; the real matcher resolves them back to seeded library tracks.
|
|
type fakeSonicProvider struct {
|
|
similar []sonic.SimilarResult
|
|
path []sonic.SimilarResult
|
|
}
|
|
|
|
func (f *fakeSonicProvider) GetSonicSimilarTracks(context.Context, *model.MediaFile, int) ([]sonic.SimilarResult, error) {
|
|
return f.similar, nil
|
|
}
|
|
|
|
func (f *fakeSonicProvider) FindSonicPath(context.Context, *model.MediaFile, *model.MediaFile, int) ([]sonic.SimilarResult, error) {
|
|
return f.path, nil
|
|
}
|
|
|
|
// songAgent looks a seeded track up by title (titles are unique in the seed) and builds an
|
|
// agents.Song carrying its title+artist, so the matcher resolves it back to that MediaFile.
|
|
func songAgent(title string) agents.Song {
|
|
mfs, err := ds.MediaFile(ctx).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, mf := range mfs {
|
|
if mf.Title == title {
|
|
return agents.Song{Name: mf.Title, Artists: []agents.Artist{{Name: mf.Artist}}}
|
|
}
|
|
}
|
|
Fail("song not found: " + title)
|
|
return agents.Song{}
|
|
}
|
|
|
|
// --- Spy/noop dependencies (shared ones live in tests/harness) ---
|
|
|
|
// spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the
|
|
// resolved ArtworkID and that resolution runs under an elevated (admin) context.
|
|
type spyArtwork struct {
|
|
lastID string
|
|
lastCtx context.Context
|
|
data []byte
|
|
}
|
|
|
|
func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (*artwork.Image, error) {
|
|
s.lastID = id
|
|
s.lastCtx = c
|
|
d := s.data
|
|
if d == nil {
|
|
d = []byte("IMG")
|
|
}
|
|
return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(d))}, nil
|
|
}
|
|
|
|
var _ artwork.Artwork = &spyArtwork{}
|