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
218 lines
5.9 KiB
Go
218 lines
5.9 KiB
Go
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
|
|
}
|
|
}
|