refactor(persistence): stateless repositories with per-call context (#6149)

* refactor(persistence): adopt generic deluan/rest repository API

Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.

* refactor(persistence): base repository helpers take a context

* refactor(persistence): LibraryRepository takes a context per call

* refactor(persistence): PropertyRepository takes a context per call

* refactor(persistence): UserPropsRepository takes a context per call

* refactor(persistence): TranscodingRepository takes a context per call

* refactor(persistence): ShareRepository takes a context per call

* refactor(persistence): PlayerRepository takes a context per call

* refactor(persistence): RadioRepository takes a context per call

* refactor(persistence): PlayQueueRepository takes a context per call

* refactor(persistence): Tag and Genre repositories take a context per call

* refactor(persistence): PluginRepository takes a context per call

* refactor(persistence): Scrobble repositories take a context per call

* refactor(persistence): FolderRepository takes a context per call

* refactor(persistence): Artwork repositories take a context per call

* refactor(persistence): UserRepository takes a context per call

* refactor(persistence): ArtistRepository takes a context per call

ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.

* test(persistence): assert artist role sort sanitization in ReadAll

* refactor(persistence): AlbumRepository takes a context per call

* test(persistence): pass the test context to album repository helpers

* refactor(persistence): MediaFileRepository takes a context per call

* refactor(persistence): Playlist repositories take a context per call

* refactor(persistence): build all repositories once per store

* refactor(core): REST repository wrappers are built once

* refactor(persistence): repositories are stateless

Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.

* chore(lint): skip containedctx in test files

* refactor: share simplifications from the stateless repositories sweep

Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.

* chore(core): assert REST wrappers implement Persistable

* chore: reformat imports

* perf(persistence): build repositories on first use

Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.

* fix(persistence): clean plugin references per deleted user

A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.

* fix(core): unload disabled plugins even when a user delete fails

A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.

* chore(deps): pin deluan/rest to v1.0.1

Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).

* test: use the spec context instead of context.Background()

Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.

* test: declare the spec context once per Describe

Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
This commit is contained in:
Deluan Quintão 2026-09-25 18:06:10 -04:00 • committed by GitHub
commit b293b96256
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
339 changed files with 6219 additions and 6172 deletions

View file

@ -9,6 +9,7 @@ linters:
- asciicheck
- bidichk
- bodyclose
- containedctx
- copyloopvar
- dogsled
- durationcheck
@ -57,6 +58,9 @@ linters:
- gosec
path: _test\.go
text: "G703"
- path: _test\.go
linters:
- containedctx
- path-except: 'db/migrations/'
linters:
- forbidigo

View file

