feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index pipeline (dump import, artifact fetch/build, incremental listen-count refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/ slskd/yt-dlp providers, staging, reconciliation, wanted list), and the supporting schema/query/store changes across backend and frontend. Also includes two smaller follow-ups: bump the central index's rebuild-after cadence from 90 to 180 days, and remove the Explore "library only" online/offline toggle entirely (frontend-only, no backend counterpart) rather than carry unused UI/state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"yellowjacket/backend/download"
|
||||
"yellowjacket/backend/explore"
|
||||
)
|
||||
|
||||
// exploreCatalog adapts the explore service to the narrow view of the
|
||||
// music world the wanted list needs.
|
||||
//
|
||||
// The adapter lives here, in the composition root, rather than in
|
||||
// either package: explore should not know that downloads exist, and
|
||||
// download should not pull in the whole explore index to ask four
|
||||
// questions. Everything below is a translation, with no policy of its
|
||||
// own — policy belongs to the reconciler.
|
||||
type exploreCatalog struct {
|
||||
explore *explore.Service
|
||||
}
|
||||
|
||||
// newExploreCatalog wires the wanted list to the explore index.
|
||||
func newExploreCatalog(e *explore.Service) download.CatalogPort {
|
||||
return &exploreCatalog{explore: e}
|
||||
}
|
||||
|
||||
// ReleaseGroupsForArtist returns an artist's discography.
|
||||
//
|
||||
// An empty result is not an error. The explore index fetches
|
||||
// discographies lazily, so the first time an artist is subscribed to
|
||||
// the honest answer is "not indexed yet" — and BrowseReleaseGroups has
|
||||
// already kicked off the background fetch that makes the next pass
|
||||
// useful.
|
||||
func (c *exploreCatalog) ReleaseGroupsForArtist(
|
||||
_ context.Context,
|
||||
artistMBID string,
|
||||
) ([]download.CatalogItem, error) {
|
||||
groups, err := c.explore.BrowseReleaseGroups(artistMBID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]download.CatalogItem, 0, len(groups))
|
||||
|
||||
for _, rg := range groups {
|
||||
out = append(out, download.CatalogItem{
|
||||
MBID: rg.MBID,
|
||||
Title: rg.Title,
|
||||
Artist: rg.ArtistCredit,
|
||||
ArtistMBID: rg.ArtistMBID,
|
||||
PrimaryType: rg.PrimaryType,
|
||||
SecondaryTypes: rg.SecondaryTypes,
|
||||
FirstReleaseDate: rg.FirstReleaseDate,
|
||||
InLibrary: rg.InLibrary,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Tracklist resolves what a want should contain.
|
||||
//
|
||||
// For an album this is the tracklist of its best release, which is what
|
||||
// the download pipeline verifies an unattended grab against. For a
|
||||
// single track it is that one track, built from the want's own cached
|
||||
// title — there is no recording lookup on the index, and inventing one
|
||||
// to learn a title the UI already passed in would be work for its own
|
||||
// sake.
|
||||
func (c *exploreCatalog) Tracklist(
|
||||
_ context.Context,
|
||||
entity download.Entity,
|
||||
mbid string,
|
||||
) ([]download.ExpectedTrack, error) {
|
||||
if entity == download.EntityRecording {
|
||||
// Handled by the reconciler from the want's own fields; see
|
||||
// recordingTracklist below.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
releases, err := c.explore.BrowseReleases(mbid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
best := bestRelease(releases, mbid)
|
||||
if best == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
out := make([]download.ExpectedTrack, 0, len(best.Tracks))
|
||||
|
||||
for _, t := range best.Tracks {
|
||||
out = append(out, download.ExpectedTrack{
|
||||
Position: t.Position,
|
||||
DiscNumber: t.DiscNumber,
|
||||
Title: t.Title,
|
||||
Artist: best.ArtistCredit,
|
||||
LengthMillis: int64(t.Length),
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// bestRelease picks which edition of an album to match a download
|
||||
// against.
|
||||
//
|
||||
// When the want named a specific release, that one. Otherwise the one
|
||||
// with the most tracks that still has a tracklist at all: a download
|
||||
// scored against a truncated tracklist looks incomplete when it is
|
||||
// fine, and a false "missing tracks" reading is what stops a good
|
||||
// candidate from clearing the auto-pick bar.
|
||||
func bestRelease(releases []explore.MBRelease, wantedMBID string) *explore.MBRelease {
|
||||
var best *explore.MBRelease
|
||||
|
||||
for i := range releases {
|
||||
r := &releases[i]
|
||||
|
||||
if r.MBID == wantedMBID && len(r.Tracks) > 0 {
|
||||
return r
|
||||
}
|
||||
|
||||
if len(r.Tracks) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if best == nil || len(r.Tracks) > len(best.Tracks) {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
// Owns reports whether the library already has what an MBID names.
|
||||
//
|
||||
// Releases are the gap: the library records release groups and
|
||||
// recordings, not specific editions, so a want for one particular
|
||||
// pressing is only retired when its own download completes. That is
|
||||
// the right failure — quietly satisfying a "want the 1997 Japanese
|
||||
// pressing" because some edition is owned would be answering a
|
||||
// different question than the one asked.
|
||||
func (c *exploreCatalog) Owns(
|
||||
_ context.Context,
|
||||
entity download.Entity,
|
||||
mbid string,
|
||||
) (bool, error) {
|
||||
if mbid == "" || entity == download.EntityRelease {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
found := c.explore.CheckLibraryMBIDs([]string{mbid})
|
||||
|
||||
kind, ok := found[mbid]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
switch entity {
|
||||
case download.EntityReleaseGroup:
|
||||
return kind == "release_group", nil
|
||||
case download.EntityRecording:
|
||||
return kind == "recording", nil
|
||||
case download.EntityArtist:
|
||||
return kind == "artist", nil
|
||||
case download.EntityRelease:
|
||||
return false, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Describe fills in display text for a want added as a bare MBID.
|
||||
func (c *exploreCatalog) Describe(
|
||||
_ context.Context,
|
||||
entity download.Entity,
|
||||
mbid string,
|
||||
) (download.CatalogItem, bool) {
|
||||
switch entity {
|
||||
case download.EntityArtist:
|
||||
artist, err := c.explore.LookupArtist(mbid)
|
||||
if err != nil || artist == nil {
|
||||
return download.CatalogItem{}, false
|
||||
}
|
||||
|
||||
return download.CatalogItem{
|
||||
MBID: mbid,
|
||||
Artist: artist.Name,
|
||||
Title: artist.Name,
|
||||
}, true
|
||||
|
||||
case download.EntityReleaseGroup, download.EntityRelease:
|
||||
rg, err := c.explore.LookupReleaseGroup(mbid)
|
||||
if err != nil || rg == nil {
|
||||
return download.CatalogItem{}, false
|
||||
}
|
||||
|
||||
return download.CatalogItem{
|
||||
MBID: mbid,
|
||||
Title: rg.Title,
|
||||
Artist: rg.ArtistCredit,
|
||||
ArtistMBID: rg.ArtistMBID,
|
||||
PrimaryType: rg.PrimaryType,
|
||||
SecondaryTypes: rg.SecondaryTypes,
|
||||
FirstReleaseDate: rg.FirstReleaseDate,
|
||||
InLibrary: rg.InLibrary,
|
||||
}, true
|
||||
|
||||
case download.EntityRecording:
|
||||
// No recording lookup on the index; the caller keeps whatever
|
||||
// title it was given.
|
||||
return download.CatalogItem{}, false
|
||||
|
||||
default:
|
||||
return download.CatalogItem{}, false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user