@ -357,7 +357,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
var track *model.MediaFile
BeforeEach(func() {
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)

View file

@ -50,7 +50,7 @@ var _ = Describe("auth_router", func() {
})
storedSessionKey := func(userID string) string {
key, _ := userProps.Get(userID, sessionKeyProperty)
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
return key
}

View file

@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = context.Background()
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
agent = listenBrainzConstructor(ds)
agent.client = newClient("http://localhost:8080", httpClient)

View file

@ -174,28 +174,28 @@ func queueTotal(stats []model.ArtworkQueueStat) int64 {
}
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
q := ds.ArtworkQueue(ctx)
q := ds.ArtworkQueue()
var rep statusReport
var err error
if rep.queue, err = q.CountQueued(nil, nil); err != nil {
if rep.queue, err = q.CountQueued(ctx, nil, nil); err != nil {
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
}
for _, k := range artwork.ReprocessKinds {
sources, err := q.SourcesInUse(k)
sources, err := q.SourcesInUse(ctx, k)
if err != nil {
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
}
slices.Sort(sources)
for _, s := range sources {
n, err := q.CountBySource(k, []string{s})
n, err := q.CountBySource(ctx, k, []string{s})
if err != nil {
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
}
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
// An absent state is exactly a row with no source, so it needs no second query.
if s == "" {
failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed})
failed, err := q.CountBySource(ctx, k, []string{model.ArtworkSourceFailed})
if err != nil {
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
}
@ -205,7 +205,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error
}
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil {
if rep.stored, err = ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, ""); err != nil {
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
}
return rep, nil
@ -442,13 +442,13 @@ func promptConfirm(in io.Reader, verb string) confirmFunc {
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
func validateSources(q model.ArtworkQueueRepository, sources []string) error {
func validateSources(ctx context.Context, q model.ArtworkQueueRepository, sources []string) error {
if len(sources) == 0 {
return nil
}
var inUse []string
for _, k := range artwork.ReprocessKinds {
found, err := q.SourcesInUse(k)
found, err := q.SourcesInUse(ctx, k)
if err != nil {
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
}
@ -475,8 +475,8 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error {
// actually inserted; the two differ because an already-queued row is left untouched.
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
q := ds.ArtworkQueue(ctx)
if err := validateSources(q, sources); err != nil {
q := ds.ArtworkQueue()
if err := validateSources(ctx, q, sources); err != nil {
return err
}
@ -495,7 +495,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
matched := make([]int64, len(kinds))
var total, external int64
for i, k := range kinds {
n, err := q.CountBySource(k, sources)
n, err := q.CountBySource(ctx, k, sources)
if err != nil {
return fmt.Errorf("counting %s artwork: %w", k, err)
}
@ -523,7 +523,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
if matched[i] == 0 {
continue
}
n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck)
n, err := q.EnqueueBySource(ctx, k, sources, model.ArtworkPriorityRecheck)
if err != nil {
return fmt.Errorf("queueing %s artwork: %w", k, err)
}
@ -590,8 +590,8 @@ func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
dryRun bool, confirm confirmFunc, out io.Writer) error {
q := ds.ArtworkQueue(ctx)
matched, err := q.CountQueued(kinds, priorities)
q := ds.ArtworkQueue()
matched, err := q.CountQueued(ctx, kinds, priorities)
if err != nil {
return fmt.Errorf("counting queued artwork: %w", err)
}
@ -612,7 +612,7 @@ func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind,
return nil
}
cancelled, err := q.PurgeQueued(kinds, priorities)
cancelled, err := q.PurgeQueued(ctx, kinds, priorities)
if err != nil {
return fmt.Errorf("cancelling queued artwork: %w", err)
}
@ -984,11 +984,11 @@ func runExplain(ctx context.Context, args []string) {
}
rep := explainReport{kind: kind, id: id, name: name}
if artwork.KeepsState(kind) {
rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
rep.stored, err = ds.Artwork().GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
}
rep.queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary)
rep.queued, err = ds.ArtworkQueue().Get(ctx, kind, id, model.ImageTypePrimary)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
}

View file

@ -491,17 +491,17 @@ var _ = Describe("explain/reprocess source round trip", func() {
It("names the absent state as reprocess --source accepts it", func() {
ds := &tests.MockDataStore{}
art := ds.Artwork(ctx).(*tests.MockArtworkRepo)
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
art := ds.Artwork().(*tests.MockArtworkRepo)
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
q := ds.ArtworkQueue(ctx)
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
q := ds.ArtworkQueue()
Expect(validateSources(ctx, q, repositorySources([]string{shown}))).To(Succeed(),
"explain's spelling of a source must be pasteable into --source")
Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
Expect(validateSources(ctx, q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
"a parenthesised name would be rejected, so explain must not print one")
})
})
@ -573,7 +573,7 @@ var _ = Describe("reprocessArtwork", func() {
decline := func(io.Writer, int64, int64) bool { return false }
put := func(kind model.Kind, id, source string) {
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
}
@ -584,8 +584,8 @@ var _ = Describe("reprocessArtwork", func() {
conf.Server.EnableM3UExternalAlbumArt = false
imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1}
ds = &tests.MockDataStore{}
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
art = ds.Artwork().(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
out.Reset()
put(model.KindArtistArtwork, "ar-1", "external:deezer")
put(model.KindArtistArtwork, "ar-2", "")
@ -601,19 +601,19 @@ var _ = Describe("reprocessArtwork", func() {
Expect(out.String()).To(ContainSubstring("album"))
Expect(out.String()).To(ContainSubstring("TOTAL"))
Expect(out.String()).To(ContainSubstring("Dry run"))
Expect(queue.Count()).To(BeZero())
Expect(queue.Count(ctx)).To(BeZero())
})
It("queues nothing when the operator declines", func() {
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Aborted"))
Expect(queue.Count()).To(BeZero())
Expect(queue.Count(ctx)).To(BeZero())
})
DescribeTable("records the applied config only for a run that leaves nothing on the old one",
func(selected []model.Kind, sources []string, dryRun, applied bool) {
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed())
@ -621,7 +621,7 @@ var _ = Describe("reprocessArtwork", func() {
if applied {
want = artwork.ConfigFingerprint()
}
Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
Expect(ds.Property().Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
},
Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true),
Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true),
@ -633,14 +633,14 @@ var _ = Describe("reprocessArtwork", func() {
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(Equal(int64(2)))
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(queue.Count(ctx)).To(Equal(int64(2)))
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
_, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
_, err = queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
stored, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
})
@ -648,20 +648,20 @@ var _ = Describe("reprocessArtwork", func() {
It("targets the absent state", func() {
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(Equal(int64(1)))
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
Expect(queue.Count(ctx)).To(Equal(int64(1)))
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
})
It("reports matched and queued separately when part of the set is already queued", func() {
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
queued, err := queue.Get(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
"an already-queued row keeps its priority and backoff")
@ -675,7 +675,7 @@ var _ = Describe("reprocessArtwork", func() {
}, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Nothing"))
Expect(queue.Count()).To(BeZero())
Expect(queue.Count(ctx)).To(BeZero())
})
It("reports an empty selection as a dry run when one was asked for", func() {
@ -767,7 +767,7 @@ var _ = Describe("reprocessArtwork", func() {
Expect(err.Error()).To(ContainSubstring("external:deezer"))
Expect(err.Error()).To(ContainSubstring("folder"))
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
Expect(queue.Count()).To(BeZero())
Expect(queue.Count(ctx)).To(BeZero())
})
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
@ -777,7 +777,7 @@ var _ = Describe("reprocessArtwork", func() {
imageAgents, false, accept, &out)).To(Succeed(),
"a reserved source must stay valid once the library has none of it")
Expect(out.String()).To(ContainSubstring("Nothing matches"))
Expect(queue.Count()).To(BeZero())
Expect(queue.Count(ctx)).To(BeZero())
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
@ -797,7 +797,7 @@ var _ = Describe("reprocessArtwork", func() {
Expect(out.String()).To(ContainSubstring("Nothing matches"),
"a well-formed filter must not be reported as a typo because of the kinds selected")
Expect(queue.Count()).To(BeZero())
Expect(queue.Count(ctx)).To(BeZero())
})
})
@ -816,19 +816,19 @@ var _ = Describe("collectStatus", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
art = ds.Artwork().(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
}
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour))
// ar-3 is absent because it gave up, so the two absent artists split across the columns.
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed())
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
})
@ -848,7 +848,7 @@ var _ = Describe("collectStatus", func() {
})
It("compares the stored fingerprint against the current one", func() {
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
rep, err := collectStatus(ctx, ds)
Expect(err).ToNot(HaveOccurred())
@ -860,7 +860,7 @@ var _ = Describe("collectStatus", func() {
It("queues nothing", func() {
_, err := collectStatus(ctx, ds)
Expect(err).ToNot(HaveOccurred())
Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything")
Expect(queue.Count(ctx)).To(Equal(int64(1)), "status must not enqueue anything")
})
})
@ -973,21 +973,21 @@ var _ = Describe("refreshItems", func() {
albums := tests.CreateMockAlbumRepo()
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
ds = &tests.MockDataStore{MockedAlbum: albums}
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
art = ds.Artwork().(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
out.Reset()
})
It("clears the stored state and queues each id at Bump priority", func() {
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
Expect(refreshItems(ctx, ds, []model.ArtworkID{
{Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero())
_, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
_, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
@ -996,7 +996,7 @@ var _ = Describe("refreshItems", func() {
It("skips an id that does not exist instead of queuing it", func() {
Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1))
_, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
_, err := queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
Expect(out.String()).To(BeEmpty())
})
@ -1145,9 +1145,9 @@ var _ = Describe("cancelArtwork", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
out.Reset()
Expect(queue.Enqueue(
Expect(queue.Enqueue(ctx,
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBackfill},
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
@ -1164,28 +1164,28 @@ var _ = Describe("cancelArtwork", func() {
Expect(out.String()).To(ContainSubstring("backfill"))
Expect(out.String()).To(ContainSubstring("TOTAL"))
Expect(out.String()).To(ContainSubstring("Dry run"))
Expect(queue.Count()).To(BeNumerically("==", 3))
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
})
It("cancels nothing when the operator declines", func() {
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Aborted"))
Expect(queue.Count()).To(BeNumerically("==", 3))
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
})
It("deletes the selected rows and leaves the rest queued", func() {
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(BeNumerically("==", 1))
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
Expect(queue.Count(ctx)).To(BeNumerically("==", 1))
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
})
It("cancels every kind and priority when neither filter is given", func() {
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(BeZero())
Expect(queue.Count(ctx)).To(BeZero())
})
It("stops at a selection that matches nothing instead of prompting", func() {
@ -1196,7 +1196,7 @@ var _ = Describe("cancelArtwork", func() {
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
Expect(queue.Count()).To(BeNumerically("==", 3))
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
})
It("reports a queue read failure instead of reporting nothing to cancel", func() {

View file

@ -72,7 +72,7 @@ func runMissingList(ctx context.Context) {
}
ds, ctx := getAdminContext(ctx)
mfs, err := ds.MediaFile(ctx).GetCursor(model.QueryOptions{
mfs, err := ds.MediaFile().GetCursor(ctx, model.QueryOptions{
Filters: squirrel.Eq{"missing": true},
Sort: "path",
})
@ -128,7 +128,7 @@ func runMissingFix(ctx context.Context, missingRef, targetRef string) {
// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path).
func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile {
mf, err := ds.MediaFile(ctx).Get(ref)
mf, err := ds.MediaFile().Get(ctx, ref)
if err == nil {
return mf
}
@ -136,7 +136,7 @@ func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *mode
log.Fatal(ctx, "Error looking up media file", "ref", ref, err)
}
mfs, err := ds.MediaFile(ctx).FindByPaths([]string{ref})
mfs, err := ds.MediaFile().FindByPaths(ctx, []string{ref})
if err != nil {
log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err)
}

View file

@ -109,7 +109,7 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
pls, err := ds.Playlist(ctx).GetAll(options)
pls, err := ds.Playlist().GetAll(ctx, options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
@ -117,17 +117,17 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
}
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
if errors.Is(err, model.ErrNotFound) {
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
if err != nil {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
if len(playlists) > 0 {
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false)
if err != nil {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
@ -194,7 +194,7 @@ func runExport(ctx context.Context) {
exported := 0
for _, pls := range allPls {
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
if err != nil {
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
continue

View file

@ -243,7 +243,7 @@ func runPluginInfo(ctx context.Context, arg string) {
}
requirePluginsEnabled(ctx)
ds, ctx := getAdminContext(ctx)
p, err := ds.Plugin(ctx).Get(arg)
p, err := ds.Plugin().Get(ctx, arg)
if err != nil {
log.Fatal(ctx, "Plugin not found", "id", arg, err)
}
@ -264,7 +264,7 @@ func runPluginValidate(ctx context.Context, arg string) {
}
requirePluginsEnabled(ctx)
ds, ctx := getAdminContext(ctx)
p, err := ds.Plugin(ctx).Get(arg)
p, err := ds.Plugin().Get(ctx, arg)
if err != nil {
log.Fatal(ctx, "Plugin not found", "id", arg, err)
}
@ -329,7 +329,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
func runPluginList(ctx context.Context) {
requirePluginsEnabled(ctx)
ds, ctx := getAdminContext(ctx)
list, err := ds.Plugin(ctx).GetAll()
list, err := ds.Plugin().GetAll(ctx)
if err != nil {
log.Fatal(ctx, "Failed to list plugins", err)
}
@ -372,7 +372,7 @@ var pluginEditCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
requirePluginsEnabled(cmd.Context())
ds, ctx := getAdminContext(cmd.Context())
cur, err := ds.Plugin(ctx).Get(args[0])
cur, err := ds.Plugin().Get(ctx, args[0])
if err != nil {
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
}

View file

@ -184,11 +184,11 @@ func schedulePeriodicScan(ctx context.Context) func() error {
}
func pidHashChanged(ds model.DataStore) (bool, error) {
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
pidAlbum, err := ds.Property().DefaultGet(context.Background(), consts.PIDAlbumKey, "")
if err != nil {
return false, err
}
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
pidTrack, err := ds.Property().DefaultGet(context.Background(), consts.PIDTrackKey, "")
if err != nil {
return false, err
}
@ -199,11 +199,11 @@ func pidHashChanged(ds model.DataStore) (bool, error) {
func runInitialScan(ctx context.Context) func() error {
return func() error {
ds := CreateDataStore()
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
if err != nil {
return err
}
inProgress, err := ds.Library(ctx).ScanInProgress()
inProgress, err := ds.Library().ScanInProgress(ctx)
if err != nil {
return err
}
@ -219,7 +219,7 @@ func runInitialScan(ctx context.Context) func() error {
switch {
case fullScanRequired == "1":
log.Warn(ctx, "Full scan required after migration")
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
case pidHasChanged:
log.Warn(ctx, "PID config changed, performing full scan")
fullScanRequired = "1"

View file

@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
}
type svcControl struct {
ctx context.Context
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
cancel context.CancelFunc
done chan struct{}
}

View file

@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
err := ds.WithTx(func(tx model.DataStore) error {
existingUser, err := tx.User(ctx).FindByUsername(userID)
existingUser, err := tx.User().FindByUsername(ctx, userID)
if existingUser != nil {
return fmt.Errorf("existing user '%s'", userID)
}
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
}
if len(libraryIds) > 0 && !setAdmin {
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
}
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
return libraryError(user.Libraries)
}
} else {
user.Libraries, err = tx.Library(ctx).GetAll()
user.Libraries, err = tx.Library().GetAll(ctx)
if err != nil {
return err
}
}
err = tx.User(ctx).Put(&user)
err = tx.User().Put(ctx, &user)
if err != nil {
return err
}
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
updatedIds[idx] = lib.ID
}
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
return err
})
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
var user *model.User
err = ds.WithTx(func(tx model.DataStore) error {
count, err := tx.User(ctx).CountAll()
count, err := tx.User().CountAll(ctx)
if err != nil {
return err
}
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
return err
}
return tx.User(ctx).Delete(user.ID)
return tx.User().Delete(ctx, user.ID)
})
if err != nil {
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
}
if len(libraryIds) > 0 && !setAdmin {
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
}
if setAdmin && !user.IsAdmin {
libraries, err := tx.Library(ctx).GetAll()
libraries, err := tx.Library().GetAll(ctx)
if err != nil {
return err
}
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
return nil
}
err := tx.User(ctx).Put(user)
err := tx.User().Put(ctx, user)
if err != nil {
return err
}
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
updatedIds[idx] = lib.ID
}
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
if err != nil {
return err
}
@ -393,13 +393,11 @@ func runUserList(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
users, err := ds.User(ctx).ReadAll()
userList, err := ds.User().ReadAll(ctx)
if err != nil {
log.Fatal(ctx, "Failed to retrieve users", err)
}
userList := users.(model.Users)
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{

View file

@ -52,14 +52,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
}
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
user, err := ds.User(ctx).FindByUsername(id)
user, err := ds.User().FindByUsername(ctx, id)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, fmt.Errorf("finding user by name: %w", err)
}
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User(ctx).Get(id)
user, err = ds.User().Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("finding user by id: %w", err)
}

View file

@ -24,7 +24,7 @@ func (p *localAgent) AgentName() string {
}
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{
top, err := p.ds.MediaFile().GetAll(ctx, model.QueryOptions{
Sort: "playCount",
Order: "desc",
Max: count,
@ -43,7 +43,7 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
}
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
seed, err := p.ds.MediaFile(ctx).Get(id)
seed, err := p.ds.MediaFile().Get(ctx, id)
if err != nil {
return nil, err
}
@ -53,7 +53,7 @@ func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artis
return nil, nil
}
// Ask for extra so we can drop the seed itself and still fill the count.
candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
Filters: squirrel.And{
persistence.SongGenres.ByID(genreIDs),
squirrel.Eq{"missing": false},

View file

@ -13,13 +13,13 @@ type SessionKeys struct {
}
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey)
}
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
return sk.DataStore.UserProps().Get(ctx, userId, sk.KeyName)
}
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName)
}

View file

@ -62,7 +62,7 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitr
// rootArt, when set, is added to the archive root.
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error {
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"})
mfs, err := a.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: filters, Sort: "album"})
if err != nil {
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
return err
@ -189,7 +189,7 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer)
}
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false)
pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false)
if err != nil {
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
return err

View file

@ -52,7 +52,7 @@ var _ = Describe("Archiver", func() {
Sort: "album",
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ds.On("MediaFile").Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer)
@ -84,7 +84,7 @@ var _ = Describe("Archiver", func() {
Sort: "album",
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ds.On("MediaFile").Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
@ -246,7 +246,7 @@ var _ = Describe("Archiver", func() {
Filters: squirrel.Eq{"album_id": "1"},
Sort: "album",
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ds.On("MediaFile").Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
Return(nil, stream.ErrTooManyTranscodes).Once()
@ -310,7 +310,7 @@ var _ = Describe("Archiver", func() {
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
ds.On("Playlist", mock.Anything).Return(plRepo)
ds.On("Playlist").Return(plRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
@ -515,17 +515,17 @@ type mockDataStore struct {
model.DataStore
}
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
args := m.Called(ctx)
func (m *mockDataStore) MediaFile() model.MediaFileRepository {
args := m.Called()
return args.Get(0).(model.MediaFileRepository)
}
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
args := m.Called(ctx)
func (m *mockDataStore) Playlist() model.PlaylistRepository {
args := m.Called()
return args.Get(0).(model.PlaylistRepository)
}
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
func (m *mockDataStore) Library() model.LibraryRepository {
return &mockLibraryRepository{}
}
@ -534,7 +534,7 @@ type mockLibraryRepository struct {
model.LibraryRepository
}
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) {
return "/music", nil
}
@ -543,7 +543,7 @@ type mockMediaFileRepository struct {
model.MediaFileRepository
}
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
args := m.Called(options)
return args.Get(0).(model.MediaFiles), args.Error(1)
}
@ -553,7 +553,7 @@ type mockPlaylistRepository struct {
model.PlaylistRepository
}
func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
args := m.Called(id, refreshSmartPlaylists, includeMissing)
return args.Get(0).(*model.Playlist), args.Error(1)
}

View file

@ -59,21 +59,21 @@ func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID
var err error
switch artID.Kind {
case model.KindArtistArtwork:
found, err = ds.Artist(ctx).Exists(artID.ID)
found, err = ds.Artist().Exists(ctx, artID.ID)
case model.KindAlbumArtwork:
found, err = ds.Album(ctx).Exists(artID.ID)
found, err = ds.Album().Exists(ctx, artID.ID)
case model.KindMediaFileArtwork:
found, err = ds.MediaFile(ctx).Exists(artID.ID)
found, err = ds.MediaFile().Exists(ctx, artID.ID)
case model.KindPlaylistArtwork:
found, err = ds.Playlist(ctx).Exists(artID.ID)
found, err = ds.Playlist().Exists(ctx, artID.ID)
case model.KindRadioArtwork:
found, err = ds.Radio(ctx).Exists(artID.ID)
found, err = ds.Radio().Exists(ctx, artID.ID)
case model.KindDiscArtwork:
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
if perr != nil {
return false
}
found, err = ds.Album(ctx).Exists(albumID)
found, err = ds.Album().Exists(ctx, albumID)
default:
return false
}
@ -119,7 +119,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
}
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
ia, err := s.ds.Artwork().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary)
switch {
case errors.Is(err, model.ErrNotFound):
return s.provisional(ctx, artID, size, square)
@ -173,7 +173,7 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
return s.dangling(ctx, artID)
}
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
art, err := s.ds.Artwork().GetImage(ctx, ia.Hash)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return s.dangling(ctx, artID)
@ -264,13 +264,13 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
// must fall back to disc/album instead of serving stale persisted embedded art.
if !conf.Server.EnableMediaFileCoverArt {
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
if err != nil {
return nil, err
}
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
}
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
switch {
case err == nil && ia.Hash != "":
return s.serveHash(ctx, artID, ia, size, square)
@ -283,7 +283,7 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
}
noRow := errors.Is(err, model.ErrNotFound)
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
if err != nil {
return nil, err
}
@ -342,7 +342,7 @@ func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image,
}
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
ItemKind: artID.Kind.Prefix(),
ItemID: artID.ID,
ImageType: model.ImageTypePrimary,

View file

@ -1,6 +1,7 @@
package artwork
import (
"context"
"io/fs"
"net/netip"
"net/url"
@ -108,15 +109,15 @@ type fakeFolderRepo struct {
otherAudioErr error
}
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
return f.result, f.err
}
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
return f.hasOtherAudio, f.otherAudioErr
}
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
f.getCallCount++
if f.getErr != nil {
return nil, f.getErr

View file

@ -45,8 +45,8 @@ var _ = Describe("Artwork", func() {
hash, err := hashImage(bytes.NewReader(imgBytes))
Expect(err).ToNot(HaveOccurred())
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
seedEntity(kind, id)
return hash
}
@ -56,9 +56,9 @@ var _ = Describe("Artwork", func() {
GinkgoHelper()
switch kind {
case "al":
Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed())
Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed())
case "mf":
Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed())
Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed())
}
}
@ -147,9 +147,9 @@ var _ = Describe("Artwork", func() {
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
mtime := fileMtime(imgPath)
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al2")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
})).To(Succeed())
@ -163,9 +163,9 @@ var _ = Describe("Artwork", func() {
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alni")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
})).To(Succeed())
@ -180,9 +180,9 @@ var _ = Describe("Artwork", func() {
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alnic")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
})).To(Succeed())
@ -210,9 +210,9 @@ var _ = Describe("Artwork", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al3")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
})).To(Succeed())
@ -220,7 +220,7 @@ var _ = Describe("Artwork", func() {
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
})
@ -229,9 +229,9 @@ var _ = Describe("Artwork", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al3b")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
})).To(Succeed())
@ -252,7 +252,7 @@ var _ = Describe("Artwork", func() {
})
It("never re-enqueues an absent state on view, however old", func() {
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
})).To(Succeed())
@ -272,7 +272,7 @@ var _ = Describe("Artwork", func() {
Expect(readAll(img)).To(Equal(coverBytes))
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -283,7 +283,7 @@ var _ = Describe("Artwork", func() {
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
@ -310,7 +310,7 @@ var _ = Describe("Artwork", func() {
It("delegates to the album when the track's state is absent", func() {
seedFoundStore("al", "albm", coverBytes)
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
@ -343,7 +343,7 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
Expect(len(readAll(img))).To(BeNumerically(">", 0))
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -494,7 +494,7 @@ var _ = Describe("Artwork", func() {
})
It("falls back to the artist placeholder for an absent artist", func() {
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
Expect(err).ToNot(HaveOccurred())
@ -535,7 +535,7 @@ var _ = Describe("EntityExists", func() {
artistRepo := tests.CreateMockArtistRepo()
artistRepo.SetData(model.Artists{{ID: "ar1"}})
radioRepo := tests.CreateMockedRadioRepo()
Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
})

View file

@ -45,7 +45,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
}
al, err := ds.Album(ctx).Get(albumID)
al, err := ds.Album().Get(ctx, albumID)
if err != nil {
return nil, err
}
@ -61,7 +61,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
}
// Query mediafiles for this album + disc to find folder associations and first track
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
Sort: "track_number",
Order: "ASC",
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
@ -88,7 +88,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
// Resolve folder IDs to library-relative paths
discFoldersRel := make(map[string]bool)
if len(folderIDs) > 0 {
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{
Filters: squirrel.Eq{"folder.id": folderIDs},
})
if err != nil {

View file

@ -44,20 +44,20 @@ var _ = Describe("Acquisition → serve loop", func() {
itemFound := func(kind model.Kind, id string) func() bool {
return func() bool {
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
return err == nil && ia.Hash != ""
}
}
itemAbsent := func(kind model.Kind, id string) func() bool {
return func() bool {
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
return err == nil && ia.Hash == ""
}
}
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
bump := func(kind, id string) {
GinkgoHelper()
Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBump,
})).To(Succeed())
@ -141,7 +141,7 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("al", "al1")
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
@ -158,7 +158,7 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("ar", "ar1")
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("upload"))
@ -175,14 +175,14 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("pl", "pl1")
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("generated"))
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Hash).To(Equal(ia.Hash))
art, err := artRepo.GetImage(ia.Hash)
art, err := artRepo.GetImage(ctx, ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Mime).To(Equal("image/png"))
Expect(len(readAll(img))).To(BeNumerically(">", 0))
@ -194,7 +194,7 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("ra", "ra1")
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("upload"))
@ -216,12 +216,12 @@ var _ = Describe("Acquisition → serve loop", func() {
provisionalBytes := readAll(provisional)
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
// The provisional read enqueued a Bump; drain it.
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("embedded"))
Expect(ia.Hash).To(Equal(provisional.Hash))
@ -237,9 +237,9 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("al", "al1")
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
art, err := artRepo.GetImage(ia.Hash)
art, err := artRepo.GetImage(ctx, ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Mime).To(Equal("image/jpeg"))
Expect(art.Width).To(BeNumerically(">", 0))
@ -259,9 +259,9 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("ra", "ra1")
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
art, err := artRepo.GetImage(ia.Hash)
art, err := artRepo.GetImage(ctx, ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Mime).To(Equal("image/gif"))
Expect(art.Width).To(BeNumerically("==", 4))
@ -279,9 +279,9 @@ var _ = Describe("Acquisition → serve loop", func() {
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
})
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
@ -293,7 +293,7 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("ra", "ra1")
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
staleHash := ia.Hash
@ -308,7 +308,7 @@ var _ = Describe("Acquisition → serve loop", func() {
// That failed read enqueued a re-resolution.
runWorkerUntil(ctx, worker, func() bool {
cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
cur, gerr := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
})
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)

View file

@ -201,7 +201,7 @@ var _ = Describe("Artist artwork resolution", func() {
uploaded := ar.ID + "_upload.jpg"
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
ar.UploadedImage = uploaded
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
Expect(rds.Artist().Put(rctx, &ar)).To(Succeed())
ia := acquire(model.KindArtistArtwork, ar.ID)
Expect(ia.Source).To(Equal("upload"))
@ -279,7 +279,7 @@ var _ = Describe("Artist artwork resolution", func() {
func soleArtist() model.Artist {
GinkgoHelper()
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{
Filters: squirrel.Eq{"artist.name": "Artist"},
})
Expect(err).ToNot(HaveOccurred())

View file

@ -67,13 +67,17 @@ type fakeFolderRepo struct {
result []model.Folder
}
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil }
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
return f.result, nil
}
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
return false, nil
}
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound }
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
return nil, model.ErrNotFound
}
func writeUpload(entityType, name, srcFixture string) string {
GinkgoHelper()

View file

@ -137,7 +137,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
func mediafileOn(relPath string) model.MediaFile {
GinkgoHelper()
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{
Filters: squirrel.Like{"media_file.path": relPath},
})
Expect(err).ToNot(HaveOccurred())

View file

@ -142,13 +142,13 @@ var _ = Describe("Playlist artwork resolution", func() {
})
scan()
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(2))
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
ia := acquire(model.KindPlaylistArtwork, pl.ID)
Expect(ia.Source).To(Equal("generated"))
@ -180,14 +180,14 @@ var _ = Describe("Playlist artwork resolution", func() {
setLayout(layout)
scan()
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(4))
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
pl.AddMediaFilesByID(ids)
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
ia := acquire(model.KindPlaylistArtwork, pl.ID)
Expect(ia.Source).To(Equal("generated"))
@ -208,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist {
if pl.OwnerID == "" {
pl.OwnerID = "admin-1"
}
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
return pl
}

View file

@ -23,7 +23,7 @@ var _ = Describe("Radio artwork resolution", func() {
It("returns the uploaded image bytes", func() {
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
ia := acquire(model.KindRadioArtwork, rd.ID)
Expect(ia.Source).To(Equal("upload"))
@ -35,7 +35,7 @@ var _ = Describe("Radio artwork resolution", func() {
// (no files on disk — the resolver has no sources to fall back to)
It("settles absent", func() {
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
ia := acquire(model.KindRadioArtwork, rd.ID)
Expect(ia.Hash).To(BeEmpty())

View file

@ -99,11 +99,11 @@ func setupResolutionHarness() {
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
Expect(rds.User(rctx).Put(&adminUser)).To(Succeed())
Expect(rds.User().Put(rctx, &adminUser)).To(Succeed())
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
Expect(rds.Library(rctx).Put(&lib)).To(Succeed())
Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
Expect(rds.Library().Put(rctx, &lib)).To(Succeed())
Expect(rds.User().SetUserLibraries(rctx, adminUser.ID, []int{lib.ID})).To(Succeed())
loadEmbeddedFixture()
@ -140,13 +140,13 @@ func scan() {
func acquire(kind model.Kind, id string) model.ItemArtwork {
GinkgoHelper()
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
Expect(rds.ArtworkQueue().EnqueuePreservingBackoff(rctx, model.ArtworkQueueItem{
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBump,
})).To(Succeed())
var ia *model.ItemArtwork
runResolutionWorkerUntil(func() bool {
got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
got, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
if err != nil {
return false
}
@ -211,7 +211,7 @@ func expectAlbumFolderCover(al model.Album, suffix string) {
// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
func requireNoStateRow(kind model.Kind, id string) {
GinkgoHelper()
_, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
_, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound),
"assert %s %q before acquiring any other entity in this spec", kind, id)
}
@ -266,7 +266,7 @@ func gridQuadrants(data []byte) [4]color.RGBA {
// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
func storedBytes(ia model.ItemArtwork) []byte {
GinkgoHelper()
art, err := rds.Artwork(rctx).GetImage(ia.Hash)
art, err := rds.Artwork().GetImage(rctx, ia.Hash)
Expect(err).ToNot(HaveOccurred())
r, err := rstore.Open(ia.Hash, art.Mime)
Expect(err).ToNot(HaveOccurred())
@ -345,7 +345,7 @@ func replaceWithRealMP3(relPath string) {
func firstAlbum() model.Album {
GinkgoHelper()
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
return albums[0]
@ -353,7 +353,7 @@ func firstAlbum() model.Album {
func albumByName(name string) model.Album {
GinkgoHelper()
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
for _, al := range albums {
if al.Name == name {

View file

@ -36,7 +36,7 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.
}
func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) {
return ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
return ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
}
// folderImages collects the folders' image files, sorted so files without
@ -79,7 +79,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
if len(folders) < 2 && anyFolderHasImages(folders) {
return nil, nil
}
parent, err := ds.Folder(ctx).Get(commonParentID)
parent, err := ds.Folder().Get(ctx, commonParentID)
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
return nil, nil
@ -91,7 +91,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
// The library root can never be an album root
return nil, nil
}
hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs)
hasOtherAudio, err := ds.Folder().HasAudioOutsideFolders(ctx, *parent, folderIDs)
if err != nil {
return nil, err
}

View file

@ -169,14 +169,14 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
}
// Cleaned like the album paths; Join keeps an empty path empty, Clean would return ".".
libPath, _ := ds.Library(ctx).GetPath(libID)
libPath, _ := ds.Library().GetPath(ctx, libID)
libPath = filepath.Join(libPath)
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
"libPath", libPath, "libID", libID, "albumPaths", paths)
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
if err != nil || len(folders) == 0 {
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
"libPath", libPath, "libID", libID, err)

View file

@ -70,7 +70,7 @@ func ConfigFingerprint() string {
// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess.
func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
current := ConfigFingerprint()
stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
stored, err := ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, "")
if err != nil {
return err
}
@ -89,14 +89,14 @@ func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
// MarkConfigApplied records the current fingerprint as the one the library is resolved under.
func MarkConfigApplied(ctx context.Context, ds model.DataStore) error {
return ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
return ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
}
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
queue := ds.ArtworkQueue(ctx)
queue := ds.ArtworkQueue()
for _, kind := range ReprocessKinds {
if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil {
if _, err := queue.EnqueueAllMissing(ctx, kind, model.ArtworkPriorityRecheck); err != nil {
return err
}
}
@ -108,31 +108,31 @@ func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
switch kind {
case model.KindArtistArtwork:
ar, err := ds.Artist(ctx).Get(id)
ar, err := ds.Artist().Get(ctx, id)
if err != nil {
return "", err
}
return ar.Name, nil
case model.KindAlbumArtwork:
al, err := ds.Album(ctx).Get(id)
al, err := ds.Album().Get(ctx, id)
if err != nil {
return "", err
}
return al.Name, nil
case model.KindPlaylistArtwork:
pls, err := ds.Playlist(ctx).Get(id)
pls, err := ds.Playlist().Get(ctx, id)
if err != nil {
return "", err
}
return pls.Name, nil
case model.KindRadioArtwork:
rd, err := ds.Radio(ctx).Get(id)
rd, err := ds.Radio().Get(ctx, id)
if err != nil {
return "", err
}
return rd.Name, nil
case model.KindMediaFileArtwork:
mf, err := ds.MediaFile(ctx).Get(id)
mf, err := ds.MediaFile().Get(ctx, id)
if err != nil {
return "", err
}
@ -148,7 +148,7 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string
if err != nil {
return "", err
}
al, err := ds.Album(ctx).Get(albumID)
al, err := ds.Album().Get(ctx, albumID)
if err != nil {
return "", err
}
@ -162,11 +162,11 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string
// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil {
if err := ds.Artwork().DeleteForItems(ctx, kind, []string{id}); err != nil {
return fmt.Errorf("clearing artwork state: %w", err)
}
item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
if err := ds.ArtworkQueue(ctx).Enqueue(item); err != nil {
if err := ds.ArtworkQueue().Enqueue(ctx, item); err != nil {
return fmt.Errorf("enqueuing artwork refresh: %w", err)
}
return nil

View file

@ -95,15 +95,15 @@ var _ = Describe("Housekeeping", func() {
It("records the current fingerprint when none was ever stored", func() {
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint()))
Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint()))
})
It("leaves a stale fingerprint stored, so the warning survives a restart", func() {
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
Expect(propRepo.Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint"))
Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint"))
})
})
@ -153,7 +153,7 @@ var _ = Describe("ItemName", func() {
{ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}},
})
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
Expect(ds.Artist(ctx).(*tests.MockArtistRepo).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
Expect(ds.Artist().(*tests.MockArtistRepo).Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
})
It("returns the album name", func() {

View file

@ -30,7 +30,7 @@ func (v libraryView) Abs(rel string) string {
// loadLibraryView resolves the MusicFS and absolute root path in a single
// library lookup.
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
lib, err := ds.Library(ctx).Get(libID)
lib, err := ds.Library().Get(ctx, libID)
if err != nil {
return libraryView{}, err
}

View file

@ -24,7 +24,7 @@ var _ = Describe("loadLibraryView", Ordered, func() {
})
It("returns a view for a library backed by registered storage", func() {
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
Expect(ds.Library().Put(ctx, &model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
lib, err := loadLibraryView(ctx, ds, 1)
Expect(err).ToNot(HaveOccurred())
@ -45,7 +45,7 @@ var _ = Describe("loadLibraryView", Ordered, func() {
})
It("returns an error when the library path uses an unregistered scheme", func() {
Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
Expect(ds.Library().Put(ctx, &model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
_, err := loadLibraryView(ctx, ds, 2)
Expect(err).To(HaveOccurred())
})

View file

@ -82,7 +82,7 @@ type processor struct {
// acquire resolves one queue item end to end: find an image, hash/decode/
// blurhash it, place its bytes, and persist the resulting state.
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
repo := p.ds.Artwork(ctx)
repo := p.ds.Artwork()
start := time.Now()
defer func() {
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
@ -137,7 +137,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
art, err := repo.GetImage(hash)
art, err := repo.GetImage(ctx, hash)
switch {
case err == nil && art.Width > 0:
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
@ -195,7 +195,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
if err != nil {
return nil, fmt.Errorf("writing image store: %w", err)
}
if err := repo.PutImage(art); err != nil {
if err := repo.PutImage(ctx, art); err != nil {
return nil, fmt.Errorf("persisting artwork image: %w", err)
}
ia := &model.ItemArtwork{
@ -210,7 +210,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
Trace: traceFrom(ctx).encode(sourcePath),
}
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
if err := repo.PutItemArtwork(ia); err != nil {
if err := repo.PutItemArtwork(ctx, ia); err != nil {
return nil, fmt.Errorf("persisting item artwork state: %w", err)
}
return ia, nil
@ -218,7 +218,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
// writeAbsent records a known-absent state: every source answered definitively "no".
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
err := repo.PutItemArtwork(&model.ItemArtwork{
err := repo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: item.ItemKind,
ItemID: item.ItemID,
ImageType: item.ImageType,

View file

@ -93,14 +93,14 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).ToNot(BeEmpty())
Expect(ia.Source).To(Equal("folder"))
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
Expect(ia.RefMtime).To(BeNumerically(">", 0))
art, err := artRepo.GetImage(ia.Hash)
art, err := artRepo.GetImage(ctx, ia.Hash)
Expect(err).ToNot(HaveOccurred())
// Every placeholder is derived from the one shared thumbnail, so all three land together.
Expect(art.BlurHash).ToNot(BeEmpty())
@ -156,12 +156,12 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("embedded"))
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
art, err := artRepo.GetImage(ia.Hash)
art, err := artRepo.GetImage(ctx, ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.BlurHash).ToNot(BeEmpty())
@ -179,7 +179,7 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
Expect(out).To(Equal(outcomeAbsent))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(BeEmpty())
Expect(ia.Source).To(BeEmpty())
@ -200,7 +200,7 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent")
})
@ -225,7 +225,7 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
})
@ -269,7 +269,7 @@ var _ = Describe("processor.acquire", func() {
Expect(out).To(Equal(outcomeFailed))
Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay")
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -299,7 +299,7 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
Expect(out).To(Equal(outcomeFoundStale))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).ToNot(BeEmpty())
Expect(ia.Source).To(Equal("folder"))
@ -316,10 +316,10 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
art, err := artRepo.GetImage(ia.Hash)
art, err := artRepo.GetImage(ctx, ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Width).To(BeZero())
Expect(art.BlurHash).To(BeEmpty())
@ -336,7 +336,7 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -354,7 +354,7 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -373,12 +373,12 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("external:deezerFake"))
Expect(ia.Hash).ToNot(BeEmpty())
art, err := artRepo.GetImage(ia.Hash)
art, err := artRepo.GetImage(ctx, ia.Hash)
Expect(err).ToNot(HaveOccurred())
rc, err := store.Open(ia.Hash, art.Mime)
Expect(err).ToNot(HaveOccurred())
@ -397,7 +397,7 @@ var _ = Describe("processor.acquire", func() {
out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
Expect(out1).To(Equal(outcomeFound))
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
// A re-decode instead of a hash dedup would overwrite this sentinel.
@ -407,11 +407,11 @@ var _ = Describe("processor.acquire", func() {
out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
Expect(out2).To(Equal(outcomeFound))
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia2.Hash).To(Equal(ia1.Hash))
reused, err := artRepo.GetImage(ia1.Hash)
reused, err := artRepo.GetImage(ctx, ia1.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(reused.BlurHash).To(Equal("SENTINEL"))
})
@ -437,7 +437,7 @@ var _ = Describe("processor.acquire", func() {
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
Expect(outN).To(Equal(outcomeFound))
iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
iaA, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(iaA.Source).To(Equal("folder"))
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
@ -451,19 +451,19 @@ var _ = Describe("processor.acquire", func() {
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
Expect(outN).To(Equal(outcomeFound))
iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
iaB, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(iaB.Hash).To(Equal(iaA.Hash))
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano()))
iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
iaAafter, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
Expect(artRepo.Data).To(HaveLen(1))
reused, err := artRepo.GetImage(iaA.Hash)
reused, err := artRepo.GetImage(ctx, iaA.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(reused.BlurHash).To(Equal("SENTINEL"))
})
@ -483,7 +483,7 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -504,7 +504,7 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
Expect(out).To(Equal(outcomeFailed))
_, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "big", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -565,12 +565,12 @@ var _ = Describe("processor.acquire", func() {
hash, err := hashImage(bytes.NewReader(imgBytes))
Expect(err).ToNot(HaveOccurred())
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
Expect(out).To(Equal(outcomeFound))
upgraded, err := artRepo.GetImage(hash)
upgraded, err := artRepo.GetImage(ctx, hash)
Expect(err).ToNot(HaveOccurred())
Expect(upgraded.Width).To(BeNumerically(">", 0))
Expect(upgraded.BlurHash).ToNot(BeEmpty())
@ -590,7 +590,7 @@ var _ = Describe("processor.acquire", func() {
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
})

View file

@ -14,9 +14,9 @@ const pruneMinAge = time.Hour
func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
start := time.Now()
defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }()
repo := ds.Artwork(ctx)
repo := ds.Artwork()
purged, err := repo.PurgeDanglingItems()
purged, err := repo.PurgeDanglingItems(ctx)
if err != nil {
return err
}
@ -25,7 +25,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
}
// Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed).
queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling()
queuePurged, err := ds.ArtworkQueue().PurgeDangling(ctx)
if err != nil {
return err
}
@ -35,7 +35,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
// Files younger than the grace window may belong to acquisitions whose rows aren't committed yet.
cutoff := time.Now().Add(-pruneMinAge)
orphans, err := repo.PurgeOrphans(cutoff)
orphans, err := repo.PurgeOrphans(ctx, cutoff)
if err != nil {
return err
}
@ -44,7 +44,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
}
// Read after the delete, so the sweep below reclaims the files of the rows just removed.
mimes, err := repo.GetMimeByHash()
mimes, err := repo.GetMimeByHash(ctx)
if err != nil {
return err
}

View file

@ -18,18 +18,20 @@ type flakyGetArtworkRepo struct {
*tests.MockArtworkRepo
}
func (f *flakyGetArtworkRepo) GetMimeByHash() (map[string]string, error) {
func (f *flakyGetArtworkRepo) GetMimeByHash(context.Context) (map[string]string, error) {
return nil, errors.New("db locked")
}
var _ = Describe("Prune", func() {
var ctx context.Context
var ds *tests.MockDataStore
var store *ImageStore
var awRepo *tests.MockArtworkRepo
BeforeEach(func() {
ctx = GinkgoT().Context()
ds = &tests.MockDataStore{}
awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo)
awRepo = ds.Artwork().(*tests.MockArtworkRepo)
store = NewImageStore(GinkgoT().TempDir())
})
@ -41,9 +43,9 @@ var _ = Describe("Prune", func() {
}
It("purges dangling item_artwork state for gone entities, summed across kinds", func() {
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
awRepo.ExistingIDs = map[string]map[string]bool{
"al": {},
"ar": {"live-artist": true},
@ -51,17 +53,17 @@ var _ = Describe("Prune", func() {
Expect(prune(context.Background(), ds, store)).To(Succeed())
_, err := awRepo.GetItemArtwork(model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
_, err := awRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
_, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
_, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
})
It("purges dangling artwork_queue rows for gone entities", func() {
queueRepo := tests.CreateMockArtworkQueueRepo()
Expect(queueRepo.Enqueue(
Expect(queueRepo.Enqueue(ctx,
model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary},
model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary},
)).To(Succeed())
@ -80,17 +82,17 @@ var _ = Describe("Prune", func() {
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
old := time.Now().Add(-2 * time.Hour)
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
ageArtwork(h, old)
kept := []byte("kept-bytes")
hk, _ := hashImage(bytes.NewReader(kept))
Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed())
Expect(awRepo.PutImage(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
Expect(prune(context.Background(), ds, store)).To(Succeed())
_, err := awRepo.GetImage(h)
_, err := awRepo.GetImage(ctx, h)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = store.Open(h, "image/jpeg")
Expect(os.IsNotExist(err)).To(BeTrue())
@ -103,14 +105,14 @@ var _ = Describe("Prune", func() {
data := []byte("reacquired-bytes")
h, _ := hashImage(bytes.NewReader(data))
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
ageArtwork(h, time.Now().Add(-2*time.Hour))
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1",
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "a1",
ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed())
Expect(prune(context.Background(), ds, store)).To(Succeed())
_, err := awRepo.GetImage(h)
_, err := awRepo.GetImage(ctx, h)
Expect(err).ToNot(HaveOccurred())
rc, err := store.Open(h, "image/jpeg")
Expect(err).ToNot(HaveOccurred())
@ -122,11 +124,11 @@ var _ = Describe("Prune", func() {
h, _ := hashImage(bytes.NewReader(data))
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
// Reacquisition refreshed created_at, so the row is unreferenced but too young to drop.
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
Expect(prune(context.Background(), ds, store)).To(Succeed())
_, err := awRepo.GetImage(h)
_, err := awRepo.GetImage(ctx, h)
Expect(err).ToNot(HaveOccurred())
rc, err := store.Open(h, "image/jpeg")
Expect(err).ToNot(HaveOccurred())
@ -137,7 +139,7 @@ var _ = Describe("Prune", func() {
data := []byte("racing-bytes")
h, _ := hashImage(bytes.NewReader(data))
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
ageArtwork(h, time.Now().Add(-2*time.Hour))
// The row is orphaned, but a concurrent acquisition just touched the file's mtime.
@ -170,7 +172,7 @@ var _ = Describe("Prune", func() {
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
// The row records the current mime; the .png file is a superseded variant.
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
Expect(prune(context.Background(), ds, store)).To(Succeed())
@ -192,14 +194,14 @@ var _ = Describe("Prune", func() {
hb, _ := hashImage(bytes.NewReader(blocked))
Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed())
Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed())
Expect(awRepo.PutImage(&model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
ageArtwork(hb, old)
good := []byte("good-bytes")
hg, _ := hashImage(bytes.NewReader(good))
Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed())
Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed())
Expect(awRepo.PutImage(&model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
ageArtwork(hg, old)
// A read-only shard directory makes os.Remove fail (EACCES) for hb's file only.
@ -210,13 +212,13 @@ var _ = Describe("Prune", func() {
Expect(prune(context.Background(), ds, store)).To(Succeed())
_, err := awRepo.GetImage(hg)
_, err := awRepo.GetImage(ctx, hg)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = store.Open(hg, "image/jpeg")
Expect(os.IsNotExist(err)).To(BeTrue())
// The row purge does not depend on file removal, so only the file survives.
_, err = awRepo.GetImage(hb)
_, err = awRepo.GetImage(ctx, hb)
Expect(err).To(MatchError(model.ErrNotFound))
rc, err := store.Open(hb, "image/jpeg")
Expect(err).ToNot(HaveOccurred())

View file

@ -198,7 +198,7 @@ func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io
// resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources.
func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) {
al, err := r.ds.Album(ctx).Get(albumID)
al, err := r.ds.Album().Get(ctx, albumID)
if err != nil {
return resolution{}, err
}
@ -243,7 +243,7 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority.
func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) {
ar, err := r.ds.Artist(ctx).Get(artistID)
ar, err := r.ds.Artist().Get(ctx, artistID)
if err != nil {
return resolution{}, err
}
@ -259,7 +259,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
}
// Only consider albums where the artist is the sole album artist.
als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
als, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
if err != nil {
return resolution{}, err
}
@ -328,7 +328,7 @@ const PlaylistGridSamples = 4
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
pl, err := r.ds.Playlist(ctx).Get(playlistID)
pl, err := r.ds.Playlist().Get(ctx, playlistID)
if err != nil {
return resolution{}, err
}
@ -374,8 +374,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
}
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).
GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false).
GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
if err != nil {
return resolution{}, err
}
@ -428,7 +428,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
// resolveRadio serves only an uploaded image; there is no fallback.
func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) {
radio, err := r.ds.Radio(ctx).Get(radioID)
radio, err := r.ds.Radio().Get(ctx, radioID)
if err != nil {
return resolution{}, err
}
@ -439,7 +439,7 @@ func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution
// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art
// is a definitive absent.
func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) {
mf, err := r.ds.MediaFile(ctx).Get(id)
mf, err := r.ds.MediaFile().Get(ctx, id)
if err != nil {
return resolution{}, err
}

View file

@ -16,12 +16,14 @@ import (
)
var _ = Describe("Uploader", func() {
var ctx context.Context
var svc Uploader
var tmpDir string
var artRepo *tests.MockArtworkRepo
var queueRepo *tests.MockArtworkQueueRepo
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = conf.NewDir(tmpDir)
@ -33,7 +35,6 @@ var _ = Describe("Uploader", func() {
Describe("SetImage", func() {
It("creates directory and saves image file", func() {
ctx := context.Background()
reader := strings.NewReader("fake image data")
filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
Expect(err).ToNot(HaveOccurred())
@ -46,7 +47,6 @@ var _ = Describe("Uploader", func() {
})
It("falls back to ID-only filename when name cleans to empty", func() {
ctx := context.Background()
reader := strings.NewReader("data")
filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
Expect(err).ToNot(HaveOccurred())
@ -54,7 +54,6 @@ var _ = Describe("Uploader", func() {
})
It("removes old image when replacing", func() {
ctx := context.Background()
oldDir := filepath.Join(tmpDir, "artwork", "artist")
Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
oldFile := filepath.Join(oldDir, "ar-1_old.png")
@ -70,15 +69,13 @@ var _ = Describe("Uploader", func() {
})
It("ignores missing old file without error", func() {
ctx := context.Background()
reader := strings.NewReader("data")
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
Expect(err).ToNot(HaveOccurred())
})
It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() {
ctx := context.Background()
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
})).To(Succeed())
@ -87,25 +84,24 @@ var _ = Describe("Uploader", func() {
// SetImage only writes the file; the state row survives and nothing is queued until
// the caller has persisted the new filename and called EnqueueArtwork.
_, err = artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
})
})
Describe("EnqueueArtwork", func() {
It("clears artwork state and enqueues a Bump", func() {
ctx := context.Background()
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
})).To(Succeed())
svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1")
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
queued, err := queueRepo.DequeueBatch(1000)
queued, err := queueRepo.DequeueBatch(ctx, 1000)
Expect(err).ToNot(HaveOccurred())
Expect(queued).To(ContainElement(SatisfyAll(
HaveField("ItemKind", "ar"),
@ -116,13 +112,12 @@ var _ = Describe("Uploader", func() {
It("is a no-op for an unknown entity type", func() {
svc.EnqueueArtwork(context.Background(), "unknown", "x-1")
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
})
})
Describe("RemoveImage", func() {
It("removes the file at the given path", func() {
ctx := context.Background()
dir := filepath.Join(tmpDir, "artwork", "artist")
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
path := filepath.Join(dir, "ar-1_test.jpg")
@ -134,13 +129,11 @@ var _ = Describe("Uploader", func() {
})
It("succeeds when file does not exist", func() {
ctx := context.Background()
err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
Expect(err).ToNot(HaveOccurred())
})
It("succeeds with empty path", func() {
ctx := context.Background()
err := svc.RemoveImage(ctx, "")
Expect(err).ToNot(HaveOccurred())
})

View file

@ -45,7 +45,7 @@ type Worker struct {
broker events.Broker
pruneMu sync.RWMutex
pools []*drainPool
runCtx context.Context
runCtx context.Context //nolint:containedctx // worker lifecycle ctx, set at Run
paused func() bool
gatesMu sync.Mutex
@ -155,7 +155,7 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
}
// Dequeue well past the pool size so a slow external lookup never idles the other slots.
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...)
items, err := w.proc.ds.ArtworkQueue().DequeueBatch(ctx, max(16, 4*concurrency), kinds...)
if err != nil {
return 0, err
}
@ -246,12 +246,12 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
ctx = withTrace(ctx, trace)
out, got, retryIn := w.proc.acquire(ctx, item)
queue := w.proc.ds.ArtworkQueue(ctx)
queue := w.proc.ds.ArtworkQueue()
switch out {
case outcomeFound, outcomeAbsent:
// A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives
// here and the next drain re-resolves it.
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
case outcomeFoundStale, outcomeFailed:
@ -260,7 +260,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
// fresh, immediate eligibility.
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
if err := queue.MarkFailedIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
@ -271,7 +271,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
// Art already being served is kept: exhaustion means unreachable, not removed.
settled := "kept previous state"
if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
writeAbsent(ctx, w.proc.ds.Artwork(), item)
settled = "recorded absent"
}
// The queue row is about to go, taking the only record of the failure with it. This write is
@ -279,7 +279,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
w.recordGiveUp(ctx, item, encoded)
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
}
@ -293,7 +293,7 @@ func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem,
if !ok {
return
}
if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil {
if err := w.proc.ds.Artwork().PutLastFailure(ctx, kind, item.ItemID, item.ImageType, trace); err != nil {
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
}
}
@ -303,7 +303,7 @@ func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueue
if !ok {
return false
}
ia, err := w.proc.ds.Artwork(ctx).GetItemArtwork(kind, item.ItemID, item.ImageType)
ia, err := w.proc.ds.Artwork().GetItemArtwork(ctx, kind, item.ItemID, item.ImageType)
return err == nil && ia.Hash != ""
}

View file

@ -21,6 +21,12 @@ import (
const soakCycles = 2200
var _ = Describe("Worker soak", func() {
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
})
It("does not leak goroutines, heap, or fds over many acquisition cycles", func() {
if testing.Short() {
Skip("skipping soak test in short mode")
@ -100,9 +106,9 @@ var _ = Describe("Worker soak", func() {
// Read-back exercises the surfaces a caller would use after acquisition.
if out == outcomeFound {
kind, _ := model.ParseKind(it.ItemKind)
ia, err := artRepo.GetItemArtwork(kind, it.ItemID, model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, kind, it.ItemID, model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i)
art, err := artRepo.GetImage(ia.Hash)
art, err := artRepo.GetImage(ctx, ia.Hash)
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)
rc, err := store.Open(ia.Hash, art.Mime)
switch {

View file

@ -56,8 +56,8 @@ type reenqueueOnDequeue struct {
done bool
}
func (r *reenqueueOnDequeue) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
items, err := r.MockArtworkQueueRepo.DequeueBatch(n, kinds...)
func (r *reenqueueOnDequeue) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
items, err := r.MockArtworkQueueRepo.DequeueBatch(ctx, n, kinds...)
if !r.done && len(items) > 0 {
r.done = true
for k, it := range r.Data {
@ -124,18 +124,27 @@ type visibilityPlaylistDS struct {
tracks model.PlaylistTrackRepository
}
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
func (v *visibilityPlaylistDS) Playlist() model.PlaylistRepository {
repo := tests.CreateMockPlaylistRepo()
repo.TracksRepo = v.tracks
if u, ok := request.UserFrom(ctx); ok && u.IsAdmin {
repo.SetData(model.Playlists{v.private})
repo.SetData(model.Playlists{v.private})
return &visibilityPlaylistRepo{MockPlaylistRepo: repo}
}
type visibilityPlaylistRepo struct {
*tests.MockPlaylistRepo
}
func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Playlist, error) {
if u, ok := request.UserFrom(ctx); !ok || !u.IsAdmin {
return nil, model.ErrNotFound
}
return repo
return v.MockPlaylistRepo.Get(ctx, id)
}
func adminUserRepo() *tests.MockedUserRepo {
repo := tests.CreateMockUserRepo()
Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
return repo
}
@ -157,8 +166,8 @@ var _ = Describe("Worker", func() {
)
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
var err error
repoRoot, err = os.Getwd()
Expect(err).ToNot(HaveOccurred())
@ -200,7 +209,7 @@ var _ = Describe("Worker", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
})).To(Succeed())
@ -208,11 +217,11 @@ var _ = Describe("Worker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(1))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
count, err := queueRepo.Count()
count, err := queueRepo.Count(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeZero(), "a found item must be deleted from the queue")
})
@ -223,7 +232,7 @@ var _ = Describe("Worker", func() {
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump,
})).To(Succeed())
@ -231,12 +240,12 @@ var _ = Describe("Worker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(1))
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("embedded"))
Expect(ia.Hash).ToNot(BeEmpty())
art, err := artRepo.GetImage(ia.Hash)
art, err := artRepo.GetImage(ctx, ia.Hash)
Expect(err).ToNot(HaveOccurred())
r, err := store.Open(ia.Hash, art.Mime)
Expect(err).ToNot(HaveOccurred())
@ -245,7 +254,7 @@ var _ = Describe("Worker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store")
count, err := queueRepo.Count()
count, err := queueRepo.Count(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeZero())
})
@ -254,7 +263,7 @@ var _ = Describe("Worker", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
n, err := w.drain(ctx, 2)
Expect(err).ToNot(HaveOccurred())
@ -265,7 +274,7 @@ var _ = Describe("Worker", func() {
Expect(it.Attempts).To(Equal(1))
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
})
@ -275,7 +284,7 @@ var _ = Describe("Worker", func() {
// Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at.
const askedFor = 90 * time.Minute
imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
n, err := w.drain(ctx, 2)
Expect(err).ToNot(HaveOccurred())
@ -296,7 +305,7 @@ var _ = Describe("Worker", func() {
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
n, err := w.drain(ctx, 2)
Expect(err).ToNot(HaveOccurred())
@ -307,7 +316,7 @@ var _ = Describe("Worker", func() {
Expect(it.Attempts).To(Equal(1))
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
@ -327,7 +336,7 @@ var _ = Describe("Worker", func() {
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
ds.MockedArtworkQueue = racing
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan,
})).To(Succeed())
@ -337,7 +346,7 @@ var _ = Describe("Worker", func() {
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil())
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
})
@ -349,7 +358,7 @@ var _ = Describe("Worker", func() {
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
ds.MockedArtworkQueue = racing
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
dequeued := findQueued(queueRepo, "al", "al8").RetryAt
n, err := w.drain(ctx, 1)
@ -368,7 +377,7 @@ var _ = Describe("Worker", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
// Age the row past the retry budget.
expireQueued(queueRepo, "al9")
@ -377,7 +386,7 @@ var _ = Describe("Worker", func() {
Expect(n).To(Equal(1))
Expect(findQueued(queueRepo, "al", "al9")).To(BeNil())
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(BeEmpty())
})
@ -385,13 +394,13 @@ var _ = Describe("Worker", func() {
It("keeps already-served art when the retry budget is exhausted", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}})
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary,
Hash: "cafebabe", Source: "external:lastfm",
})).To(Succeed())
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
expireQueued(queueRepo, "al10")
n, err := w.drain(ctx, 1)
@ -399,7 +408,7 @@ var _ = Describe("Worker", func() {
Expect(n).To(Equal(1))
Expect(findQueued(queueRepo, "al", "al10")).To(BeNil())
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al10", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al10", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
})
@ -409,7 +418,7 @@ var _ = Describe("Worker", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
_, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
@ -430,13 +439,13 @@ var _ = Describe("Worker", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
expireQueued(queueRepo, "al13")
_, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to")
Expect(ia.Hash).To(BeEmpty())
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
@ -445,20 +454,20 @@ var _ = Describe("Worker", func() {
It("keeps the failure on the state row after the queue row is deleted", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}})
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
Hash: "cafebabe", Source: "external:lastfm",
})).To(Succeed())
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
expireQueued(queueRepo, "al12")
_, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
Expect(findQueued(queueRepo, "al", "al12")).To(BeNil())
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
"the queue row is gone, so this is the only remaining record of the failure")
@ -473,7 +482,7 @@ var _ = Describe("Worker", func() {
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true},
})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
expireQueued(queueRepo, "mfX")
n, err := w.drain(ctx, 1)
@ -481,7 +490,7 @@ var _ = Describe("Worker", func() {
Expect(n).To(Equal(1))
Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying")
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound),
"no row leaves the track unresolved, so a later view can still recover it")
// Known gap: with no row and no absent settle, there is nowhere to keep the failure.
@ -496,14 +505,14 @@ var _ = Describe("Worker", func() {
tracks: &tests.MockPlaylistTrackRepo{},
}
w = NewWorker(vds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
n, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(1))
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(BeEmpty())
})
@ -523,9 +532,9 @@ var _ = Describe("Worker", func() {
{ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}},
{ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}},
})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed())
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed())
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed())
n, err := w.drain(ctx, 3)
Expect(err).ToNot(HaveOccurred())
@ -572,7 +581,7 @@ var _ = Describe("Worker", func() {
conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}})
folderRepo.result = nil
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed())
n, err := w.drain(ctx, 2)
Expect(err).ToNot(HaveOccurred())
@ -582,7 +591,7 @@ var _ = Describe("Worker", func() {
Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it")
Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3"))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found")
})
@ -591,7 +600,7 @@ var _ = Describe("Worker", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
n, err := w.drain(ctx, 2)
Expect(err).ToNot(HaveOccurred())
@ -725,7 +734,7 @@ var _ = Describe("Worker", func() {
{ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}},
})
conf.Server.UICoverArtSize = 300
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan,
})).To(Succeed())
})
@ -813,11 +822,11 @@ var _ = Describe("Worker", func() {
// Artists first, exactly as Backfill orders them.
for _, a := range artists {
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill,
})).To(Succeed())
}
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill,
})).To(Succeed())
@ -832,12 +841,12 @@ var _ = Describe("Worker", func() {
})
Eventually(func() bool {
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
return err == nil && ia.Hash != ""
}, 5*time.Second, 50*time.Millisecond).Should(BeTrue(),
"a blocked external pool must not hold up local artwork")
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "arx0", model.ImageTypePrimary)
_, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "arx0", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended")
})
})
@ -849,7 +858,7 @@ var _ = Describe("Worker", func() {
for i := range 8 {
id := fmt.Sprintf("alc%d", i)
albums = append(albums, model.Album{ID: id, Name: "Album"})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
})).To(Succeed())
}
@ -875,21 +884,21 @@ var _ = Describe("Worker", func() {
for i := range 8 {
id := fmt.Sprintf("alp%d", i)
albums = append(albums, model.Album{ID: id, Name: "Album", FolderIDs: []string{"f1"}})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
})).To(Succeed())
}
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums)
// Pauses as soon as the first item has left the queue.
w.PauseWhile(func() bool {
n, _ := queueRepo.Count()
n, _ := queueRepo.Count(ctx)
return n < 8
})
_, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
count, err := queueRepo.Count()
count, err := queueRepo.Count(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue")
})
@ -897,7 +906,7 @@ var _ = Describe("Worker", func() {
It("dequeues past the worker pool so one drain covers many items", func() {
for i := range 16 {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan,
})).To(Succeed())
}
@ -932,7 +941,7 @@ var _ = Describe("Worker", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
})).To(Succeed())
w.PauseWhile(func() bool { return true })

View file

@ -48,7 +48,7 @@ func Init(ds model.DataStore) {
}
func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string {
secret, err := ds.Property(ctx).Get(key)
secret, err := ds.Property().Get(ctx, key)
if err != nil || secret == "" {
log.Info(ctx, "Creating new JWT secret", "key", key)
return createNewSecret(ctx, ds, key)
@ -154,9 +154,9 @@ func CheckClaims(c Claims, usr model.User, audience string) error {
}
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
u, err := ds.User(ctx).FindFirstAdmin()
u, err := ds.User().FindFirstAdmin(ctx)
if err != nil {
c, err := ds.User(ctx).CountAll()
c, err := ds.User().CountAll(ctx)
if c == 0 && err == nil {
log.Debug(ctx, "No admin user yet!", err)
} else {
@ -176,7 +176,7 @@ func createNewSecret(ctx context.Context, ds model.DataStore, key string) string
log.Error(ctx, "Could not encrypt JWT secret", err)
return secret
}
if err := ds.Property(ctx).Put(key, encSecret); err != nil {
if err := ds.Property().Put(ctx, key, encSecret); err != nil {
log.Error(ctx, "Could not save JWT secret in DB", err)
}
return secret

View file

@ -19,7 +19,7 @@ func userName(ctx context.Context) string {
// BFR We should only access files through the `storage.Storage` interface. This will require changing how
// TagLib and ffmpeg access files
var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string {
libPath, err := ds.Library(ctx).GetPath(libId)
libPath, err := ds.Library().GetPath(ctx, libId)
if err != nil {
return path
}

View file

@ -31,7 +31,7 @@ func (m *mockArtistRepo) SetData(artists model.Artists) {
}
// Get implements model.ArtistRepository.
func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
func (m *mockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
}
// GetAll implements model.ArtistRepository.
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) {
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
@ -85,7 +85,7 @@ func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) {
}
// Get implements model.MediaFileRepository.
func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
func (m *mockMediaFileRepo) Get(ctx context.Context, id string) (*model.MediaFile, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
@ -94,12 +94,12 @@ func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
}
// GetAllByTags implements model.MediaFileRepository.
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
return m.GetAll(options...)
func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
return m.GetAll(ctx, options...)
}
// GetAll implements model.MediaFileRepository.
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
@ -112,7 +112,7 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi
}
// GetRandom implements model.MediaFileRepository.
func (m *mockMediaFileRepo) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
func (m *mockMediaFileRepo) GetRandom(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
@ -156,7 +156,7 @@ func newMockAlbumRepo() *mockAlbumRepo {
}
// Get implements model.AlbumRepository.
func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
func (m *mockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
@ -165,7 +165,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
}
// GetAll implements model.AlbumRepository.
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
func (m *mockAlbumRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Albums, error) {
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v

View file

@ -182,7 +182,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
}
}
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
err = e.ds.Album().UpdateExternalInfo(ctx, &album.Album)
if err != nil {
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
"elapsed", time.Since(start), err)
@ -285,7 +285,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
if !throttled {
artist.ExternalInfoUpdatedAt = new(time.Now())
}
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
err := e.ds.Artist().UpdateExternalInfo(ctx, &artist.Artist)
if err != nil {
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
"elapsed", time.Since(start), err)
@ -548,7 +548,7 @@ func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist)
if len(ids) == 0 {
return matches, nil
}
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
Filters: squirrel.Eq{"artist.id": ids},
})
if err != nil {
@ -577,7 +577,7 @@ func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artis
if len(mbids) == 0 {
return matches, nil
}
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
Filters: squirrel.Eq{"mbz_artist_id": mbids},
})
if err != nil {
@ -612,7 +612,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis
clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
return squirrel.Like{"artist.name": name}
})
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
Filters: squirrel.Or(clauses),
})
if err != nil {
@ -628,7 +628,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis
func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) {
if id != "" {
artist, err := e.ds.Artist(ctx).Get(id)
artist, err := e.ds.Artist().Get(ctx, id)
if err == nil {
return &auxArtist{Artist: *artist}, nil
}
@ -644,7 +644,7 @@ func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxA
return nil, model.ErrNotFound
}
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
artists, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
Filters: squirrel.Like{"artist.name": artistName},
Max: 1,
})
@ -666,7 +666,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
ids = append(ids, sa.ID)
}
similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
similar, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
Filters: squirrel.Eq{"artist.id": ids},
})
if err != nil {

View file

@ -71,8 +71,8 @@ var _ = Describe("Provider - RefreshInfo", func() {
ag = new(mockAgents)
broker = &fakeBroker{}
p = external.NewProvider(ds, ag, matcher.New(ds), broker)
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
mockArtistRepo = ds.Artist().(*tests.MockArtistRepo)
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)
})
It("repopulates an artist even when its info is fresh", func() {
@ -84,7 +84,7 @@ var _ = Describe("Provider - RefreshInfo", func() {
Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed())
saved, err := mockArtistRepo.Get("ar-1")
saved, err := mockArtistRepo.Get(ctx, "ar-1")
Expect(err).ToNot(HaveOccurred())
Expect(saved.Biography).To(Equal("Fresh Bio"))
})
@ -99,7 +99,7 @@ var _ = Describe("Provider - RefreshInfo", func() {
Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed())
saved, err := mockAlbumRepo.Get("al-1")
saved, err := mockAlbumRepo.Get(ctx, "al-1")
Expect(err).ToNot(HaveOccurred())
Expect(saved.Description).To(Equal("Fresh Notes"))
})

View file

@ -37,7 +37,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
if !errors.Is(err, model.ErrNotFound) {
return nil, err
}
genre, err := e.ds.Genre(ctx).Get(id)
genre, err := e.ds.Genre().Get(ctx, id)
if err != nil {
return nil, err
}
@ -178,13 +178,13 @@ func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.
func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) {
// Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would
// mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay.
repo := e.ds.Playlist(ctx).Tracks(playlistID, true)
repo := e.ds.Playlist().Tracks(ctx, playlistID, true)
if repo == nil {
return nil, model.ErrNotFound
}
// A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated
// seed wastes an agent call and can reach the mix twice through the seed fallback.
tracks, err := repo.GetAll(model.QueryOptions{
tracks, err := repo.GetAll(ctx, model.QueryOptions{
Sort: "random",
Max: n * 4,
Filters: squirrel.Eq{"missing": false},
@ -225,7 +225,7 @@ func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n
// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so
// missing files would surface as unplayable entries.
func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) {
return e.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
return e.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
Filters: squirrel.And{filter, squirrel.Eq{"missing": false}},
Max: n,
})

View file

@ -35,7 +35,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ds = new(tests.MockDataStore)
ag = new(mockAgents)
p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
})

View file

@ -38,7 +38,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ds = new(tests.MockDataStore)
ag = new(mockAgents)
p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
mockArtistRepo = ds.Artist().(*tests.MockArtistRepo)
})
It("returns error when artist is not found", func() {

View file

@ -32,25 +32,28 @@ type Library interface {
SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error
ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error
NewRepository(ctx context.Context) rest.Repository
Repository() rest.Repository[model.Library]
}
type libraryService struct {
ds model.DataStore
scanner model.Scanner
watcher Watcher
broker events.Broker
pluginManager PluginUnloader
ds model.DataStore
broker events.Broker
repo *libraryRepositoryWrapper
}
// NewLibrary creates a new Library service
func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library {
return &libraryService{
ds: ds,
scanner: scanner,
watcher: watcher,
broker: broker,
pluginManager: pluginManager,
ds: ds,
broker: broker,
repo: &libraryRepositoryWrapper{
LibraryRepository: ds.Library(),
ds: ds,
scanner: scanner,
watcher: watcher,
broker: broker,
pluginManager: pluginManager,
},
}
}
@ -58,16 +61,16 @@ func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, brok
func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
// Verify user exists
if _, err := s.ds.User(ctx).Get(userID); err != nil {
if _, err := s.ds.User().Get(ctx, userID); err != nil {
return nil, err
}
return s.ds.User(ctx).GetUserLibraries(userID)
return s.ds.User().GetUserLibraries(ctx, userID)
}
func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
// Verify user exists
user, err := s.ds.User(ctx).Get(userID)
user, err := s.ds.User().Get(ctx, userID)
if err != nil {
return err
}
@ -90,7 +93,7 @@ func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, li
}
// Set user libraries
err = s.ds.User(ctx).SetUserLibraries(userID, libraryIDs)
err = s.ds.User().SetUserLibraries(ctx, userID, libraryIDs)
if err != nil {
return fmt.Errorf("error setting user libraries: %w", err)
}
@ -115,7 +118,7 @@ func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID strin
}
// Check if user has explicit access to this library
libraries, err := s.ds.User(ctx).GetUserLibraries(userID)
libraries, err := s.ds.User().GetUserLibraries(ctx, userID)
if err != nil {
log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err)
return fmt.Errorf("error checking library access: %w", err)
@ -132,25 +135,14 @@ func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID strin
// REST repository wrapper
func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
repo := s.ds.Library(ctx)
wrapper := &libraryRepositoryWrapper{
ctx: ctx,
LibraryRepository: repo,
Repository: repo.(rest.Repository),
ds: s.ds,
scanner: s.scanner,
watcher: s.watcher,
broker: s.broker,
pluginManager: s.pluginManager,
}
return wrapper
func (s *libraryService) Repository() rest.Repository[model.Library] {
return s.repo
}
var _ rest.Persistable[model.Library] = (*libraryRepositoryWrapper)(nil)
type libraryRepositoryWrapper struct {
rest.Repository
model.LibraryRepository
ctx context.Context
ds model.DataStore
scanner model.Scanner
watcher Watcher
@ -158,59 +150,58 @@ type libraryRepositoryWrapper struct {
pluginManager PluginUnloader
}
func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
lib := entity.(*model.Library)
if err := r.validateLibrary(lib); err != nil {
func (r *libraryRepositoryWrapper) Save(ctx context.Context, lib *model.Library) (string, error) {
if err := r.validateLibrary(ctx, lib); err != nil {
return "", err
}
err := r.LibraryRepository.Put(lib)
err := r.LibraryRepository.Put(ctx, lib)
if err != nil {
return "", r.mapError(err)
}
// Start watcher and trigger scan after successful library creation
if r.watcher != nil {
if err := r.watcher.Watch(r.ctx, lib); err != nil {
log.Warn(r.ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
if err := r.watcher.Watch(ctx, lib); err != nil {
log.Warn(ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
}
if r.scanner != nil {
go r.triggerScan(lib, "new")
go r.triggerScan(ctx, lib, "new")
}
// Send library refresh event to all clients
if r.broker != nil {
event := &events.RefreshResource{}
r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID)))
log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name)
r.broker.SendBroadcastMessage(ctx, event.With("library", strconv.Itoa(lib.ID)))
log.Debug(ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name)
}
return strconv.Itoa(lib.ID), nil
}
func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string) error {
lib := entity.(*model.Library)
func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity model.Library, cols ...string) error {
lib := &entity
libID, err := strconv.Atoi(id)
if err != nil {
return fmt.Errorf("invalid library ID: %s", id)
}
lib.ID = libID
if err := r.validateLibrary(lib); err != nil {
if err := r.validateLibrary(ctx, lib); err != nil {
return err
}
// Get the original library to check if path changed
originalLib, err := r.Get(libID)
originalLib, err := r.Get(ctx, libID)
if err != nil {
return r.mapError(err)
}
pathChanged := originalLib.Path != lib.Path
err = r.LibraryRepository.Put(lib, cols...)
err = r.LibraryRepository.Put(ctx, lib, cols...)
if err != nil {
return r.mapError(err)
}
@ -218,27 +209,36 @@ func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string)
// Restart watcher and trigger scan if path was updated
if pathChanged {
if r.watcher != nil {
if err := r.watcher.Watch(r.ctx, lib); err != nil {
log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
if err := r.watcher.Watch(ctx, lib); err != nil {
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
}
if r.scanner != nil {
go r.triggerScan(lib, "updated")
go r.triggerScan(ctx, lib, "updated")
}
}
// Send library refresh event to all clients
if r.broker != nil {
event := &events.RefreshResource{}
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name)
r.broker.SendBroadcastMessage(ctx, event.With("library", id))
log.Debug(ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name)
}
return nil
}
func (r *libraryRepositoryWrapper) Delete(id string) error {
func (r *libraryRepositoryWrapper) Delete(ctx context.Context, ids ...string) error {
for _, id := range ids {
if err := r.deleteOne(ctx, id); err != nil {
return err
}
}
return nil
}
func (r *libraryRepositoryWrapper) deleteOne(ctx context.Context, id string) error {
libID, err := strconv.Atoi(id)
if err != nil {
return &rest.ValidationError{Errors: map[string]string{
@ -247,7 +247,7 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
}
// Get library info before deletion for logging
lib, err := r.Get(libID)
lib, err := r.Get(ctx, libID)
if err != nil {
return r.mapError(err)
}
@ -255,7 +255,7 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
// Run the deletion in a transaction so the cascade delete and the orphaned-artist
// reconciliation it triggers (see libraryRepository.Delete) commit atomically.
err = r.ds.WithTx(func(tx model.DataStore) error {
return tx.Library(r.ctx).Delete(libID)
return tx.Library().Delete(ctx, libID)
}, "delete library")
if err != nil {
return r.mapError(err)
@ -263,25 +263,25 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
// Stop watcher and trigger scan after successful library deletion to clean up orphaned data
if r.watcher != nil {
if err := r.watcher.StopWatching(r.ctx, libID); err != nil {
log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err)
if err := r.watcher.StopWatching(ctx, libID); err != nil {
log.Warn(ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err)
}
}
if r.scanner != nil {
go r.triggerScan(lib, "deleted")
go r.triggerScan(ctx, lib, "deleted")
}
// Send library refresh event to all clients
if r.broker != nil {
event := &events.RefreshResource{}
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
r.broker.SendBroadcastMessage(ctx, event.With("library", id))
log.Debug(ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
}
// After successful deletion, check if any plugins were auto-disabled
// and need to be unloaded from memory
r.pluginManager.UnloadDisabledPlugins(r.ctx)
r.pluginManager.UnloadDisabledPlugins(ctx)
return nil
}
@ -309,7 +309,7 @@ func (r *libraryRepositoryWrapper) mapError(err error) error {
return err
}
func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error {
func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library *model.Library) error {
validationErrors := make(map[string]string)
if library.Name == "" {
@ -320,7 +320,7 @@ func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error
validationErrors["path"] = "ra.validation.required"
} else {
// Validate path format and accessibility
if err := r.validateLibraryPath(library); err != nil {
if err := r.validateLibraryPath(ctx, library); err != nil {
validationErrors["path"] = err.Error()
}
}
@ -332,7 +332,7 @@ func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error
return nil
}
func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) error {
func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error {
// Validate path format
if !filepath.IsAbs(library.Path) {
return fmt.Errorf("library path must be absolute")
@ -350,7 +350,7 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) e
fsys, err := fileStore.FS()
if err != nil {
log.Warn(r.ctx, "Error validating library.path", "path", library.Path, err)
log.Warn(ctx, "Error validating library.path", "path", library.Path, err)
return fmt.Errorf("resources.library.validation.pathInvalid")
}
@ -358,7 +358,7 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) e
info, err := fs.Stat(fsys, ".")
if err != nil {
// Parse the error message to check for "not a directory"
log.Warn(r.ctx, "Error stating library.path", "path", library.Path, err)
log.Warn(ctx, "Error stating library.path", "path", library.Path, err)
errStr := err.Error()
if strings.Contains(errStr, "not a directory") ||
strings.Contains(errStr, "The directory name is invalid.") {
@ -385,7 +385,7 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
}
// Use CountAll to efficiently validate library IDs exist
count, err := s.ds.Library(ctx).CountAll(model.QueryOptions{
count, err := s.ds.Library().CountAll(ctx, model.QueryOptions{
Filters: squirrel.Eq{"id": libraryIDs},
})
if err != nil {
@ -399,13 +399,13 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
return nil
}
func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) {
log.Info(r.ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) {
log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
start := time.Now()
warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library
warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library
if err != nil {
log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
} else {
log.Info(r.ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
}
}

View file

@ -66,18 +66,18 @@ var _ = Describe("Library Service", func() {
})
Describe("Library CRUD Operations", func() {
var repo rest.Persistable
var repo rest.Persistable[model.Library]
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
r := service.Repository()
repo = r.(rest.Persistable[model.Library])
})
Describe("Create", func() {
It("creates a new library successfully", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data[1].Name).To(Equal("New Library"))
@ -87,7 +87,7 @@ var _ = Describe("Library Service", func() {
It("fails when library name is empty", func() {
library := &model.Library{Path: tempDir}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
@ -96,7 +96,7 @@ var _ = Describe("Library Service", func() {
It("fails when library path is empty", func() {
library := &model.Library{Name: "Test"}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
@ -105,7 +105,7 @@ var _ = Describe("Library Service", func() {
It("fails when library path is not absolute", func() {
library := &model.Library{Name: "Test", Path: "relative/path"}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -140,7 +140,7 @@ var _ = Describe("Library Service", func() {
return errors.New("UNIQUE constraint failed: library.name")
}
_, err = repo.Save(library)
_, err = repo.Save(ctx, library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -157,7 +157,7 @@ var _ = Describe("Library Service", func() {
return errors.New("UNIQUE constraint failed: library.path")
}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -181,7 +181,7 @@ var _ = Describe("Library Service", func() {
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
err = repo.Update("1", library)
err = repo.Update(ctx, "1", *library)
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library"))
@ -191,7 +191,7 @@ var _ = Describe("Library Service", func() {
It("forwards the columns sent by the client to the repository", func() {
library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}
err := repo.Update("1", library, "name", "path")
err := repo.Update(ctx, "1", *library, "name", "path")
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"}))
@ -205,7 +205,7 @@ var _ = Describe("Library Service", func() {
library := &model.Library{ID: 999, Name: "Non-existent", Path: uniqueTempDir}
err = repo.Update("999", library)
err = repo.Update(ctx, "999", *library)
Expect(err).To(HaveOccurred())
Expect(err).To(Equal(model.ErrNotFound))
@ -214,7 +214,7 @@ var _ = Describe("Library Service", func() {
It("fails when library name is empty", func() {
library := &model.Library{ID: 1, Path: tempDir}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
@ -224,7 +224,7 @@ var _ = Describe("Library Service", func() {
unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir)
library := &model.Library{ID: 1, Name: "Updated Library", Path: unnormalizedPath}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data[1].Path).To(Equal(filepath.Clean(unnormalizedPath)))
@ -239,7 +239,7 @@ var _ = Describe("Library Service", func() {
// Update the library keeping the same name (should be allowed)
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).NotTo(HaveOccurred())
})
@ -253,7 +253,7 @@ var _ = Describe("Library Service", func() {
// Update the library keeping the same path (should be allowed)
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).NotTo(HaveOccurred())
})
@ -284,7 +284,7 @@ var _ = Describe("Library Service", func() {
// Try to update library 2 to have the same name as library 1
library := &model.Library{ID: 2, Name: "Library One", Path: otherTempDir}
err = repo.Update("2", library)
err = repo.Update(ctx, "2", *library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -312,7 +312,7 @@ var _ = Describe("Library Service", func() {
// Try to update library 2 to have the same path as library 1
library := &model.Library{ID: 2, Name: "Library Two", Path: tempDir}
err = repo.Update("2", library)
err = repo.Update(ctx, "2", *library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -327,7 +327,7 @@ var _ = Describe("Library Service", func() {
It("fails when path is not absolute", func() {
library := &model.Library{Name: "Test", Path: "relative/path"}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -339,7 +339,7 @@ var _ = Describe("Library Service", func() {
nonExistentPath := filepath.Join(tempDir, "nonexistent")
library := &model.Library{Name: "Test", Path: nonExistentPath}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -354,7 +354,7 @@ var _ = Describe("Library Service", func() {
library := &model.Library{Name: "Test", Path: testFile}
_, err = repo.Save(library)
_, err = repo.Save(ctx, library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -371,7 +371,7 @@ var _ = Describe("Library Service", func() {
It("handles multiple validation errors", func() {
library := &model.Library{Name: "", Path: "relative/path"}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -393,7 +393,7 @@ var _ = Describe("Library Service", func() {
It("fails when updated path is not absolute", func() {
library := &model.Library{ID: 1, Name: "Test", Path: "relative/path"}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -410,7 +410,7 @@ var _ = Describe("Library Service", func() {
// Update the library keeping the same name (should be allowed)
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).NotTo(HaveOccurred())
})
@ -419,7 +419,7 @@ var _ = Describe("Library Service", func() {
nonExistentPath := filepath.Join(tempDir, "nonexistent")
library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -434,7 +434,7 @@ var _ = Describe("Library Service", func() {
library := &model.Library{ID: 1, Name: "Test", Path: testFile}
err = repo.Update("1", library)
err = repo.Update(ctx, "1", *library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -446,7 +446,7 @@ var _ = Describe("Library Service", func() {
// Try to update with empty name and invalid path
library := &model.Library{ID: 1, Name: "", Path: "relative/path"}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
@ -467,14 +467,14 @@ var _ = Describe("Library Service", func() {
})
It("deletes an existing library successfully", func() {
err := repo.Delete("1")
err := repo.Delete(ctx, "1")
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data).To(HaveLen(0))
})
It("fails when library doesn't exist", func() {
err := repo.Delete("999")
err := repo.Delete(ctx, "999")
Expect(err).To(HaveOccurred())
Expect(err).To(Equal(model.ErrNotFound))
@ -613,17 +613,17 @@ var _ = Describe("Library Service", func() {
})
Describe("Scan Triggering", func() {
var repo rest.Persistable
var repo rest.Persistable[model.Library]
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
r := service.Repository()
repo = r.(rest.Persistable[model.Library])
})
It("triggers scan when creating a new library", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).NotTo(HaveOccurred())
// Wait briefly for the goroutine to complete
@ -649,7 +649,7 @@ var _ = Describe("Library Service", func() {
// Update the library with a new path
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
err = repo.Update("1", library)
err = repo.Update(ctx, "1", *library)
Expect(err).NotTo(HaveOccurred())
// Wait briefly for the goroutine to complete
@ -670,7 +670,7 @@ var _ = Describe("Library Service", func() {
// Update the library name only (same path)
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).NotTo(HaveOccurred())
// Wait a bit to ensure no scan was triggered
@ -683,7 +683,7 @@ var _ = Describe("Library Service", func() {
// Try to create library with invalid data (empty name)
library := &model.Library{Path: tempDir}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).To(HaveOccurred())
// Ensure no scan was triggered since creation failed
@ -700,7 +700,7 @@ var _ = Describe("Library Service", func() {
// Try to update with invalid data (empty name)
library := &model.Library{ID: 1, Name: "", Path: tempDir}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).To(HaveOccurred())
// Ensure no scan was triggered since update failed
@ -716,7 +716,7 @@ var _ = Describe("Library Service", func() {
})
// Delete the library
err := repo.Delete("1")
err := repo.Delete(ctx, "1")
Expect(err).NotTo(HaveOccurred())
// Wait briefly for the goroutine to complete
@ -731,7 +731,7 @@ var _ = Describe("Library Service", func() {
It("does not trigger scan when library deletion fails", func() {
// Try to delete a non-existent library
err := repo.Delete("999")
err := repo.Delete(ctx, "999")
Expect(err).To(HaveOccurred())
// Ensure no scan was triggered since deletion failed
@ -744,7 +744,7 @@ var _ = Describe("Library Service", func() {
It("starts watcher when creating a new library", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).NotTo(HaveOccurred())
// Verify watcher was started
@ -773,7 +773,7 @@ var _ = Describe("Library Service", func() {
// Update library with new path
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
err = repo.Update("1", library)
err = repo.Update(ctx, "1", *library)
Expect(err).NotTo(HaveOccurred())
// Verify watcher was restarted
@ -793,7 +793,7 @@ var _ = Describe("Library Service", func() {
// Update library with same path but different name
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).NotTo(HaveOccurred())
// Verify watcher was NOT restarted (since path didn't change)
@ -808,7 +808,7 @@ var _ = Describe("Library Service", func() {
{ID: 1, Name: "Test Library", Path: tempDir},
})
err := repo.Delete("1")
err := repo.Delete(ctx, "1")
Expect(err).NotTo(HaveOccurred())
// Verify watcher was stopped
@ -826,7 +826,7 @@ var _ = Describe("Library Service", func() {
})
// Mock deletion to fail by trying to delete non-existent library
err := repo.Delete("999")
err := repo.Delete(ctx, "999")
Expect(err).To(HaveOccurred())
// Verify watcher was NOT stopped since deletion failed
@ -838,11 +838,11 @@ var _ = Describe("Library Service", func() {
})
Describe("Event Broadcasting", func() {
var repo rest.Persistable
var repo rest.Persistable[model.Library]
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
r := service.Repository()
repo = r.(rest.Persistable[model.Library])
// Clear any events from broker
broker.Events = []events.Event{}
})
@ -850,7 +850,7 @@ var _ = Describe("Library Service", func() {
It("sends refresh event when creating a library", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
_, err := repo.Save(library)
_, err := repo.Save(ctx, library)
Expect(err).NotTo(HaveOccurred())
Expect(broker.Events).To(HaveLen(1))
@ -863,7 +863,7 @@ var _ = Describe("Library Service", func() {
})
library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}
err := repo.Update("1", library)
err := repo.Update(ctx, "1", *library)
Expect(err).NotTo(HaveOccurred())
Expect(broker.Events).To(HaveLen(1))
@ -875,7 +875,7 @@ var _ = Describe("Library Service", func() {
{ID: 2, Name: "Library to Delete", Path: tempDir},
})
err := repo.Delete("2")
err := repo.Delete(ctx, "2")
Expect(err).NotTo(HaveOccurred())
Expect(broker.Events).To(HaveLen(1))
@ -883,13 +883,13 @@ var _ = Describe("Library Service", func() {
})
Describe("Plugin Manager Integration", func() {
var repo rest.Persistable
var repo rest.Persistable[model.Library]
BeforeEach(func() {
// Reset the call count for each test
pluginManager.unloadCalls = 0
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
r := service.Repository()
repo = r.(rest.Persistable[model.Library])
})
It("calls UnloadDisabledPlugins after successful library deletion", func() {
@ -897,14 +897,14 @@ var _ = Describe("Library Service", func() {
{ID: 2, Name: "Library to Delete", Path: tempDir},
})
err := repo.Delete("2")
err := repo.Delete(ctx, "2")
Expect(err).NotTo(HaveOccurred())
Expect(pluginManager.unloadCalls).To(Equal(1))
})
It("does not call UnloadDisabledPlugins when library deletion fails", func() {
// Try to delete non-existent library
err := repo.Delete("999")
err := repo.Delete(ctx, "999")
Expect(err).To(HaveOccurred())
Expect(pluginManager.unloadCalls).To(Equal(0))
})

View file

@ -57,7 +57,7 @@ func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (mod
func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) {
opts := songsByArtistTitleWithLyricsFirst(artist, title)
opts.Max = maxLegacyLyricsCandidates
mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts)
mediaFiles, err := l.ds.MediaFile().GetAll(ctx, opts)
if err != nil {
return nil, err
}

View file

@ -58,7 +58,7 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta
return fmt.Errorf("%w: %q", ErrSameFile, missingID)
}
missing, err := s.ds.MediaFile(ctx).Get(missingID)
missing, err := s.ds.MediaFile().Get(ctx, missingID)
if err != nil {
return fmt.Errorf("loading missing file %q: %w", missingID, err)
}
@ -66,7 +66,7 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta
return fmt.Errorf("%w: %q", ErrNotMissing, missingID)
}
target, err := s.ds.MediaFile(ctx).GetWithParticipants(targetID)
target, err := s.ds.MediaFile().GetWithParticipants(ctx, targetID)
if err != nil {
return fmt.Errorf("loading target file %q: %w", targetID, err)
}
@ -82,27 +82,27 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta
// Preserve the original created_at so the remapped track doesn't resurface in "Recently Added"
target.CreatedAt = missing.CreatedAt
target.ID = missing.ID
if err := tx.MediaFile(ctx).Put(target); err != nil {
if err := tx.MediaFile().Put(ctx, target); err != nil {
return fmt.Errorf("update matched track: %w", err)
}
// Unlike the scanner's freshly-imported target, this one may carry history of its own
if err := tx.MediaFile(ctx).ReassignReferences(discardedID, missing.ID); err != nil {
if err := tx.MediaFile().ReassignReferences(ctx, discardedID, missing.ID); err != nil {
return fmt.Errorf("reassign target references: %w", err)
}
if err := tx.MediaFile(ctx).Delete(discardedID); err != nil {
if err := tx.MediaFile().Delete(ctx, discardedID); err != nil {
return fmt.Errorf("delete discarded track: %w", err)
}
if oldAlbumID != newAlbumID {
oldAlbumTracks, err := tx.MediaFile(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}})
oldAlbumTracks, err := tx.MediaFile().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}})
if err != nil {
return fmt.Errorf("get old album tracks: %w", err)
}
if oldAlbumTracks == 0 {
if err := tx.Album(ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil {
if err := tx.Album().ReassignAnnotation(ctx, oldAlbumID, newAlbumID); err != nil {
return fmt.Errorf("reassign album annotations: %w", err)
}
if err := tx.Album(ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) {
if err := tx.Album().CopyAttributes(ctx, oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("copy album attributes: %w", err)
}
}
@ -121,7 +121,7 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta
// Stats are refreshed synchronously, unlike deleteMissing, so the CLI sees them before it exits.
// album/artist play count aggregates are not recalculated here; they are refreshed by the next scan.
if _, err := s.ds.Artist(ctx).RefreshStats(true); err != nil {
if _, err := s.ds.Artist().RefreshStats(ctx, true); err != nil {
log.Error(ctx, "Error refreshing artist stats after remapping missing file", err)
}
affectedAlbumIDs := []string{newAlbumID}
@ -146,10 +146,10 @@ func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) er
// Delete missing files within a transaction
err = s.ds.WithTx(func(tx model.DataStore) error {
if len(ids) == 0 {
_, err := tx.MediaFile(ctx).DeleteAllMissing()
_, err := tx.MediaFile().DeleteAllMissing(ctx)
return err
}
return tx.MediaFile(ctx).DeleteMissing(ids)
return tx.MediaFile().DeleteMissing(ctx, ids)
})
if err != nil {
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
@ -192,11 +192,11 @@ func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []strin
// refreshAlbumChunk processes a single chunk of album IDs
func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
albumRepo := s.ds.Album(ctx)
mfRepo := s.ds.MediaFile(ctx)
albumRepo := s.ds.Album()
mfRepo := s.ds.MediaFile()
// Batch load existing albums
albums, err := albumRepo.GetAll(model.QueryOptions{
albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
Filters: squirrel.Eq{"album.id": albumIDs},
})
if err != nil {
@ -210,7 +210,7 @@ func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []s
}
// Batch load all media files for these albums
mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
mediaFiles, err := mfRepo.GetAll(ctx, model.QueryOptions{
Filters: squirrel.Eq{"album_id": albumIDs},
Sort: "album_id, path",
})
@ -243,7 +243,7 @@ func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []s
newAlbum.UpdatedAt = time.Now()
newAlbum.CreatedAt = oldAlbum.CreatedAt
if err := albumRepo.Put(&newAlbum); err != nil {
if err := albumRepo.Put(ctx, &newAlbum); err != nil {
log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
// Continue with other albums instead of failing entirely
continue
@ -265,7 +265,7 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri
}
}
mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{
mfs, err := s.ds.MediaFile().GetAll(ctx, model.QueryOptions{
Filters: filters,
})
if err != nil {
@ -293,7 +293,7 @@ func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbu
// Refresh artist stats in background
s.wg.Go(func() {
bgCtx := request.AddValues(context.Background(), ctx)
if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
if _, err := s.ds.Artist().RefreshStats(bgCtx, true); err != nil {
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
} else {
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")

View file

@ -262,12 +262,12 @@ var _ = Describe("Maintenance", func() {
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
got, err := mfRepo.Get("m1")
got, err := mfRepo.Get(ctx, "m1")
Expect(err).ToNot(HaveOccurred())
Expect(got.Path).To(Equal("new/song.mp3")) // moved to target's location
Expect(got.Missing).To(BeFalse())
Expect(got.CreatedAt).To(BeTemporally("==", created)) // created_at preserved
exists, _ := mfRepo.Exists("t1")
exists, _ := mfRepo.Exists(ctx, "t1")
Expect(exists).To(BeFalse()) // discarded row removed
Expect(ds.GCCalled).To(BeTrue())
})
@ -369,14 +369,14 @@ var _ = Describe("Maintenance", func() {
Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
// The old album lost the remapped track, so its stats are recalculated from the remaining one
oldAlbum, err := albumRepo.Get("album1")
oldAlbum, err := albumRepo.Get(ctx, "album1")
Expect(err).ToNot(HaveOccurred())
Expect(oldAlbum.SongCount).To(Equal(1))
Expect(oldAlbum.Size).To(Equal(int64(1000)))
Expect(oldAlbum.Duration).To(BeNumerically("==", 100))
// The target album keeps the track, now under the missing file's ID
newAlbum, err := albumRepo.Get("album2")
newAlbum, err := albumRepo.Get(ctx, "album2")
Expect(err).ToNot(HaveOccurred())
Expect(newAlbum.SongCount).To(Equal(1))
Expect(newAlbum.Size).To(Equal(int64(2000)))
@ -407,7 +407,7 @@ var _ = Describe("Maintenance", func() {
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
// The surviving row is the missing file's ID, holding the target's data
got, err := mfRepo.GetWithParticipants("m1")
got, err := mfRepo.GetWithParticipants(ctx, "m1")
Expect(err).ToNot(HaveOccurred())
Expect(got.Participants).To(HaveKeyWithValue(model.RoleArtist, model.ParticipantList{participant}))
})
@ -447,7 +447,7 @@ type extendedMediaFileRepo struct {
deleteMissingError error
}
func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error {
func (m *extendedMediaFileRepo) DeleteMissing(ctx context.Context, ids []string) error {
m.deleteMissingCalled = true
m.deletedIDs = ids
if m.deleteMissingError != nil {
@ -470,7 +470,7 @@ type extendedAlbumRepo struct {
failOnce bool
}
func (m *extendedAlbumRepo) Put(album *model.Album) error {
func (m *extendedAlbumRepo) Put(ctx context.Context, album *model.Album) error {
m.mu.Lock()
m.putCallCount++
m.lastPutData = album
@ -490,7 +490,7 @@ func (m *extendedAlbumRepo) Put(album *model.Album) error {
}
m.mu.Unlock()
return m.MockAlbumRepo.Put(album)
return m.MockAlbumRepo.Put(ctx, album)
}
func (m *extendedAlbumRepo) GetPutCallCount() int {
@ -507,7 +507,7 @@ type extendedArtistRepo struct {
refreshStatsError error
}
func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
func (m *extendedArtistRepo) RefreshStats(ctx context.Context, allArtists bool) (int64, error) {
m.mu.Lock()
m.refreshStatsCalled = true
err := m.refreshStatsError
@ -516,7 +516,7 @@ func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
if err != nil {
return 0, err
}
return m.MockArtistRepo.RefreshStats(allArtists)
return m.MockArtistRepo.RefreshStats(ctx, allArtists)
}
func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {

View file

@ -95,7 +95,7 @@ func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map
if len(ids) == 0 {
return nil
}
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"media_file.id": ids},
squirrel.Eq{"missing": false},
@ -134,7 +134,7 @@ func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result m
if len(mbids) == 0 {
return nil
}
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"mbz_recording_id": mbids},
squirrel.Eq{"missing": false},
@ -180,7 +180,7 @@ func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result m
if len(isrcs) == 0 {
return nil
}
res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
res, err := m.ds.MediaFile().GetAllByTags(ctx, model.TagISRC, isrcs, model.QueryOptions{
Filters: squirrel.Eq{"missing": false},
Sort: "starred desc, rating desc, year asc, compilation asc",
})
@ -442,7 +442,7 @@ func (m *Matcher) resolveArtists(ctx context.Context, queries []indexedQuery) (r
filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))})
}
if len(filter) > 0 {
artists, err := m.ds.Artist(ctx).GetAll(model.QueryOptions{Filters: filter})
artists, err := m.ds.Artist().GetAll(ctx, model.QueryOptions{Filters: filter})
if err != nil {
return resolvedArtists{}, err
}
@ -543,7 +543,7 @@ func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string)
return nil, nil
}
args := slice.Map(artistIDs, func(id string) any { return id })
return m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
return m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
Filters: squirrel.And{
squirrel.Expr(
"media_file.id IN (SELECT media_file_id FROM media_file_artists "+

View file

@ -1267,7 +1267,7 @@ func newMockMediaFileRepo() *mockMediaFileRepo {
return &mockMediaFileRepo{}
}
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
@ -1279,8 +1279,8 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi
return args.Get(0).(model.MediaFiles), args.Error(1)
}
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
return m.GetAll(options...)
func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
return m.GetAll(ctx, options...)
}
func (m *mockMediaFileRepo) SetError(hasError bool) {
@ -1298,7 +1298,7 @@ func newMockArtistRepo() *mockArtistRepo {
return &mockArtistRepo{}
}
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) {
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v

View file

@ -47,11 +47,11 @@ type insightsCollector struct {
func GetInstance(ds model.DataStore) Insights {
return singleton.GetInstance(func() *insightsCollector {
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
id, err := ds.Property().Get(context.TODO(), consts.InsightsIDKey)
if err != nil {
log.Trace("Could not get Insights ID from DB. Creating one", err)
id = uuid.NewString()
err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id)
err = ds.Property().Put(context.TODO(), consts.InsightsIDKey, id)
if err != nil {
log.Trace("Could not save Insights ID to DB", err)
}
@ -87,7 +87,7 @@ func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, succe
}
func (c *insightsCollector) sendInsights(ctx context.Context) {
count, err := c.ds.User(ctx).CountAll(model.QueryOptions{})
count, err := c.ds.User().CountAll(ctx, model.QueryOptions{})
if err != nil {
log.Trace(ctx, "Could not check user count", err)
return
@ -245,41 +245,41 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
// Library info
var err error
data.Library.Tracks, err = c.ds.MediaFile(ctx).CountAll()
data.Library.Tracks, err = c.ds.MediaFile().CountAll(ctx)
if err != nil {
log.Trace(ctx, "Error reading tracks count", err)
}
data.Library.Albums, err = c.ds.Album(ctx).CountAll()
data.Library.Albums, err = c.ds.Album().CountAll(ctx)
if err != nil {
log.Trace(ctx, "Error reading albums count", err)
}
data.Library.Artists, err = c.ds.Artist(ctx).CountAll()
data.Library.Artists, err = c.ds.Artist().CountAll(ctx)
if err != nil {
log.Trace(ctx, "Error reading artists count", err)
}
data.Library.Playlists, err = c.ds.Playlist(ctx).CountAll()
data.Library.Playlists, err = c.ds.Playlist().CountAll(ctx)
if err != nil {
log.Trace(ctx, "Error reading playlists count", err)
}
data.Library.Shares, err = c.ds.Share(ctx).CountAll()
data.Library.Shares, err = c.ds.Share().CountAll(ctx)
if err != nil {
log.Trace(ctx, "Error reading shares count", err)
}
data.Library.Radios, err = c.ds.Radio(ctx).Count()
data.Library.Radios, err = c.ds.Radio().CountAll(ctx)
if err != nil {
log.Trace(ctx, "Error reading radios count", err)
}
data.Library.Libraries, err = c.ds.Library(ctx).CountAll()
data.Library.Libraries, err = c.ds.Library().CountAll(ctx)
if err != nil {
log.Trace(ctx, "Error reading libraries count", err)
}
data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{
data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
})
if err != nil {
log.Trace(ctx, "Error reading active users count", err)
}
data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix()
data.Library.FileSuffixes, err = c.ds.MediaFile().CountBySuffix(ctx)
if err != nil {
log.Trace(ctx, "Error reading file suffixes count", err)
}
@ -297,7 +297,7 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
// Collect active players if permitted
if conf.Server.DevEnablePlayerInsights {
data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{
data.Library.ActivePlayers, err = c.ds.Player().CountByClient(ctx, model.QueryOptions{
Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)},
})
if err != nil {
@ -324,7 +324,7 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
// hasSmartPlaylists checks if there are any smart playlists (playlists with rules)
func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) {
count, err := c.ds.Playlist(ctx).CountAll(model.QueryOptions{
count, err := c.ds.Playlist().CountAll(ctx, model.QueryOptions{
Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}},
})
return count > 0, err

View file

@ -197,28 +197,28 @@ var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics {
})
func processSqlAggregateMetrics(ctx context.Context, ds model.DataStore, targetGauge *prometheus.GaugeVec) {
albumsCount, err := ds.Album(ctx).CountAll()
albumsCount, err := ds.Album().CountAll(ctx)
if err != nil {
log.Warn("album CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount))
artistCount, err := ds.Artist(ctx).CountAll()
artistCount, err := ds.Artist().CountAll(ctx)
if err != nil {
log.Warn("artist CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "artist"}).Set(float64(artistCount))
songsCount, err := ds.MediaFile(ctx).CountAll()
songsCount, err := ds.MediaFile().CountAll(ctx)
if err != nil {
log.Warn("media CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount))
usersCount, err := ds.User(ctx).CountAll()
usersCount, err := ds.User().CountAll(ctx)
if err != nil {
log.Warn("user CountAll error", err)
return

View file

@ -23,7 +23,7 @@ type Track interface {
}
type playbackDevice struct {
serviceCtx context.Context
serviceCtx context.Context //nolint:containedctx // playback service lifecycle ctx
ParentPlaybackServer PlaybackServer
Default bool
User string

View file

@ -111,7 +111,7 @@ func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) {
// GetMediaFile retrieves the MediaFile given by the id parameter
func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) {
return ps.datastore.MediaFile(*ps.ctx).Get(id)
return ps.datastore.MediaFile().Get(*ps.ctx, id)
}
// GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device.

View file

@ -37,14 +37,14 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
var err error
user, _ := request.UserFrom(ctx)
if playerID != "" {
plr, err = p.ds.Player(ctx).Get(playerID)
plr, err = p.ds.Player().Get(ctx, playerID)
if err == nil && (plr.Client != client || plr.UserId != user.ID) {
playerID = ""
}
}
username := userName(ctx)
if err != nil || playerID == "" {
plr, err = p.ds.Player(ctx).FindMatch(user.ID, client, userAgent)
plr, err = p.ds.Player().FindMatch(ctx, user.ID, client, userAgent)
if err == nil {
log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", username, "type", userAgent)
} else {
@ -66,17 +66,17 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
err = p.ds.Player(ctx).Put(plr)
err = p.ds.Player().Put(ctx, plr)
if err != nil {
log.Warn(ctx, "Could not save player", "id", plr.ID, "client", client, "username", username, "type", userAgent, err)
}
})
if plr.TranscodingId != "" {
trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId)
trc, err = p.ds.Transcoding().Get(ctx, plr.TranscodingId)
}
return plr, trc, err
}
func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) {
return p.ds.Player(ctx).Get(playerId)
return p.ds.Player().Get(ctx, playerId)
}

View file

@ -145,14 +145,14 @@ func (m *mockPlayerRepository) add(p *model.Player) {
m.data[p.ID] = *p
}
func (m *mockPlayerRepository) Get(id string) (*model.Player, error) {
func (m *mockPlayerRepository) Get(_ context.Context, id string) (*model.Player, error) {
if p, ok := m.data[id]; ok {
return &p, nil
}
return nil, model.ErrNotFound
}
func (m *mockPlayerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) {
func (m *mockPlayerRepository) FindMatch(_ context.Context, userId, client, userAgent string) (*model.Player, error) {
for _, p := range m.data {
if p.Client == client && p.UserId == userId && p.UserAgent == userAgent {
return &p, nil
@ -161,7 +161,7 @@ func (m *mockPlayerRepository) FindMatch(userId, client, userAgent string) (*mod
return nil, model.ErrNotFound
}
func (m *mockPlayerRepository) Put(p *model.Player) error {
func (m *mockPlayerRepository) Put(_ context.Context, p *model.Player) error {
m.lastSaved = p
return nil
}

View file

@ -39,7 +39,7 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo
}
if pls.ID != "" && pls.Sync != sync {
pls.Sync = sync
if putErr := s.ds.Playlist(ctx).Put(pls); putErr != nil {
if putErr := s.ds.Playlist().Put(ctx, pls); putErr != nil {
return nil, putErr
}
}
@ -74,7 +74,7 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo
var errNotInLibrary = fmt.Errorf("path not in any library")
func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folder, error) {
libs, err := s.ds.Library(ctx).GetAll()
libs, err := s.ds.Library().GetAll(ctx)
if err != nil {
return nil, err
}
@ -84,7 +84,7 @@ func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folde
return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir)
}
folder, err := s.ds.Folder(ctx).GetByPath(lib, dir)
folder, err := s.ds.Folder().GetByPath(ctx, lib, dir)
if err != nil {
return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err)
}
@ -122,7 +122,7 @@ func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Pla
log.Error(ctx, "Error parsing playlist", err)
return nil, err
}
err = s.ds.Playlist(ctx).Put(pls)
err = s.ds.Playlist().Put(ctx, pls)
if err != nil {
log.Error(ctx, "Error saving playlist", err)
return nil, err
@ -166,14 +166,14 @@ func fingerprint(h *xxh3.Hasher) string {
// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode
// normalization forms to handle cross-platform filesystem differences.
func (s *playlists) findByPathNormalized(ctx context.Context, path string) (*model.Playlist, error) {
pls, err := s.ds.Playlist(ctx).FindByPath(path)
pls, err := s.ds.Playlist().FindByPath(ctx, path)
if errors.Is(err, model.ErrNotFound) {
altPath := norm.NFD.String(path)
if altPath == path {
altPath = norm.NFC.String(path)
}
if altPath != path {
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
pls, err = s.ds.Playlist().FindByPath(ctx, altPath)
}
}
return pls, err
@ -221,5 +221,5 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
}
return s.ds.Playlist(ctx).Put(newPls)
return s.ds.Playlist().Put(ctx, newPls)
}

View file

@ -1172,7 +1172,7 @@ type mockedMediaFileRepo struct {
data map[string]model.MediaFile
}
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
func (r *mockedMediaFileRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
// If data map provided, look up files
@ -1212,7 +1212,7 @@ type mockedMediaFileFromListRepo struct {
data []string
}
func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
func (r *mockedMediaFileFromListRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
for idx, dataPath := range r.data {
@ -1247,7 +1247,7 @@ type mockFolderRepoForImport struct {
folder *model.Folder
}
func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) {
func (m *mockFolderRepoForImport) GetByPath(_ context.Context, _ model.Library, _ string) (*model.Folder, error) {
if m.folder != nil {
return m.folder, nil
}

View file

@ -20,7 +20,7 @@ import (
)
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
mediaFileRepository := s.ds.MediaFile(ctx)
mediaFileRepository := s.ds.MediaFile()
resolver, err := newPathResolver(ctx, s.ds)
if err != nil {
return err
@ -96,7 +96,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
}
}
found, err := mediaFileRepository.FindByPaths(lookupCandidates)
found, err := mediaFileRepository.FindByPaths(ctx, lookupCandidates)
if err != nil {
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
continue
@ -215,7 +215,7 @@ type pathResolver struct {
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
libs, err := ds.Library(ctx).GetAll()
libs, err := ds.Library().GetAll(ctx)
if err != nil {
return nil, err
}

View file

@ -24,7 +24,7 @@ var _ = Describe("libraryMatcher", func() {
// Helper function to create a libraryMatcher from the mock datastore
createMatcher := func(ds model.DataStore) *libraryMatcher {
libs, err := ds.Library(ctx).GetAll()
libs, err := ds.Library().GetAll(ctx)
Expect(err).ToNot(HaveOccurred())
return newLibraryMatcher(libs)
}

View file

@ -49,8 +49,8 @@ type Playlists interface {
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
// REST adapters
NewRepository(ctx context.Context) rest.Repository
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
Repository() rest.Repository[model.Playlist]
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository[model.PlaylistTrack]
}
// ImageUploadService is a local interface satisfied by artwork.Uploader.
@ -64,10 +64,13 @@ type ImageUploadService interface {
type playlists struct {
ds model.DataStore
imgUpload ImageUploadService
repo *playlistRepositoryWrapper
}
func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists {
return &playlists{ds: ds, imgUpload: imgUpload}
s := &playlists{ds: ds, imgUpload: imgUpload}
s.repo = &playlistRepositoryWrapper{PlaylistRepository: ds.Playlist(), service: s}
return s
}
func InPath(folder model.Folder) bool {
@ -86,30 +89,30 @@ func InPath(folder model.Folder) bool {
// --- Read operations ---
func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
return s.ds.Playlist(ctx).GetAll(options...)
return s.ds.Playlist().GetAll(ctx, options...)
}
func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
return s.ds.Playlist(ctx).Get(id)
return s.ds.Playlist().Get(ctx, id)
}
func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
return s.ds.Playlist(ctx).GetWithTracks(id, true, false)
return s.ds.Playlist().GetWithTracks(ctx, id, true, false)
}
func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) {
return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
return s.ds.Playlist().GetPlaylists(ctx, mediaFileId)
}
// Tracks scopes a repository to one playlist's tracks, for callers that page or stream them rather
// than loading every one like GetWithTracks. Gets first because PlaylistRepository.Tracks discards
// its error behind a nil (and warns), and this is probed with ids that are usually not playlists.
func (s *playlists) Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) {
repo := s.ds.Playlist(ctx)
if _, err := repo.Get(id); err != nil {
repo := s.ds.Playlist()
if _, err := repo.Get(ctx, id); err != nil {
return nil, err
}
tracks := repo.Tracks(id, true)
tracks := repo.Tracks(ctx, id, true)
if tracks == nil {
return nil, model.ErrNotFound
}
@ -127,7 +130,7 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string,
var err error
if playlistId != "" {
pls, err = tx.Playlist(ctx).Get(playlistId)
pls, err = tx.Playlist().Get(ctx, playlistId)
if err != nil {
return err
}
@ -145,7 +148,7 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string,
pls.Tracks = nil
pls.AddMediaFilesByID(ids)
err = tx.Playlist(ctx).Put(pls)
err = tx.Playlist().Put(ctx, pls)
playlistId = pls.ID
return err
})
@ -165,7 +168,7 @@ func (s *playlists) Delete(ctx context.Context, id string) error {
}
}
return s.ds.Playlist(ctx).Delete(id)
return s.ds.Playlist().Delete(ctx, id)
}
func (s *playlists) Update(ctx context.Context, playlistID string,
@ -183,21 +186,21 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
return err
}
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
repo := tx.Playlist(ctx)
repo := tx.Playlist()
if len(idxToRemove) > 0 {
tracksRepo := repo.Tracks(playlistID, false)
tracksRepo := repo.Tracks(ctx, playlistID, false)
// Convert 0-based indices to 1-based position IDs and delete them directly,
// avoiding the need to load all tracks into memory.
positions := make([]string, len(idxToRemove))
for i, idx := range idxToRemove {
positions[i] = strconv.Itoa(idx + 1)
}
if err := tracksRepo.Delete(positions...); err != nil {
if err := tracksRepo.Delete(ctx, positions...); err != nil {
return err
}
if len(idsToAdd) > 0 {
if _, err := tracksRepo.Add(idsToAdd); err != nil {
if _, err := tracksRepo.Add(ctx, idsToAdd); err != nil {
return err
}
}
@ -205,7 +208,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
}
if len(idsToAdd) > 0 {
if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil {
if _, err := repo.Tracks(ctx, playlistID, false).Add(ctx, idsToAdd); err != nil {
return err
}
}
@ -221,7 +224,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
// checkWritable fetches the playlist and verifies the current user can modify it.
func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) {
pls, err := s.ds.Playlist(ctx).Get(id)
pls, err := s.ds.Playlist().Get(ctx, id)
if err != nil {
return nil, err
}
@ -257,7 +260,7 @@ func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls
if public != nil {
pls.Public = *public
}
return ds.Playlist(ctx).Put(pls)
return ds.Playlist().Put(ctx, pls)
}
// --- Track management operations ---
@ -266,7 +269,7 @@ func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []stri
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
return s.ds.Playlist().Tracks(ctx, playlistID, false).Add(ctx, ids)
}
// InsertTracks adds tracks before the 1-based position pos; a position past the end appends.
@ -279,7 +282,7 @@ func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []s
// concurrent writers instead of waiting for the lock.
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
var err error
count, err = tx.Playlist(ctx).Tracks(playlistID, false).Insert(ids, pos)
count, err = tx.Playlist().Tracks(ctx, playlistID, false).Insert(ctx, ids, pos)
return err
})
return count, err
@ -289,21 +292,21 @@ func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds [
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds)
return s.ds.Playlist().Tracks(ctx, playlistID, false).AddAlbums(ctx, albumIds)
}
func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds)
return s.ds.Playlist().Tracks(ctx, playlistID, false).AddArtists(ctx, artistIds)
}
func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs)
return s.ds.Playlist().Tracks(ctx, playlistID, false).AddDiscs(ctx, discs)
}
func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
@ -311,7 +314,7 @@ func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackId
return err
}
return s.ds.WithTx(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...)
return tx.Playlist().Tracks(ctx, playlistID, false).Delete(ctx, trackIds...)
})
}
@ -320,7 +323,7 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int
return err
}
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
return tx.Playlist().Tracks(ctx, playlistID, false).Reorder(ctx, pos, newPos)
})
}
@ -339,7 +342,7 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R
}
pls.UploadedImage = filename
if err := s.ds.Playlist(ctx).Put(pls); err != nil {
if err := s.ds.Playlist().Put(ctx, pls); err != nil {
return err
}
s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID)
@ -357,7 +360,7 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error {
}
pls.UploadedImage = ""
if err := s.ds.Playlist(ctx).Put(pls); err != nil {
if err := s.ds.Playlist().Put(ctx, pls); err != nil {
return err
}
s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID)

View file

@ -493,15 +493,15 @@ var _ = Describe("Playlists", func() {
It("clears the resolved artwork state and re-queues after removing an upload", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{
Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "pl", ItemID: "pls-1", Hash: "oldhash", Source: "upload",
})).To(Succeed())
Expect(ps.RemoveImage(ctx, "pls-1")).To(Succeed())
_, err := ds.Artwork(ctx).GetItemArtwork(model.KindPlaylistArtwork, "pls-1", model.ImageTypePrimary)
_, err := ds.Artwork().GetItemArtwork(ctx, model.KindPlaylistArtwork, "pls-1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
queued, _ := ds.ArtworkQueue(ctx).DequeueBatch(100)
queued, _ := ds.ArtworkQueue().DequeueBatch(ctx, 100)
Expect(queued).To(ContainElement(SatisfyAll(
HaveField("ItemKind", "pl"),
HaveField("ItemID", "pls-1"),

View file

@ -14,42 +14,43 @@ import (
// --- REST adapter (follows Share/Library pattern) ---
func (s *playlists) NewRepository(ctx context.Context) rest.Repository {
return &playlistRepositoryWrapper{
ctx: ctx,
PlaylistRepository: s.ds.Playlist(ctx),
service: s,
}
func (s *playlists) Repository() rest.Repository[model.Playlist] {
return s.repo
}
// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter.
// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository),
// and rest.Persistable by delegating to service methods for all mutations.
// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter,
// delegating to service methods for all mutations.
type playlistRepositoryWrapper struct {
model.PlaylistRepository
ctx context.Context
service *playlists
}
func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
var _ rest.Persistable[model.Playlist] = (*playlistRepositoryWrapper)(nil)
func (r *playlistRepositoryWrapper) Save(ctx context.Context, entity *model.Playlist) (string, error) {
return r.service.savePlaylist(ctx, entity)
}
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
func (r *playlistRepositoryWrapper) Update(ctx context.Context, id string, entity model.Playlist, cols ...string) error {
return r.service.updatePlaylistEntity(ctx, id, &entity, cols...)
}
func (r *playlistRepositoryWrapper) Delete(id string) error {
return r.service.Delete(r.ctx, id)
func (r *playlistRepositoryWrapper) Delete(ctx context.Context, ids ...string) error {
for _, id := range ids {
if err := r.service.Delete(ctx, id); err != nil {
return err
}
}
return nil
}
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
repo := s.ds.Playlist(ctx)
tracks := repo.Tracks(playlistId, refreshSmartPlaylist)
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository[model.PlaylistTrack] {
repo := s.ds.Playlist()
tracks := repo.Tracks(ctx, playlistId, refreshSmartPlaylist)
if tracks == nil {
return nil
}
return tracks.(rest.Repository)
return tracks
}
// savePlaylist creates a new playlist, assigning the owner from context.
@ -63,7 +64,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri
pls.UploadedImage = "" // Managed by image upload endpoint
pls.ExternalImageURL = "" // Managed by M3U import / plugins only
pls.EvaluatedAt = nil // Server-managed
err := s.ds.Playlist(ctx).Put(pls)
err := s.ds.Playlist().Put(ctx, pls)
if err != nil {
return "", err
}
@ -156,7 +157,7 @@ func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.P
if len(updateCols) == 0 {
return nil
}
return s.ds.Playlist(ctx).Put(current, updateCols...)
return s.ds.Playlist().Put(ctx, current, updateCols...)
}
// sentFields returns a predicate that reports whether a JSON field was present

View file

@ -31,7 +31,7 @@ var _ = Describe("REST Adapter", func() {
})
Describe("NewRepository", func() {
var repo rest.Persistable
var repo rest.Persistable[model.Playlist]
BeforeEach(func() {
mockPlsRepo.Data = map[string]*model.Playlist{
@ -43,9 +43,9 @@ var _ = Describe("REST Adapter", func() {
Describe("Save", func() {
It("sets the owner from the context user", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{Name: "New Playlist"}
id, err := repo.Save(pls)
id, err := repo.Save(ctx, pls)
Expect(err).ToNot(HaveOccurred())
Expect(id).ToNot(BeEmpty())
Expect(pls.OwnerID).To(Equal("user-1"))
@ -53,16 +53,16 @@ var _ = Describe("REST Adapter", func() {
It("forces a new creation by clearing ID", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{ID: "should-be-cleared", Name: "New"}
_, err := repo.Save(pls)
_, err := repo.Save(ctx, pls)
Expect(err).ToNot(HaveOccurred())
Expect(pls.ID).ToNot(Equal("should-be-cleared"))
})
It("clears server-managed fields to prevent injection via REST API", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{
Name: "Legit Playlist",
Comment: "A comment",
@ -74,7 +74,7 @@ var _ = Describe("REST Adapter", func() {
ExternalImageURL: "http://evil.example.com/ssrf",
EvaluatedAt: new(time.Now()),
}
_, err := repo.Save(pls)
_, err := repo.Save(ctx, pls)
Expect(err).ToNot(HaveOccurred())
saved := mockPlsRepo.Last
@ -95,33 +95,33 @@ var _ = Describe("REST Adapter", func() {
Describe("Update", func() {
It("allows owner to update their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
err := repo.Update(ctx, "pls-1", *pls)
Expect(err).ToNot(HaveOccurred())
})
It("allows admin to update any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
err := repo.Update(ctx, "pls-1", *pls)
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner, non-admin", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
err := repo.Update(ctx, "pls-1", *pls)
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
It("denies regular user from changing ownership", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"}
err := repo.Update("pls-1", pls)
err := repo.Update(ctx, "pls-1", *pls)
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
@ -133,9 +133,9 @@ var _ = Describe("REST Adapter", func() {
// entity.OwnerID. sentFields normalizes both sides so the
// permission gate fires regardless of casing.
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{OwnerID: "other-user"}
err := repo.Update("pls-1", pls, colName)
err := repo.Update(ctx, "pls-1", *pls, colName)
Expect(err).To(Equal(rest.ErrPermissionDenied))
},
Entry("canonical camelCase", "ownerId"),
@ -152,10 +152,10 @@ var _ = Describe("REST Adapter", func() {
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}},
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}}
pls := &model.Playlist{Name: "Smart Playlist", Rules: newRules}
err := repo.Update("smart-1", pls)
err := repo.Update(ctx, "smart-1", *pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
})
@ -171,10 +171,10 @@ var _ = Describe("REST Adapter", func() {
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}},
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}}
pls := &model.Playlist{Rules: newRules}
err := repo.Update("smart-1", pls, "rules")
err := repo.Update(ctx, "smart-1", *pls, "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.ImportedHash).To(BeEmpty())
})
@ -190,9 +190,9 @@ var _ = Describe("REST Adapter", func() {
UpdatedAt: originalTime,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{Name: "File Playlist", Sync: false}
err := repo.Update("file-pls", pls)
err := repo.Update(ctx, "file-pls", *pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime))
@ -207,9 +207,9 @@ var _ = Describe("REST Adapter", func() {
Sync: false,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{Name: "Manual Playlist", Sync: true}
err := repo.Update("manual-pls", pls)
err := repo.Update(ctx, "manual-pls", *pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).To(BeNil())
})
@ -224,9 +224,9 @@ var _ = Describe("REST Adapter", func() {
UpdatedAt: originalTime,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{Name: "My Playlist", Public: true}
err := repo.Update("pls-pub", pls)
err := repo.Update(ctx, "pls-pub", *pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Public).To(BeTrue())
Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime))
@ -241,9 +241,9 @@ var _ = Describe("REST Adapter", func() {
Sync: true,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{Name: "New Name", Sync: false}
err := repo.Update("file-pls2", pls)
err := repo.Update(ctx, "file-pls2", *pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("New Name"))
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
@ -251,9 +251,9 @@ var _ = Describe("REST Adapter", func() {
It("returns rest.ErrNotFound when playlist doesn't exist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("nonexistent", pls)
err := repo.Update(ctx, "nonexistent", *pls)
Expect(err).To(Equal(rest.ErrNotFound))
})
@ -274,8 +274,8 @@ var _ = Describe("REST Adapter", func() {
})
It("preserves name and comment when only public is sent (bulk Make Public)", func() {
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
repo = ps.Repository().(rest.Persistable[model.Playlist])
err := repo.Update(ctx, "partial", model.Playlist{Public: true}, "public")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
@ -290,16 +290,16 @@ var _ = Describe("REST Adapter", func() {
Path: "/music/p.m3u",
Sync: true,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync")
repo = ps.Repository().(rest.Persistable[model.Playlist])
err := repo.Update(ctx, "file-partial", model.Playlist{Sync: false}, "sync")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me"))
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
})
It("renames the playlist when only name is sent", func() {
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name")
repo = ps.Repository().(rest.Persistable[model.Playlist])
err := repo.Update(ctx, "partial", model.Playlist{Name: "Renamed"}, "name")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
@ -307,8 +307,8 @@ var _ = Describe("REST Adapter", func() {
})
It("clears the comment when an empty comment is sent explicitly", func() {
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment")
repo = ps.Repository().(rest.Persistable[model.Playlist])
err := repo.Update(ctx, "partial", model.Playlist{Comment: ""}, "comment")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Comment).To(BeEmpty())
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
@ -323,9 +323,9 @@ var _ = Describe("REST Adapter", func() {
Public: true,
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
}
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"}
err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules")
err := repo.Update(ctx, "smart-partial", model.Playlist{Rules: newRules}, "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original"))
@ -342,9 +342,9 @@ var _ = Describe("REST Adapter", func() {
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
EvaluatedAt: &evaluatedAt,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}}
err := repo.Update("smart-reset", &model.Playlist{Rules: newRules}, "rules")
err := repo.Update(ctx, "smart-reset", model.Playlist{Rules: newRules}, "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.EvaluatedAt).To(BeNil())
})
@ -358,8 +358,8 @@ var _ = Describe("REST Adapter", func() {
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
EvaluatedAt: &evaluatedAt,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("smart-keep", &model.Playlist{Name: "Renamed Smart"}, "name")
repo = ps.Repository().(rest.Persistable[model.Playlist])
err := repo.Update(ctx, "smart-keep", model.Playlist{Name: "Renamed Smart"}, "name")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.EvaluatedAt).ToNot(BeNil())
Expect(*mockPlsRepo.Last.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second))
@ -373,10 +373,10 @@ var _ = Describe("REST Adapter", func() {
OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
}
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"}
err := repo.Update("smart-edit",
&model.Playlist{Name: "Smart Renamed", Rules: newRules},
err := repo.Update(ctx, "smart-edit",
model.Playlist{Name: "Smart Renamed", Rules: newRules},
"name", "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed"))
@ -392,11 +392,11 @@ var _ = Describe("REST Adapter", func() {
OwnerID: "user-1",
Rules: rules,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
repo = ps.Repository().(rest.Persistable[model.Playlist])
// Same rules sent back — rulesEqual should report no change and
// the request should no-op (no Put call).
sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules")
err := repo.Update(ctx, "smart-idempotent", model.Playlist{Rules: sameRules}, "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened
})
@ -410,8 +410,8 @@ var _ = Describe("REST Adapter", func() {
Public: false,
Rules: rules,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("smart-public", &model.Playlist{Public: true}, "public")
repo = ps.Repository().(rest.Persistable[model.Playlist])
err := repo.Update(ctx, "smart-public", model.Playlist{Public: true}, "public")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Public).To(BeTrue())
Expect(mockPlsRepo.Last.Rules).To(Equal(rules))
@ -421,8 +421,8 @@ var _ = Describe("REST Adapter", func() {
It("does not treat a missing ownerId as an ownership transfer attempt", func() {
// A non-admin user sending only {public:true} should not be blocked
// just because OwnerID is the zero value in the deserialized entity.
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
repo = ps.Repository().(rest.Persistable[model.Playlist])
err := repo.Update(ctx, "partial", model.Playlist{Public: true}, "public")
Expect(err).ToNot(HaveOccurred())
})
@ -431,8 +431,8 @@ var _ = Describe("REST Adapter", func() {
// like {"Name":"x"}, but rest.Put's field-name extraction is
// case-sensitive. sentFields normalizes both sides so a request
// with {"Name":"Renamed"} is honored, not silently ignored.
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name")
repo = ps.Repository().(rest.Persistable[model.Playlist])
err := repo.Update(ctx, "partial", model.Playlist{Name: "Renamed"}, "Name")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
@ -443,16 +443,16 @@ var _ = Describe("REST Adapter", func() {
Describe("Delete", func() {
It("delegates to service Delete with permission checks", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Delete("pls-1")
repo = ps.Repository().(rest.Persistable[model.Playlist])
err := repo.Delete(ctx, "pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Delete("pls-1")
repo = ps.Repository().(rest.Persistable[model.Playlist])
err := repo.Delete(ctx, "pls-1")
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
})

View file

@ -70,7 +70,7 @@ type bufferedScrobbler struct {
loader Loader
service string
wakeSignal chan struct{}
ctx context.Context
ctx context.Context //nolint:containedctx // scrobbler lifecycle ctx, cancelled by Stop
cancel context.CancelFunc
}
@ -97,7 +97,7 @@ func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track
}
func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
err := b.ds.ScrobbleBuffer(ctx).Enqueue(b.service, userId, s.ID, s.TimeStamp)
err := b.ds.ScrobbleBuffer().Enqueue(ctx, b.service, userId, s.ID, s.TimeStamp)
if err != nil {
return err
}
@ -154,8 +154,8 @@ func (b *bufferedScrobbler) run(ctx context.Context) {
}
func (b *bufferedScrobbler) processQueue(ctx context.Context) (bool, time.Duration) {
buffer := b.ds.ScrobbleBuffer(ctx)
userIds, err := buffer.UserIDs(b.service)
buffer := b.ds.ScrobbleBuffer()
userIds, err := buffer.UserIDs(ctx, b.service)
if err != nil {
log.Error(ctx, "Error retrieving userIds from scrobble buffer", "scrobbler", b.service, err)
return false, 0
@ -176,14 +176,14 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string)
// Scrobbles are drained on a background context that no longer carries the
// request's authenticated user. Restore it from the buffered userId so that
// scrobblers relying on the user in the context (e.g. plugins) still get it.
if user, err := b.ds.User(ctx).Get(userId); err != nil {
if user, err := b.ds.User().Get(ctx, userId); err != nil {
log.Warn(ctx, "Could not load user for buffered scrobble", "userId", userId, "scrobbler", b.service, err)
} else {
ctx = request.WithUser(ctx, *user)
}
buffer := b.ds.ScrobbleBuffer(ctx)
buffer := b.ds.ScrobbleBuffer()
for {
entry, err := buffer.Next(b.service, userId)
entry, err := buffer.Next(ctx, b.service, userId)
if err != nil {
log.Error(ctx, "Error reading from scrobble buffer", "scrobbler", b.service, err)
return false, 0
@ -210,7 +210,7 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string)
log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service,
"userId", entry.UserID, "artist", entry.Artist, "track", entry.Title, err)
}
err = buffer.Dequeue(entry)
err = buffer.Dequeue(ctx, entry)
if err != nil {
log.Error(ctx, "Error removing entry from scrobble buffer", "userId", entry.UserID,
"track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err)

View file

@ -26,7 +26,7 @@ var _ = Describe("BufferedScrobbler", func() {
ctx = context.Background()
buffer = tests.CreateMockedScrobbleBufferRepo()
userRepo := tests.CreateMockUserRepo()
Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed())
Expect(userRepo.Put(ctx, &model.User{ID: "user1", UserName: "alice"})).To(Succeed())
ds = &tests.MockDataStore{
MockedScrobbleBuffer: buffer,
MockedUser: userRepo,
@ -55,7 +55,7 @@ var _ = Describe("BufferedScrobbler", func() {
track := model.MediaFile{ID: "123", Title: "Test Track"}
now := time.Now()
scrobble := Scrobble{MediaFile: track, TimeStamp: now}
Expect(buffer.Length()).To(Equal(int64(0)))
Expect(buffer.Length(ctx)).To(Equal(int64(0)))
Expect(scr.ScrobbleCalled.Load()).To(BeFalse())
Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed())
@ -131,7 +131,7 @@ func TestBufferedScrobblerBackoffSchedule(t *testing.T) {
g := NewWithT(t)
buffer := tests.CreateMockedScrobbleBufferRepo()
userRepo := tests.CreateMockUserRepo()
g.Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed())
g.Expect(userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"})).To(Succeed())
ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo}
flaky := &recoveringScrobbler{}
@ -147,7 +147,7 @@ func TestBufferedScrobblerBackoffSchedule(t *testing.T) {
// First attempt fires immediately on the enqueue wake and is left buffered.
synctest.Wait()
g.Expect(flaky.count.Load()).To(Equal(int32(1)))
g.Expect(buffer.Length()).To(Equal(int64(1)))
g.Expect(buffer.Length(t.Context())).To(Equal(int64(1)))
// Each subsequent retry waits exactly double the previous: 5s, 10s, 20s, 40s.
for i, gap := range []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second} {
@ -165,10 +165,10 @@ func TestBufferedScrobblerBackoffSchedule(t *testing.T) {
flaky.succeed()
bs.sendWakeSignal()
synctest.Wait()
g.Expect(buffer.Length()).To(Equal(int64(1)), "wake during backoff drained early")
g.Expect(buffer.Length(t.Context())).To(Equal(int64(1)), "wake during backoff drained early")
time.Sleep(80 * time.Second)
synctest.Wait()
g.Expect(buffer.Length()).To(Equal(int64(0)))
g.Expect(buffer.Length(t.Context())).To(Equal(int64(0)))
})
}
@ -176,7 +176,7 @@ func TestBufferedScrobblerBackoffWindow(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
buffer := tests.CreateMockedScrobbleBufferRepo()
userRepo := tests.CreateMockUserRepo()
_ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"})
_ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"})
ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo}
scr := &fakeScrobbler{Authorized: true}
scr.SetError(errors.Join(errors.New("boom"), ErrRetryLater))
@ -211,7 +211,7 @@ func TestBufferedScrobblerHonorsServerDelay(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
buffer := tests.CreateMockedScrobbleBufferRepo()
userRepo := tests.CreateMockUserRepo()
_ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"})
_ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"})
ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo}
scr := &fakeScrobbler{Authorized: true}
scr.SetError(errors.Join(errors.New("429"), &agents.RetryLaterError{RetryIn: 30 * time.Second}))
@ -244,8 +244,8 @@ func TestBufferedScrobblerTakesTheLongestServerDelayAcrossUsers(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
buffer := tests.CreateMockedScrobbleBufferRepo()
userRepo := tests.CreateMockUserRepo()
_ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"})
_ = userRepo.Put(&model.User{ID: "user2", UserName: "bob"})
_ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"})
_ = userRepo.Put(t.Context(), &model.User{ID: "user2", UserName: "bob"})
ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo}
scr := &recoveringScrobbler{delays: map[string]time.Duration{
"user1": 10 * time.Second,
@ -253,8 +253,8 @@ func TestBufferedScrobblerTakesTheLongestServerDelayAcrossUsers(t *testing.T) {
}}
// Both are buffered before the drain goroutine exists: it drains once on startup, and
// seeing only one user there would park it on that user's delay, ignoring the other.
_ = buffer.Enqueue("test", "user1", "1", time.Now())
_ = buffer.Enqueue("test", "user2", "2", time.Now())
_ = buffer.Enqueue(t.Context(), "test", "user1", "1", time.Now())
_ = buffer.Enqueue(t.Context(), "test", "user2", "2", time.Now())
bs := newBufferedScrobbler(ds, scr, "test")
defer bs.Stop()

View file

@ -68,14 +68,14 @@ type ReportPlaybackParams struct {
}
type nowPlayingEntry struct {
ctx context.Context
ctx context.Context //nolint:containedctx // queued work item carries the request ctx to the worker
userId string
track *model.MediaFile
position int
}
type playbackReportEntry struct {
ctx context.Context
ctx context.Context //nolint:containedctx // queued work item carries the request ctx to the worker
info PlaybackSession
filtered bool
}
@ -293,7 +293,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId)
return nil
}
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId)
if err != nil {
return err
}
@ -328,7 +328,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
case StatePlaying, StatePaused:
info, getErr := p.playMap.Get(clientId)
if getErr != nil || info.MediaFile.ID != params.MediaId {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId)
if err != nil {
return err
}
@ -364,7 +364,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
var loadedMF *model.MediaFile
haveVerdict := false
if !params.IgnoreScrobble && player.ScrobbleEnabled {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId)
if err != nil {
return err
}
@ -409,7 +409,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
mf := loadedMF
if mf == nil {
var mfErr error
mf, mfErr = p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
mf, mfErr = p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId)
if mfErr != nil {
return mfErr
}
@ -477,7 +477,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
success := 0
for _, s := range submissions {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(s.TrackID)
mf, err := p.ds.MediaFile().GetWithParticipants(ctx, s.TrackID)
if err != nil {
log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err)
continue
@ -504,22 +504,22 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, timestamp time.Time) error {
return p.ds.WithTx(func(tx model.DataStore) error {
err := tx.MediaFile(ctx).IncPlayCount(track.ID, timestamp)
err := tx.MediaFile().IncPlayCount(ctx, track.ID, timestamp)
if err != nil {
return err
}
err = tx.Album(ctx).IncPlayCount(track.AlbumID, timestamp)
err = tx.Album().IncPlayCount(ctx, track.AlbumID, timestamp)
if err != nil {
return err
}
for _, artist := range track.Participants[model.RoleArtist] {
err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp)
err = tx.Artist().IncPlayCount(ctx, artist.ID, timestamp)
if err != nil {
return err
}
}
if conf.Server.EnableScrobbleHistory {
return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp)
return tx.Scrobble().RecordScrobble(ctx, track.ID, timestamp)
}
return nil
})
@ -538,7 +538,7 @@ func (p *playTracker) isFilteredOut(ctx context.Context, t *model.MediaFile) boo
log.Warn(ctx, "Invalid scrobble filter, ignoring", "user", u.UserName, err)
return false
}
match, err := p.ds.MediaFile(ctx).MatchesCriteria(t.ID, c)
match, err := p.ds.MediaFile().MatchesCriteria(ctx, t.ID, c)
if err != nil {
log.Warn(ctx, "Error evaluating scrobble filter, ignoring", "user", u.UserName, "track", t.Title, err)
return false

View file

@ -55,12 +55,12 @@ type flipOnPlayRepo struct {
played atomic.Bool
}
func (r *flipOnPlayRepo) IncPlayCount(id string, ts time.Time) error {
func (r *flipOnPlayRepo) IncPlayCount(ctx context.Context, id string, ts time.Time) error {
r.played.Store(true)
return r.MediaFileRepository.IncPlayCount(id, ts)
return r.MediaFileRepository.IncPlayCount(ctx, id, ts)
}
func (r *flipOnPlayRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) {
func (r *flipOnPlayRepo) MatchesCriteria(context.Context, string, criteria.Criteria) (bool, error) {
if r.played.Load() {
return r.after, nil
}
@ -73,9 +73,9 @@ type slowMediaFileRepo struct {
model.MediaFileRepository
}
func (s *slowMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) {
func (s *slowMediaFileRepo) GetWithParticipants(ctx context.Context, id string) (*model.MediaFile, error) {
time.Sleep(5 * time.Millisecond)
return s.MediaFileRepository.GetWithParticipants(id)
return s.MediaFileRepository.GetWithParticipants(ctx, id)
}
var _ = Describe("PlayTracker", func() {
@ -119,13 +119,13 @@ var _ = Describe("PlayTracker", func() {
model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1"), _p("ar-2", "Artist 2")},
},
}
_ = ds.MediaFile(ctx).Put(&track)
_ = ds.MediaFile().Put(ctx, &track)
artist1 = model.Artist{ID: "ar-1"}
_ = ds.Artist(ctx).Put(&artist1)
_ = ds.Artist().Put(ctx, &artist1)
artist2 = model.Artist{ID: "ar-2"}
_ = ds.Artist(ctx).Put(&artist2)
_ = ds.Artist().Put(ctx, &artist2)
album = model.Album{ID: "al-1"}
_ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album)
_ = ds.Album().(*tests.MockAlbumRepo).Put(ctx, &album)
})
AfterEach(func() {
@ -149,7 +149,7 @@ var _ = Describe("PlayTracker", func() {
It("returns current playing music", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
_ = ds.MediaFile().Put(ctx, &track2)
ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true})
_ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{
@ -180,7 +180,7 @@ var _ = Describe("PlayTracker", func() {
hidden := track
hidden.ID = "789"
hidden.LibraryID = 2
_ = ds.MediaFile(ctx).Put(&hidden)
_ = ds.MediaFile().Put(ctx, &hidden)
reporter := request.WithPlayer(
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
model.Player{ScrobbleEnabled: true},
@ -199,7 +199,7 @@ var _ = Describe("PlayTracker", func() {
hidden := track
hidden.ID = "789"
hidden.LibraryID = 2
_ = ds.MediaFile(ctx).Put(&hidden)
_ = ds.MediaFile().Put(ctx, &hidden)
reporter := request.WithPlayer(
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
model.Player{ScrobbleEnabled: true},
@ -360,7 +360,7 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
mockDS := ds.(*tests.MockDataStore)
mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
mockScrobble := mockDS.Scrobble().(*tests.MockScrobbleRepo)
Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1))
Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123"))
Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1"))
@ -376,7 +376,7 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
mockDS := ds.(*tests.MockDataStore)
mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
mockScrobble := mockDS.Scrobble().(*tests.MockScrobbleRepo)
Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0))
})
})
@ -388,7 +388,7 @@ var _ = Describe("PlayTracker", func() {
BeforeEach(func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1",
ScrobbleFilter: `{"all":[{"contains":{"title":"Track"}}]}`})
repo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
repo = ds.MediaFile().(*tests.MockMediaFileRepo)
})
It("does not send a matching track to the agent", func() {
@ -489,7 +489,7 @@ var _ = Describe("PlayTracker", func() {
var flip *flipOnPlayRepo
install := func(before, after bool) {
flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(ctx), before: before, after: after}
flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(), before: before, after: after}
ds.(*tests.MockDataStore).MockedMediaFile = flip
}
@ -632,7 +632,7 @@ var _ = Describe("PlayTracker", func() {
It("starting replaces existing entry when switching tracks on same player", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
_ = ds.MediaFile().Put(ctx, &track2)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
@ -659,7 +659,7 @@ var _ = Describe("PlayTracker", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
_ = ds.MediaFile().Put(ctx, &track2)
err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1",
@ -760,7 +760,7 @@ var _ = Describe("PlayTracker", func() {
model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")},
},
}
_ = ds.MediaFile(ctx).Put(&longTrack)
_ = ds.MediaFile().Put(ctx, &longTrack)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
@ -958,7 +958,7 @@ var _ = Describe("PlayTracker", func() {
BeforeEach(func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
_ = ds.MediaFile().Put(ctx, &track2)
})
It("does not downgrade an actively playing session when a late starting report arrives for the same track", func() {
@ -1023,7 +1023,7 @@ var _ = Describe("PlayTracker", func() {
})
It("never lets a concurrent starting report downgrade the playing session", func() {
ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile(ctx)}
ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile()}
for i := range 20 {
raceClientId := fmt.Sprintf("race-client-%d", i)
var wg sync.WaitGroup

View file

@ -8,7 +8,6 @@ import (
"time"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -21,22 +20,24 @@ import (
type Share interface {
Load(ctx context.Context, id string) (*model.Share, error)
NewRepository(ctx context.Context) rest.Repository
Repository() model.ShareRepository
}
func NewShare(ds model.DataStore) Share {
return &shareService{
ds: ds,
ds: ds,
repo: &shareRepositoryWrapper{ShareRepository: ds.Share(), ds: ds},
}
}
type shareService struct {
ds model.DataStore
ds model.DataStore
repo *shareRepositoryWrapper
}
func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error) {
repo := s.ds.Share(ctx)
share, err := repo.Get(id)
repo := s.ds.Share()
share, err := repo.Get(ctx, id)
if err != nil {
return nil, err
}
@ -47,40 +48,29 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
share.LastVisitedAt = new(time.Now())
share.VisitCount++
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
err = repo.Update(ctx, id, *share, "last_visited_at", "visit_count")
if err != nil {
log.Warn(ctx, "Could not increment visit count for share", "share", share.ID)
}
return share, nil
}
func (s *shareService) NewRepository(ctx context.Context) rest.Repository {
repo := s.ds.Share(ctx)
wrapper := &shareRepositoryWrapper{
ctx: ctx,
ShareRepository: repo,
Repository: repo.(rest.Repository),
Persistable: repo.(rest.Persistable),
ds: s.ds,
}
return wrapper
func (s *shareService) Repository() model.ShareRepository {
return s.repo
}
type shareRepositoryWrapper struct {
model.ShareRepository
rest.Repository
rest.Persistable
ctx context.Context
ds model.DataStore
ds model.DataStore
}
func (r *shareRepositoryWrapper) newId() (string, error) {
func (r *shareRepositoryWrapper) newId(ctx context.Context) (string, error) {
for {
id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10)
if err != nil {
return "", err
}
exists, err := r.Exists(id)
exists, err := r.Exists(ctx, id)
if err != nil {
return "", err
}
@ -90,14 +80,13 @@ func (r *shareRepositoryWrapper) newId() (string, error) {
}
}
func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
s := entity.(*model.Share)
func (r *shareRepositoryWrapper) Save(ctx context.Context, s *model.Share) (string, error) {
// Owner is always the caller; never trust a client-supplied UserID, as it
// determines the library-access context used to resolve the share contents.
if user, ok := request.UserFrom(r.ctx); ok {
if user, ok := request.UserFrom(ctx); ok {
s.UserID = user.ID
}
id, err := r.newId()
id, err := r.newId(ctx)
if err != nil {
return "", err
}
@ -106,39 +95,39 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration))
}
s.ResourceType, err = r.resourceType(s.ResourceIDs)
s.ResourceType, err = r.resourceType(ctx, s.ResourceIDs)
if err != nil {
return "", err
}
switch s.ResourceType {
case "artist":
s.Contents = r.contentsLabelFromArtist(s.ID, s.ResourceIDs)
s.Contents = r.contentsLabelFromArtist(ctx, s.ID, s.ResourceIDs)
case "album":
s.Contents = r.contentsLabelFromAlbums(s.ID, s.ResourceIDs)
s.Contents = r.contentsLabelFromAlbums(ctx, s.ID, s.ResourceIDs)
case "playlist":
s.Contents = r.contentsLabelFromPlaylist(s.ID, s.ResourceIDs)
s.Contents = r.contentsLabelFromPlaylist(ctx, s.ID, s.ResourceIDs)
case "media_file":
s.Contents = r.contentsLabelFromMediaFiles(s.ID, s.ResourceIDs)
s.Contents = r.contentsLabelFromMediaFiles(ctx, s.ID, s.ResourceIDs)
}
s.Contents = str.TruncateRunes(s.Contents, 30, "...")
return r.Persistable.Save(s)
return r.ShareRepository.Save(ctx, s)
}
var shareableKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindMediaFileArtwork}
// resourceType resolves every ID as the current user, so an entity they cannot see cannot
// ride along behind a valid first one, and requires all IDs to be of the same kind.
func (r *shareRepositoryWrapper) resourceType(resourceIDs string) (string, error) {
func (r *shareRepositoryWrapper) resourceType(ctx context.Context, resourceIDs string) (string, error) {
resourceType := ""
for _, id := range strings.Split(resourceIDs, ",") {
kind, err := model.GetEntityKindByID(r.ctx, r.ds, id)
kind, err := model.GetEntityKindByID(ctx, r.ds, id)
if err != nil {
return "", err
}
if !slices.Contains(shareableKinds, kind) {
log.Error(r.ctx, "Invalid Resource ID", "id", id)
log.Error(ctx, "Invalid Resource ID", "id", id)
return "", model.ErrNotFound
}
if resourceType != "" && kind.String() != resourceType {
@ -149,53 +138,53 @@ func (r *shareRepositoryWrapper) resourceType(resourceIDs string) (string, error
return resourceType, nil
}
func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error {
func (r *shareRepositoryWrapper) Update(ctx context.Context, id string, entity model.Share, _ ...string) error {
cols := []string{"description", "downloadable"}
// TODO Better handling of Share expiration
if !V(entity.(*model.Share).ExpiresAt).IsZero() {
if !V(entity.ExpiresAt).IsZero() {
cols = append(cols, "expires_at")
}
return r.Persistable.Update(id, entity, cols...)
return r.ShareRepository.Update(ctx, id, entity, cols...)
}
func (r *shareRepositoryWrapper) contentsLabelFromArtist(shareID string, ids string) string {
func (r *shareRepositoryWrapper) contentsLabelFromArtist(ctx context.Context, shareID string, ids string) string {
idList := strings.SplitN(ids, ",", 2)
a, err := r.ds.Artist(r.ctx).Get(idList[0])
a, err := r.ds.Artist().Get(ctx, idList[0])
if err != nil {
log.Error(r.ctx, "Error retrieving artist name for share", "share", shareID, err)
log.Error(ctx, "Error retrieving artist name for share", "share", shareID, err)
return ""
}
return a.Name
}
func (r *shareRepositoryWrapper) contentsLabelFromAlbums(shareID string, ids string) string {
func (r *shareRepositoryWrapper) contentsLabelFromAlbums(ctx context.Context, shareID string, ids string) string {
idList := strings.Split(ids, ",")
all, err := r.ds.Album(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}})
all, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}})
if err != nil {
log.Error(r.ctx, "Error retrieving album names for share", "share", shareID, err)
log.Error(ctx, "Error retrieving album names for share", "share", shareID, err)
return ""
}
names := slice.Map(all, func(a model.Album) string { return a.Name })
return strings.Join(names, ", ")
}
func (r *shareRepositoryWrapper) contentsLabelFromPlaylist(shareID string, id string) string {
pls, err := r.ds.Playlist(r.ctx).Get(id)
func (r *shareRepositoryWrapper) contentsLabelFromPlaylist(ctx context.Context, shareID string, id string) string {
pls, err := r.ds.Playlist().Get(ctx, id)
if err != nil {
log.Error(r.ctx, "Error retrieving album names for share", "share", shareID, err)
log.Error(ctx, "Error retrieving album names for share", "share", shareID, err)
return ""
}
return pls.Name
}
func (r *shareRepositoryWrapper) contentsLabelFromMediaFiles(shareID string, ids string) string {
func (r *shareRepositoryWrapper) contentsLabelFromMediaFiles(ctx context.Context, shareID string, ids string) string {
idList := strings.Split(ids, ",")
mfs, err := r.ds.MediaFile(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.And{
mfs, err := r.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.And{
squirrel.Eq{"media_file.id": idList},
squirrel.Eq{"missing": false},
}})
if err != nil {
log.Error(r.ctx, "Error retrieving media files for share", "share", shareID, err)
log.Error(ctx, "Error retrieving media files for share", "share", shareID, err)
return ""
}

View file

@ -14,27 +14,27 @@ import (
var _ = Describe("Share", func() {
var ds model.DataStore
var share Share
var mockedRepo rest.Persistable
var mockedRepo rest.Persistable[model.Share]
ctx := context.Background()
BeforeEach(func() {
ds = &tests.MockDataStore{}
mockedRepo = ds.Share(ctx).(rest.Persistable)
mockedRepo = ds.Share().(rest.Persistable[model.Share])
share = NewShare(ds)
})
Describe("NewRepository", func() {
var repo rest.Persistable
var repo rest.Persistable[model.Share]
BeforeEach(func() {
repo = share.NewRepository(ctx).(rest.Persistable)
_ = ds.Album(ctx).Put(&model.Album{ID: "123", Name: "Album"})
repo = share.Repository().(rest.Persistable[model.Share])
_ = ds.Album().Put(ctx, &model.Album{ID: "123", Name: "Album"})
})
Describe("Save", func() {
It("it sets a random ID", func() {
entity := &model.Share{Description: "test", ResourceIDs: "123"}
id, err := repo.Save(entity)
id, err := repo.Save(ctx, entity)
Expect(err).ToNot(HaveOccurred())
Expect(id).ToNot(BeEmpty())
Expect(entity.ID).To(Equal(id))
@ -42,63 +42,62 @@ var _ = Describe("Share", func() {
It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() {
loggedInCtx := request.WithUser(context.Background(), model.User{ID: "logged-in-user"})
repo := share.NewRepository(loggedInCtx).(rest.Persistable)
repo := share.Repository().(rest.Persistable[model.Share])
entity := &model.Share{Description: "test", ResourceIDs: "123", UserID: "victim-user"}
_, err := repo.Save(entity)
_, err := repo.Save(loggedInCtx, entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.UserID).To(Equal("logged-in-user"))
})
It("does not truncate ASCII labels shorter than 30 characters", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
_ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "Example Media File"})
entity := &model.Share{Description: "test", ResourceIDs: "456"}
_, err := repo.Save(entity)
_, err := repo.Save(ctx, entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("Example Media File"))
})
It("truncates ASCII labels longer than 30 characters", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"})
_ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"})
entity := &model.Share{Description: "test", ResourceIDs: "789"}
_, err := repo.Save(entity)
_, err := repo.Save(ctx, entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("Example Media File But The ..."))
})
It("does not truncate CJK labels shorter than 30 runes", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"})
_ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "青春コンプレックス"})
entity := &model.Share{Description: "test", ResourceIDs: "456"}
_, err := repo.Save(entity)
_, err := repo.Save(ctx, entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("青春コンプレックス"))
})
It("truncates CJK labels longer than 30 runes", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"})
_ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"})
entity := &model.Share{Description: "test", ResourceIDs: "789"}
_, err := repo.Save(entity)
_, err := repo.Save(ctx, entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
})
It("fails when any of the resource IDs does not exist", func() {
entity := &model.Share{Description: "test", ResourceIDs: "123,missing"}
_, err := repo.Save(entity)
_, err := repo.Save(ctx, entity)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("fails when the resource IDs are of mixed types", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
_ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "Example Media File"})
entity := &model.Share{Description: "test", ResourceIDs: "123,456"}
_, err := repo.Save(entity)
_, err := repo.Save(ctx, entity)
Expect(err).To(HaveOccurred())
})
})
Describe("Update", func() {
It("filters out read-only fields", func() {
entity := &model.Share{}
err := repo.Update("id", entity)
err := repo.Update(ctx, "id", model.Share{})
Expect(err).ToNot(HaveOccurred())
Expect(mockedRepo.(*tests.MockShareRepo).Cols).To(ConsistOf("description", "downloadable"))
})

View file

@ -100,7 +100,7 @@ func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int)
return nil, err
}
mf, err := s.ds.MediaFile(ctx).Get(id)
mf, err := s.ds.MediaFile().Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("getting media file %s: %w", id, err)
}
@ -120,11 +120,11 @@ func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count
return nil, err
}
startMF, err := s.ds.MediaFile(ctx).Get(startID)
startMF, err := s.ds.MediaFile().Get(ctx, startID)
if err != nil {
return nil, fmt.Errorf("getting start media file %s: %w", startID, err)
}
endMF, err := s.ds.MediaFile(ctx).Get(endID)
endMF, err := s.ds.MediaFile().Get(ctx, endID)
if err != nil {
return nil, fmt.Errorf("getting end media file %s: %w", endID, err)
}

View file

@ -312,7 +312,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai
// It checks the DB first (for user-customized values), then falls back to
// the built-in defaults, and finally to fallbackBitrate.
func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int {
if t, err := ds.Transcoding(ctx).FindByFormat(format); err == nil && t.DefaultBitRate > 0 {
if t, err := ds.Transcoding().FindByFormat(ctx, format); err == nil && t.DefaultBitRate > 0 {
return t.DefaultBitRate
}
for _, dt := range consts.DefaultTranscodings {
@ -327,7 +327,7 @@ func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string
// It checks the DB first (for user-customized commands), then falls back to
// the built-in default command. Returns "" if the format is unknown.
func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string {
t, err := ds.Transcoding(ctx).FindByFormat(format)
t, err := ds.Transcoding().FindByFormat(ctx, format)
if err == nil && t.Command != "" {
return t.Command
}
@ -447,7 +447,7 @@ func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile)
}
mf.ProbeData = string(data)
if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil {
if err := s.ds.MediaFile().UpdateProbeData(ctx, mf.ID, mf.ProbeData); err != nil {
log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err)
// Don't fail the decision — we have the data in memory
}

View file

@ -133,7 +133,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req
}
type Stream struct {
ctx context.Context
ctx context.Context //nolint:containedctx // stream outlives the call that built it; Read has no ctx
mf *model.MediaFile
bitRate int
format string

View file

@ -36,7 +36,7 @@ var _ = Describe("MediaStreamer", func() {
conf.Server.CacheFolder = conf.NewDir(cacheDir)
conf.Server.TranscodingCacheSize = "100MB"
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
})
testCache := stream.NewTranscodingCache()
@ -51,7 +51,7 @@ var _ = Describe("MediaStreamer", func() {
var mf *model.MediaFile
BeforeEach(func() {
var err error
mf, err = ds.MediaFile(ctx).Get("123")
mf, err = ds.MediaFile().Get(ctx, "123")
Expect(err).ToNot(HaveOccurred())
})
It("returns a seekable stream if format is 'raw'", func() {
@ -151,7 +151,7 @@ var _ = Describe("MediaStreamer", func() {
var mf *model.MediaFile
BeforeEach(func() {
var err error
mf, err = ds.MediaFile(ctx).Get("123")
mf, err = ds.MediaFile().Get(ctx, "123")
Expect(err).ToNot(HaveOccurred())
})

View file

@ -15,62 +15,40 @@ type PluginUnloader interface {
// User provides business logic for user management with plugin coordination.
type User interface {
NewRepository(ctx context.Context) rest.Repository
Repository() rest.Repository[model.User]
}
type userService struct {
ds model.DataStore
pluginManager PluginUnloader
repo *userRepositoryWrapper
}
// NewUser creates a new User service
func NewUser(ds model.DataStore, pluginManager PluginUnloader) User {
return &userService{
ds: ds,
pluginManager: pluginManager,
repo: &userRepositoryWrapper{
UserRepository: ds.User(),
pluginManager: pluginManager,
},
}
}
// NewRepository returns a REST repository wrapper for user operations.
// Repository returns a REST repository wrapper for user operations.
// The wrapper intercepts Delete operations to coordinate plugin unloading.
func (s *userService) NewRepository(ctx context.Context) rest.Repository {
repo := s.ds.User(ctx)
wrapper := &userRepositoryWrapper{
ctx: ctx,
UserRepository: repo,
pluginManager: s.pluginManager,
}
return wrapper
func (s *userService) Repository() rest.Repository[model.User] {
return s.repo
}
type userRepositoryWrapper struct {
model.UserRepository
ctx context.Context
pluginManager PluginUnloader
}
// Save implements rest.Persistable by delegating to the underlying repository.
func (r *userRepositoryWrapper) Save(entity any) (string, error) {
return r.UserRepository.(rest.Persistable).Save(entity)
}
// Update implements rest.Persistable by delegating to the underlying repository.
func (r *userRepositoryWrapper) Update(id string, entity any, cols ...string) error {
return r.UserRepository.(rest.Persistable).Update(id, entity, cols...)
}
// Delete implements rest.Persistable and coordinates plugin unloading.
func (r *userRepositoryWrapper) Delete(id string) error {
// The underlying repository Delete handles the database cleanup
// including calling cleanupPluginUserReferences
err := r.UserRepository.(rest.Persistable).Delete(id)
if err != nil {
return err
}
// After successful deletion, check if any plugins were auto-disabled
// and need to be unloaded from memory
r.pluginManager.UnloadDisabledPlugins(r.ctx)
return nil
var _ rest.Persistable[model.User] = (*userRepositoryWrapper)(nil)
// Delete unloads plugins even on error: a bulk delete can fail after earlier users were removed
// and their plugins auto-disabled.
func (r *userRepositoryWrapper) Delete(ctx context.Context, ids ...string) error {
err := r.UserRepository.Delete(ctx, ids...)
r.pluginManager.UnloadDisabledPlugins(ctx)
return err
}

View file

@ -29,19 +29,19 @@ var _ = Describe("User Service", func() {
})
Describe("NewRepository", func() {
It("returns a rest.Persistable", func() {
repo := service.NewRepository(ctx)
_, ok := repo.(rest.Persistable)
It("returns a rest.Persistable[model.User]", func() {
repo := service.Repository()
_, ok := repo.(rest.Persistable[model.User])
Expect(ok).To(BeTrue())
})
})
Describe("Delete", func() {
var repo rest.Persistable
var repo rest.Persistable[model.User]
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
r := service.Repository()
repo = r.(rest.Persistable[model.User])
// Add a test user
user := &model.User{
@ -50,37 +50,45 @@ var _ = Describe("User Service", func() {
IsAdmin: false,
}
user.NewPassword = "password"
Expect(userRepo.Put(user)).To(Succeed())
Expect(userRepo.Put(ctx, user)).To(Succeed())
})
It("deletes the user successfully", func() {
err := repo.Delete("user-123")
err := repo.Delete(ctx, "user-123")
Expect(err).NotTo(HaveOccurred())
// Verify user is deleted
_, err = userRepo.Get("user-123")
_, err = userRepo.Get(ctx, "user-123")
Expect(err).To(Equal(model.ErrNotFound))
})
It("calls UnloadDisabledPlugins after successful deletion", func() {
err := repo.Delete("user-123")
err := repo.Delete(ctx, "user-123")
Expect(err).NotTo(HaveOccurred())
Expect(pluginManager.unloadCalls).To(Equal(1))
})
It("does not call UnloadDisabledPlugins when deletion fails", func() {
// Try to delete non-existent user
err := repo.Delete("non-existent")
Expect(err).To(HaveOccurred())
Expect(pluginManager.unloadCalls).To(Equal(0))
It("still calls UnloadDisabledPlugins when deletion fails", func() {
err := repo.Delete(ctx, "non-existent")
Expect(err).To(MatchError(model.ErrNotFound))
Expect(pluginManager.unloadCalls).To(Equal(1))
})
It("unloads plugins when a bulk delete fails after removing earlier users", func() {
err := repo.Delete(ctx, "user-123", "non-existent")
Expect(err).To(MatchError(model.ErrNotFound))
_, err = userRepo.Get(ctx, "user-123")
Expect(err).To(Equal(model.ErrNotFound))
Expect(pluginManager.unloadCalls).To(Equal(1))
})
It("returns error when repository fails", func() {
userRepo.Error = errors.New("database error")
err := repo.Delete("user-123")
err := repo.Delete(ctx, "user-123")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("database error"))
Expect(pluginManager.unloadCalls).To(Equal(0))
Expect(pluginManager.unloadCalls).To(Equal(1))
})
})
})

View file

@ -184,7 +184,7 @@ func isSchemaEmpty(ctx context.Context, db *sql.DB) bool {
}
type logAdapter struct {
ctx context.Context
ctx context.Context //nolint:containedctx // goose logger interface has no ctx
silent bool
}

2
go.mod
View file

@ -9,7 +9,7 @@ require (
github.com/Masterminds/squirrel v1.5.4
github.com/andybalholm/cascadia v1.3.5
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12
github.com/deluan/rest v1.0.1
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36
github.com/djherbis/atime v1.1.0

18
go.sum
View file

@ -31,8 +31,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3h
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb h1:CGVY6RtDsqaleUFogGP03m3a/9OKi3ZMDr5nhm51Emk=
github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12 h1:x4N/tx0XC9zcnipk5rpdI4qaG9JmovHW5HL97XYnO1I=
github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/rest v1.0.1 h1:Enuzzfd88C1/lG6Jqr2NgRreJoClaFuWcOuspeKWHZ8=
github.com/deluan/rest v1.0.1/go.mod h1:r0yO0VgBWOb5Xb7aCPIedtcOuwmA2Oe5lGailC5pvc4=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbMzMmVqPa5mO1u8vMBlybRCW2ieXpA=
@ -109,8 +109,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@ -128,8 +126,6 @@ github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a8
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI=
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
@ -138,7 +134,6 @@ github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -231,13 +226,8 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@ -252,7 +242,6 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
@ -310,7 +299,6 @@ golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
@ -318,7 +306,6 @@ golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
@ -333,7 +320,6 @@ golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=

View file

@ -1,14 +1,15 @@
package model
import (
"context"
"iter"
"math"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/deluan/rest"
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/conf"
)
type Album struct {
@ -137,24 +138,25 @@ type Albums []Album
type AlbumCursor iter.Seq2[Album, error]
type AlbumRepository interface {
CountAll(...QueryOptions) (int64, error)
Exists(id string) (bool, error)
Put(*Album) error
UpdateExternalInfo(*Album) error
Get(id string) (*Album, error)
GetAll(...QueryOptions) (Albums, error)
rest.Repository[Album]
CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
Exists(ctx context.Context, id string) (bool, error)
Put(ctx context.Context, m *Album) error
UpdateExternalInfo(ctx context.Context, m *Album) error
Get(ctx context.Context, id string) (*Album, error)
GetAll(ctx context.Context, options ...QueryOptions) (Albums, error)
// GetSoleAlbumArtistIDsInSubtrees returns the sole album artists of the albums with folders in
// any of the given library-relative subtrees.
GetSoleAlbumArtistIDsInSubtrees(lib Library, paths ...string) ([]string, error)
GetCursor(...QueryOptions) (AlbumCursor, error)
GetYears(libraryIDs ...int) ([]int, error)
GetSoleAlbumArtistIDsInSubtrees(ctx context.Context, lib Library, paths ...string) ([]string, error)
GetCursor(ctx context.Context, options ...QueryOptions) (AlbumCursor, error)
GetYears(ctx context.Context, libraryIDs ...int) ([]int, error)
// The following methods are used exclusively by the scanner:
Touch(ids ...string) error
TouchByMissingFolder() (int64, error)
GetTouchedAlbums(libID int) (AlbumCursor, error)
RefreshPlayCounts() (int64, error)
CopyAttributes(fromID, toID string, columns ...string) error
Touch(ctx context.Context, ids ...string) error
TouchByMissingFolder(ctx context.Context) (int64, error)
GetTouchedAlbums(ctx context.Context, libID int) (AlbumCursor, error)
RefreshPlayCounts(ctx context.Context) (int64, error)
CopyAttributes(ctx context.Context, fromID, toID string, columns ...string) error
AnnotatedRepository
SearchableRepository[Albums]

View file

@ -1,6 +1,9 @@
package model
import "time"
import (
"context"
"time"
)
type Annotations struct {
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
@ -13,8 +16,8 @@ type Annotations struct {
}
type AnnotatedRepository interface {
IncPlayCount(itemID string, ts time.Time) error
SetStar(starred bool, itemIDs ...string) error
SetRating(rating int, itemID string) error
ReassignAnnotation(prevID string, newID string) error
IncPlayCount(ctx context.Context, itemID string, ts time.Time) error
SetStar(ctx context.Context, starred bool, itemIDs ...string) error
SetRating(ctx context.Context, rating int, itemID string) error
ReassignAnnotation(ctx context.Context, prevID string, newID string) error
}

View file

@ -1,11 +1,13 @@
package model
import (
"context"
"iter"
"maps"
"slices"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/consts"
)
@ -84,18 +86,19 @@ type ArtistIndexes []ArtistIndex
type ArtistCursor iter.Seq2[Artist, error]
type ArtistRepository interface {
CountAll(options ...QueryOptions) (int64, error)
Exists(id string) (bool, error)
Put(m *Artist, colsToUpdate ...string) error
UpdateExternalInfo(a *Artist) error
Get(id string) (*Artist, error)
GetAll(options ...QueryOptions) (Artists, error)
GetCursor(options ...QueryOptions) (ArtistCursor, error)
GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error)
rest.Repository[Artist]
CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
Exists(ctx context.Context, id string) (bool, error)
Put(ctx context.Context, m *Artist, colsToUpdate ...string) error
UpdateExternalInfo(ctx context.Context, a *Artist) error
Get(ctx context.Context, id string) (*Artist, error)
GetAll(ctx context.Context, options ...QueryOptions) (Artists, error)
GetCursor(ctx context.Context, options ...QueryOptions) (ArtistCursor, error)
GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error)
// The following methods are used exclusively by the scanner:
RefreshPlayCounts() (int64, error)
RefreshStats(allArtists bool) (int64, error)
RefreshPlayCounts(ctx context.Context) (int64, error)
RefreshStats(ctx context.Context, allArtists bool) (int64, error)
AnnotatedRepository
SearchableRepository[Artists]

View file

@ -1,6 +1,9 @@
package model
import "time"
import (
"context"
"time"
)
// Artwork is one unique image, identified by the XXH3-64 hash of its bytes.
type Artwork struct {
@ -116,60 +119,60 @@ const (
// Delete* takes the rows to remove; Purge* finds them itself and reports how many went.
type ArtworkRepository interface {
GetImage(hash string) (*Artwork, error)
PutImage(a *Artwork) error
GetImage(ctx context.Context, hash string) (*Artwork, error)
PutImage(ctx context.Context, a *Artwork) error
// PurgeOrphans deletes rows referenced by no item_artwork row and older than cutoff.
PurgeOrphans(createdBefore time.Time) (int64, error)
GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error)
PutItemArtwork(ia *ItemArtwork) error
PurgeOrphans(ctx context.Context, createdBefore time.Time) (int64, error)
GetItemArtwork(ctx context.Context, kind Kind, id, imageType string) (*ItemArtwork, error)
PutItemArtwork(ctx context.Context, ia *ItemArtwork) error
// PutLastFailure records the trace of the attempt that exhausted the retry budget.
PutLastFailure(kind Kind, id, imageType, trace string) error
DeleteForItems(kind Kind, ids []string) error
PutLastFailure(ctx context.Context, kind Kind, id, imageType, trace string) error
DeleteForItems(ctx context.Context, kind Kind, ids []string) error
// GetInfoForItems hydrates a page in one batched query.
GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error)
GetInfoForItems(ctx context.Context, kind Kind, ids []string) (map[string]ItemArtworkInfo, error)
// GetMimeByHash returns hash -> current mime for every stored artwork.
GetMimeByHash() (map[string]string, error)
GetMimeByHash(ctx context.Context) (map[string]string, error)
// PurgeDanglingItems removes state rows whose entity no longer exists.
PurgeDanglingItems() (int64, error)
PurgeDanglingItems(ctx context.Context) (int64, error)
}
type ArtworkQueueRepository interface {
// Get returns the pending row for an item, or ErrNotFound when it is not queued.
Get(kind Kind, id, imageType string) (*ArtworkQueueItem, error)
Get(ctx context.Context, kind Kind, id, imageType string) (*ArtworkQueueItem, error)
// Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset.
Enqueue(items ...ArtworkQueueItem) error
Enqueue(ctx context.Context, items ...ArtworkQueueItem) error
// EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a
// request-triggered read-through never resets a failed resolution's backoff.
EnqueuePreservingBackoff(items ...ArtworkQueueItem) error
EnqueuePreservingBackoff(ctx context.Context, items ...ArtworkQueueItem) error
// EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority.
EnqueueAllMissing(kind Kind, priority int) (int64, error)
EnqueueAllMissing(ctx context.Context, kind Kind, priority int) (int64, error)
// EnqueueIfMissing inserts only for items with no item_artwork row yet.
EnqueueIfMissing(items ...ArtworkQueueItem) error
EnqueueIfMissing(ctx context.Context, items ...ArtworkQueueItem) error
// CountBySource reports how many items of a kind currently resolve from the given sources.
// An empty sources slice means every source; "" matches absent state, and the pseudo-source
// ArtworkSourceFailed matches the absent states that gave up.
CountBySource(kind Kind, sources []string) (int64, error)
CountBySource(ctx context.Context, kind Kind, sources []string) (int64, error)
// SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included.
SourcesInUse(kind Kind) ([]string, error)
SourcesInUse(ctx context.Context, kind Kind) ([]string, error)
// EnqueueBySource inserts queue rows for items of a kind whose current source matches.
// It does not clear existing artwork state: the current image stays until it is replaced.
EnqueueBySource(kind Kind, sources []string, priority int) (int64, error)
EnqueueBySource(ctx context.Context, kind Kind, sources []string, priority int) (int64, error)
// DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc.
// Restricted to the given kinds when any are passed, so one kind cannot block another's drain.
DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error)
DequeueBatch(ctx context.Context, n int, kinds ...string) ([]ArtworkQueueItem, error)
// MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches
// seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility.
MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error
MarkFailedIfUnchanged(ctx context.Context, kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error
// DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue.
DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error
Count() (int64, error)
DeleteIfUnchanged(ctx context.Context, kind, id, imageType string, retryAt time.Time) error
Count(ctx context.Context) (int64, error)
// CountQueued reports the pending rows matching the kinds and priorities, grouped by both;
// an empty filter means every one.
CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error)
CountQueued(ctx context.Context, kinds []Kind, priorities []int) ([]ArtworkQueueStat, error)
// PurgeDangling removes queue rows whose entity no longer exists.
PurgeDangling() (int64, error)
PurgeDangling(ctx context.Context) (int64, error)
// PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one.
PurgeQueued(kinds []Kind, priorities []int) (int64, error)
PurgeQueued(ctx context.Context, kinds []Kind, priorities []int) (int64, error)
}
type ArtworkQueueStat struct {

View file

@ -1,15 +1,18 @@
package model
import "time"
import (
"context"
"time"
)
type Bookmarkable struct {
BookmarkPosition int64 `structs:"-" json:"bookmarkPosition"`
}
type BookmarkableRepository interface {
AddBookmark(id, comment string, position int64) error
DeleteBookmark(id string) error
GetBookmarks() (Bookmarks, error)
AddBookmark(ctx context.Context, id, comment string, position int64) error
DeleteBookmark(ctx context.Context, id string) error
GetBookmarks(ctx context.Context) (Bookmarks, error)
}
type Bookmark struct {

View file

@ -4,7 +4,6 @@ import (
"context"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
)
type QueryOptions struct {
@ -16,34 +15,28 @@ type QueryOptions struct {
Seed string // for random sorting
}
type ResourceRepository interface {
rest.Repository
}
type DataStore interface {
Library(ctx context.Context) LibraryRepository
Folder(ctx context.Context) FolderRepository
Album(ctx context.Context) AlbumRepository
Artist(ctx context.Context) ArtistRepository
MediaFile(ctx context.Context) MediaFileRepository
Genre(ctx context.Context) GenreRepository
Tag(ctx context.Context) TagRepository
Playlist(ctx context.Context) PlaylistRepository
PlayQueue(ctx context.Context) PlayQueueRepository
Transcoding(ctx context.Context) TranscodingRepository
Player(ctx context.Context) PlayerRepository
Radio(ctx context.Context) RadioRepository
Share(ctx context.Context) ShareRepository
Property(ctx context.Context) PropertyRepository
User(ctx context.Context) UserRepository
UserProps(ctx context.Context) UserPropsRepository
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
Scrobble(ctx context.Context) ScrobbleRepository
Plugin(ctx context.Context) PluginRepository
Artwork(ctx context.Context) ArtworkRepository
ArtworkQueue(ctx context.Context) ArtworkQueueRepository
Resource(ctx context.Context, model any) ResourceRepository
Library() LibraryRepository
Folder() FolderRepository
Album() AlbumRepository
Artist() ArtistRepository
MediaFile() MediaFileRepository
Genre() GenreRepository
Tag() TagRepository
Playlist() PlaylistRepository
PlayQueue() PlayQueueRepository
Transcoding() TranscodingRepository
Player() PlayerRepository
Radio() RadioRepository
Share() ShareRepository
Property() PropertyRepository
User() UserRepository
UserProps() UserPropsRepository
ScrobbleBuffer() ScrobbleBufferRepository
Scrobble() ScrobbleRepository
Plugin() PluginRepository
Artwork() ArtworkRepository
ArtworkQueue() ArtworkQueueRepository
WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(block func(tx DataStore) error, scope ...string) error

View file

@ -1,6 +1,7 @@
package model
import (
"context"
"fmt"
"iter"
"os"
@ -83,19 +84,19 @@ type FolderUpdateInfo struct {
}
type FolderRepository interface {
Get(id string) (*Folder, error)
GetByPath(lib Library, path string) (*Folder, error)
GetAll(...QueryOptions) ([]Folder, error)
CountAll(...QueryOptions) (int64, error)
GetFolderUpdateInfo(lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error)
Get(ctx context.Context, id string) (*Folder, error)
GetByPath(ctx context.Context, lib Library, path string) (*Folder, error)
GetAll(ctx context.Context, options ...QueryOptions) ([]Folder, error)
CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
GetFolderUpdateInfo(ctx context.Context, lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error)
// HasAudioOutsideFolders reports whether any folder in parent's subtree
// (including parent itself) contains audio files and is not one of the
// given folder IDs.
HasAudioOutsideFolders(parent Folder, excludeFolderIDs []string) (bool, error)
Put(*Folder) error
MarkMissing(missing bool, ids ...string) error
GetTouchedWithPlaylists() (FolderCursor, error)
HasAudioOutsideFolders(ctx context.Context, parent Folder, excludeFolderIDs []string) (bool, error)
Put(ctx context.Context, f *Folder) error
MarkMissing(ctx context.Context, missing bool, ids ...string) error
GetTouchedWithPlaylists(ctx context.Context) (FolderCursor, error)
// GetAllWithPlaylists returns all non-missing folders with playlists, ignoring
// the scan-timestamp gate used by GetTouchedWithPlaylists.
GetAllWithPlaylists() (FolderCursor, error)
GetAllWithPlaylists(ctx context.Context) (FolderCursor, error)
}

View file

@ -1,5 +1,11 @@
package model
import (
"context"
"github.com/deluan/rest"
)
type Genre struct {
ID string `structs:"id" json:"id,omitempty" toml:"id,omitempty" yaml:"id,omitempty"`
Name string `structs:"name" json:"name"`
@ -10,6 +16,7 @@ type Genre struct {
type Genres []Genre
type GenreRepository interface {
GetAll(...QueryOptions) (Genres, error)
Get(id string) (*Genre, error)
rest.Repository[Genre]
GetAll(ctx context.Context, options ...QueryOptions) (Genres, error)
Get(ctx context.Context, id string) (*Genre, error)
}

View file

@ -23,11 +23,11 @@ func getEntity(ctx context.Context, ds DataStore, id string) (any, Kind, error)
kind Kind
get func() (any, error)
}{
{KindArtistArtwork, func() (any, error) { return ds.Artist(ctx).Get(id) }},
{KindAlbumArtwork, func() (any, error) { return ds.Album(ctx).Get(id) }},
{KindPlaylistArtwork, func() (any, error) { return ds.Playlist(ctx).Get(id) }},
{KindMediaFileArtwork, func() (any, error) { return ds.MediaFile(ctx).Get(id) }},
{KindRadioArtwork, func() (any, error) { return ds.Radio(ctx).Get(id) }},
{KindArtistArtwork, func() (any, error) { return ds.Artist().Get(ctx, id) }},
{KindAlbumArtwork, func() (any, error) { return ds.Album().Get(ctx, id) }},
{KindPlaylistArtwork, func() (any, error) { return ds.Playlist().Get(ctx, id) }},
{KindMediaFileArtwork, func() (any, error) { return ds.MediaFile().Get(ctx, id) }},
{KindRadioArtwork, func() (any, error) { return ds.Radio().Get(ctx, id) }},
}
for _, g := range getters {
entity, err := g.get()

View file

@ -19,7 +19,7 @@ var _ = Describe("GetEntityByID", func() {
})
It("returns the entity matching the id", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
entity, err := model.GetEntityByID(ctx, ds, "a1")
Expect(err).ToNot(HaveOccurred())
Expect(entity).To(BeAssignableToTypeOf(&model.Album{}))
@ -32,7 +32,7 @@ var _ = Describe("GetEntityByID", func() {
})
It("propagates unexpected repository errors instead of reporting not-found", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true)
ds.Album().(*tests.MockAlbumRepo).SetError(true)
_, err := model.GetEntityByID(ctx, ds, "a1")
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(model.ErrNotFound))
@ -49,7 +49,7 @@ var _ = Describe("GetEntityKindByID", func() {
})
It("returns the artwork kind for the matching id", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1"}})
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1"}})
kind, err := model.GetEntityKindByID(ctx, ds, "a1")
Expect(err).ToNot(HaveOccurred())
Expect(kind).To(Equal(model.KindAlbumArtwork))

View file

@ -1,8 +1,10 @@
package model
import (
"context"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/utils/slice"
)
@ -39,23 +41,24 @@ func (l Libraries) IDs() []int {
}
type LibraryRepository interface {
Get(id int) (*Library, error)
rest.Repository[Library]
Get(ctx context.Context, id int) (*Library, error)
// GetPath returns the path of the library with the given ID.
// Its implementation must be optimized to avoid unnecessary queries.
GetPath(id int) (string, error)
GetAll(...QueryOptions) (Libraries, error)
CountAll(...QueryOptions) (int64, error)
Put(l *Library, colsToUpdate ...string) error
Delete(id int) error
StoreMusicFolder() error
AddArtist(id int, artistID string) error
GetPath(ctx context.Context, id int) (string, error)
GetAll(ctx context.Context, options ...QueryOptions) (Libraries, error)
CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
Put(ctx context.Context, l *Library, colsToUpdate ...string) error
Delete(ctx context.Context, id int) error
StoreMusicFolder(ctx context.Context) error
AddArtist(ctx context.Context, id int, artistID string) error
// User-library association methods
GetUsersWithLibraryAccess(libraryID int) (Users, error)
GetUsersWithLibraryAccess(ctx context.Context, libraryID int) (Users, error)
// TODO These methods should be moved to a core service
ScanBegin(id int, fullScan bool) error
ScanEnd(id int) error
ScanInProgress() (bool, error)
RefreshStats(id int) error
ScanBegin(ctx context.Context, id int, fullScan bool) error
ScanEnd(ctx context.Context, id int) error
ScanInProgress(ctx context.Context) (bool, error)
RefreshStats(ctx context.Context, id int) error
}

View file

@ -2,6 +2,7 @@ package model
import (
"cmp"
"context"
"encoding/json"
"fmt"
"iter"
@ -11,6 +12,7 @@ import (
"strings"
"time"
"github.com/deluan/rest"
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@ -537,42 +539,43 @@ func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string {
type MediaFileCursor iter.Seq2[MediaFile, error]
type MediaFileRepository interface {
CountAll(options ...QueryOptions) (int64, error)
CountBySuffix(options ...QueryOptions) (map[string]int64, error)
Exists(id string) (bool, error)
Put(m *MediaFile) error
UpdateProbeData(id string, data string) error
Get(id string) (*MediaFile, error)
GetWithParticipants(id string) (*MediaFile, error)
GetAll(options ...QueryOptions) (MediaFiles, error)
rest.Repository[MediaFile]
CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
CountBySuffix(ctx context.Context, options ...QueryOptions) (map[string]int64, error)
Exists(ctx context.Context, id string) (bool, error)
Put(ctx context.Context, m *MediaFile) error
UpdateProbeData(ctx context.Context, id string, data string) error
Get(ctx context.Context, id string) (*MediaFile, error)
GetWithParticipants(ctx context.Context, id string) (*MediaFile, error)
GetAll(ctx context.Context, options ...QueryOptions) (MediaFiles, error)
// GetRandom returns up to options.Max media files in random order, applying the same
// filters as GetAll. Sort/Order are ignored.
GetRandom(options ...QueryOptions) (MediaFiles, error)
GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error)
GetRandom(ctx context.Context, options ...QueryOptions) (MediaFiles, error)
GetAllByTags(ctx context.Context, tag TagName, values []string, options ...QueryOptions) (MediaFiles, error)
// MatchesCriteria reports whether the media file matches the criteria's rule
// expression, using the logged user's annotations. Limit and offset are ignored.
MatchesCriteria(id string, c criteria.Criteria) (bool, error)
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
MatchesCriteria(ctx context.Context, id string, c criteria.Criteria) (bool, error)
GetCursor(ctx context.Context, options ...QueryOptions) (MediaFileCursor, error)
// GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given
// folders or their direct children.
GetAlbumIDsByFolder(lib Library, folderIDs ...string) ([]string, error)
GetAlbumIDsByFolder(ctx context.Context, lib Library, folderIDs ...string) ([]string, error)
// GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't
// pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors.
GetCursorWithArtwork(options ...QueryOptions) (MediaFileCursor, error)
Delete(id string) error
DeleteMissing(ids []string) error
DeleteAllMissing() (int64, error)
FindByPaths(paths []string) (MediaFiles, error)
GetCursorWithArtwork(ctx context.Context, options ...QueryOptions) (MediaFileCursor, error)
Delete(ctx context.Context, id string) error
DeleteMissing(ctx context.Context, ids []string) error
DeleteAllMissing(ctx context.Context) (int64, error)
FindByPaths(ctx context.Context, paths []string) (MediaFiles, error)
// ReassignReferences moves annotations, bookmarks and playlist entries from prevID to newID,
// keeping newID's own row wherever a user has both.
ReassignReferences(prevID, newID string) error
ReassignReferences(ctx context.Context, prevID, newID string) error
// The following methods are used exclusively by the scanner:
MarkMissing(bool, ...*MediaFile) error
MarkMissingByFolder(missing bool, folderIDs ...string) error
GetMissingAndMatching(libId int) (MediaFileCursor, error)
FindRecentFilesByMBZTrackID(missing MediaFile, since time.Time) (MediaFiles, error)
FindRecentFilesByProperties(missing MediaFile, since time.Time) (MediaFiles, error)
MarkMissing(ctx context.Context, missing bool, mfs ...*MediaFile) error
MarkMissingByFolder(ctx context.Context, missing bool, folderIDs ...string) error
GetMissingAndMatching(ctx context.Context, libId int) (MediaFileCursor, error)
FindRecentFilesByMBZTrackID(ctx context.Context, missing MediaFile, since time.Time) (MediaFiles, error)
FindRecentFilesByProperties(ctx context.Context, missing MediaFile, since time.Time) (MediaFiles, error)
AnnotatedRepository
BookmarkableRepository

View file

@ -1,7 +1,10 @@
package model
import (
"context"
"time"
"github.com/deluan/rest"
)
type Player struct {
@ -23,9 +26,11 @@ type Player struct {
type Players []Player
type PlayerRepository interface {
Get(id string) (*Player, error)
FindMatch(userId, client, userAgent string) (*Player, error)
Put(p *Player) error
CountAll(...QueryOptions) (int64, error)
CountByClient(...QueryOptions) (map[string]int64, error)
rest.Repository[Player]
rest.Persistable[Player]
Get(ctx context.Context, id string) (*Player, error)
FindMatch(ctx context.Context, userId, client, userAgent string) (*Player, error)
Put(ctx context.Context, p *Player) error
CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
CountByClient(ctx context.Context, options ...QueryOptions) (map[string]int64, error)
}

Some files were not shown because too many files have changed in this diff Show more