Files
yellowjacket/backend/download/types.go
T
yonluandClaude Sonnet 5 cbd82a5a74
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
feat: autotag mixed-bag splitting, search relevance fixes, and multi-library download imports
Autotag: detect "junk drawer" folders with no artist/album consensus
and split them into synthetic per-cluster groups instead of forcing
one match on an unrelated pile of tracks; repair tagging_items rows
left behind by a prior scan orphan-cleanup gap.

Explore: fix an exact artist-name search being drowned out by its own
catalog entries in intent-prior scoring, and prune stale in_library
bookkeeping left behind when a referenced library row is deleted.

Download: fix a multi-library regression where every import failed
with "no library root configured" — the importer resolved the
library root from a legacy single-library config field that nothing
populates in the current multi-library model. It now resolves the
destination library per-request from the request's own library_id.
Also widen the Soulseek search window (12s -> 20s), measured against
real request history to be missing available peers on live queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-10 11:52:26 -04:00

386 lines
13 KiB
Go

// Package download acquires music from user-configured external
// services and imports it into the library.
//
// The services users connect are not the same kind of thing: some
// search, some move bytes, some are whole automation systems we hand a
// request to. Rather than one interface every adapter half-implements,
// a provider fills one or more of three roles — Searcher, Transporter,
// Delegator — and declares which in its Caps. The pipeline composes
// them: a search-only provider (Prowlarr) is paired with a transport
// (qBittorrent, SABnzbd) by protocol at grab time, while providers that
// do both (slskd, yt-dlp) pair with themselves.
//
// Nothing here downloads into the library. Grabs land in a staging
// directory, are verified and tagged against the release the user
// actually asked for, and only then move into library paths.
package download
import (
"slices"
"strings"
"time"
)
// Kind identifies a provider implementation. It is stored in the
// database and used to look up the constructor in the registry, so
// values are stable strings and never renamed.
type Kind string
// Provider kinds.
const (
KindSlskd Kind = "slskd"
KindYtDlp Kind = "yt-dlp"
KindLidarr Kind = "lidarr"
KindProwlarr Kind = "prowlarr"
KindQBittorrent Kind = "qbittorrent"
KindSABnzbd Kind = "sabnzbd"
// KindFake is an in-memory provider used by tests. It is never
// offered in the UI.
KindFake Kind = "fake"
)
// Protocol is how a candidate's bytes are moved. Search-only providers
// report it so the pipeline can pick a compatible transport; providers
// that transport their own results use ProtocolDirect.
type Protocol string
// Transport protocols.
const (
// ProtocolDirect means the finding provider also does the fetch.
ProtocolDirect Protocol = "direct"
ProtocolTorrent Protocol = "torrent"
ProtocolUsenet Protocol = "usenet"
)
// Caps declares which roles a provider fills and which optional
// behaviours it supports. The frontend renders controls from this
// rather than switching on Kind, so a provider that gains resume
// support later needs no frontend change.
type Caps struct {
// Roles.
CanSearch bool `json:"canSearch"`
CanTransport bool `json:"canTransport"`
CanDelegate bool `json:"canDelegate"`
// CanList marks a provider that keeps a persistent wanted list of
// its own, which the reconciler mirrors this app's list into.
CanList bool `json:"canList"`
// Optional behaviours.
CanResume bool `json:"canResume"`
CanCancel bool `json:"canCancel"`
ReportsSize bool `json:"reportsSize"`
// Protocols this provider can transport. Empty for providers that
// only fetch their own search results.
Transports []Protocol `json:"transports"`
}
// Handles reports whether the provider can transport the given protocol.
func (c Caps) Handles(p Protocol) bool {
return slices.Contains(c.Transports, p)
}
// Request is what the user asked for. Requests that carry a MusicBrainz
// anchor are far more reliable than free-text ones, because the anchor
// gives the import step an expected tracklist to match against — so the
// pipeline records which it got and refuses to auto-pick without one.
type Request struct {
ID string `json:"id"`
// Anchors. Any may be empty; all empty means free-text.
ReleaseMBID string `json:"releaseMbid,omitempty"`
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
// RecordingMBID anchors a single-track request. Its Expected holds
// exactly that one track, which is what lets a track request be
// scored — and therefore auto-picked — on the same footing as an
// album.
RecordingMBID string `json:"recordingMbid,omitempty"`
// WantID links back to the wanted-list row this request was raised
// for, or 0 for a request the user started by hand. The reconciler
// writes the outcome back through it.
WantID int64 `json:"wantId,omitempty"`
// Source records where the request came from, for the downloads
// list. Empty means "manual".
Source string `json:"source,omitempty"`
// Display and query text. Artist/Album are what searches are built
// from; Query overrides them when the user typed something raw.
Artist string `json:"artist"`
Album string `json:"album"`
Query string `json:"query,omitempty"`
// Expected is the tracklist the anchor resolves to, used for
// completeness scoring and for the autotag match at import. Empty
// for free-text requests.
Expected []ExpectedTrack `json:"expected,omitempty"`
// LibraryID is the library imported files belong to.
LibraryID int64 `json:"libraryId"`
CreatedAt time.Time `json:"createdAt"`
}
// Anchored reports whether the request carries a MusicBrainz ID. Only
// anchored requests are eligible for auto-pick.
func (r Request) Anchored() bool {
return r.ReleaseMBID != "" ||
r.ReleaseGroupMBID != "" ||
r.RecordingMBID != ""
}
// SearchText returns the string to hand a provider's search endpoint.
//
// Album titles routinely start with the artist name — self-titled
// albums ("Boston" / "Boston") and titles like "Blank Banshee 0" both
// do — so naively concatenating Artist and Album would search for
// "Blank Banshee Blank Banshee 0". That repeated term is enough to
// return zero results on providers that expect every term to appear
// in a match (Soulseek in particular), so the artist is dropped when
// the album title already leads with it.
func (r Request) SearchText() string {
if r.Query != "" {
return r.Query
}
if r.Artist != "" && albumLeadsWithArtist(r.Artist, r.Album) {
return strings.TrimSpace(r.Album)
}
return strings.TrimSpace(r.Artist + " " + r.Album)
}
// albumLeadsWithArtist reports whether album starts with artist as a
// whole word, case-insensitively, so it is safe to drop the artist
// from a combined query without losing a real search term. A plain
// substring check would misfire on cases like artist "Air" against
// album "Repair".
func albumLeadsWithArtist(artist, album string) bool {
a, b := strings.ToLower(strings.TrimSpace(artist)), strings.ToLower(strings.TrimSpace(album))
if a == "" || !strings.HasPrefix(b, a) {
return false
}
rest := b[len(a):]
return rest == "" || !isWordChar(rune(rest[0]))
}
// isWordChar reports whether r continues a word for the purposes of
// albumLeadsWithArtist's boundary check.
func isWordChar(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
}
// ExpectedTrack is one track of the release the user asked for.
type ExpectedTrack struct {
Position int `json:"position"`
DiscNumber int `json:"discNumber"`
Title string `json:"title"`
Artist string `json:"artist"`
LengthMillis int64 `json:"lengthMillis"`
}
// Candidate is one acquirable thing a provider found: a Soulseek user's
// folder, a torrent, a YouTube playlist. Providers fill the descriptive
// fields; the ranker fills Match, Quality and Score.
type Candidate struct {
// ID is unique within the provider that produced it, and is what
// gets handed back to Grab.
ID string `json:"id"`
ProviderID int64 `json:"providerId"`
Kind Kind `json:"kind"`
// Protocol determines which transport can fetch this.
Protocol Protocol `json:"protocol"`
// Descriptive.
Title string `json:"title"`
Artist string `json:"artist,omitempty"`
Origin string `json:"origin,omitempty"` // peer username, indexer name, channel
Files []CandidateFile `json:"files"`
TotalSize int64 `json:"totalSize"`
// Health is the provider's own availability signal, normalized to
// 0..1: seeder count for torrents, free upload slots and queue
// length for Soulseek. 0.5 when the provider has no signal.
Health float64 `json:"health"`
// Scores, filled by the ranker.
Match MatchScore `json:"match"`
Quality QualityScore `json:"quality"`
Score float64 `json:"score"`
// Payload is provider-private data needed to fetch this candidate
// (magnet URI, NZB URL, slskd file list). Never shown to the user.
Payload map[string]string `json:"-"`
}
// CandidateFile is one file inside a candidate. Soulseek and torrent
// results give paths and sizes but no tags, so Format and duration are
// inferred from the path and size where possible.
type CandidateFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
Format Format `json:"format"`
Bitrate int `json:"bitrate,omitempty"` // kbps, 0 when unknown
IsAudio bool `json:"isAudio"`
MatchedTo int `json:"matchedTo,omitempty"` // expected track position
}
// Format is a normalized audio container/codec name.
type Format string
// Audio formats, ordered by the quality ranking in formatRank.
const (
FormatUnknown Format = ""
FormatFLAC Format = "flac"
FormatALAC Format = "alac"
FormatWAV Format = "wav"
FormatMP3 Format = "mp3"
FormatAAC Format = "aac"
FormatOGG Format = "ogg"
FormatOpus Format = "opus"
FormatWMA Format = "wma"
)
// Lossless reports whether the format preserves the source exactly.
func (f Format) Lossless() bool {
return f == FormatFLAC || f == FormatALAC || f == FormatWAV
}
// Supported reports whether the player can decode this format. Grabs
// of unsupported formats are still allowed — the user may want them —
// but they rank below playable ones.
func (f Format) Supported() bool {
switch f {
case FormatMP3, FormatFLAC, FormatOGG, FormatWAV:
return true
case FormatUnknown, FormatALAC, FormatAAC, FormatOpus, FormatWMA:
return false
default:
return false
}
}
// MatchScore answers "is this the release the user asked for?" It is
// deliberately separate from QualityScore: a perfect match at 128kbps
// and a mediocre match in FLAC are different failures, and collapsing
// them into one number makes the ranking impossible to explain.
type MatchScore struct {
// Overall is 0..1.
Overall float64 `json:"overall"`
TitleFit float64 `json:"titleFit"` // filenames vs expected titles
ArtistFit float64 `json:"artistFit"` // path/origin vs expected artist
AlbumFit float64 `json:"albumFit"` // folder name vs album title
Completeness float64 `json:"completeness"` // audio files vs expected count
// Anchored records whether an MBID drove this score. Unanchored
// matches are capped, because there is nothing to be right about.
Anchored bool `json:"anchored"`
}
// QualityScore answers "is this a good copy?".
type QualityScore struct {
// Overall is 0..1.
Overall float64 `json:"overall"`
FormatRank float64 `json:"formatRank"` // FLAC > V0 > 320 > lower
Bitrate float64 `json:"bitrate"`
Health float64 `json:"health"` // seeders, free slots
Priority float64 `json:"priority"` // user's per-provider preference
// Mixed marks a candidate whose files are not all the same format,
// which usually means a hand-assembled folder rather than a rip.
Mixed bool `json:"mixed"`
}
// AudioFiles returns only the audio entries of a candidate.
func (c Candidate) AudioFiles() []CandidateFile {
out := make([]CandidateFile, 0, len(c.Files))
for _, f := range c.Files {
if f.IsAudio {
out = append(out, f)
}
}
return out
}
// State is the lifecycle position of a download item.
type State string
// Download item states. Searching through Importing are live;
// Complete, Cancelled and Failed are terminal.
const (
StateSearching State = "searching"
StateFound State = "found"
StateQueued State = "queued"
StateGrabbing State = "grabbing"
StateVerifying State = "verifying"
StateTagging State = "tagging"
StateImporting State = "importing"
StateComplete State = "complete"
StateCancelled State = "cancelled"
StateFailed State = "failed"
)
// IsTerminal reports whether the state means no further progress will
// happen without a new attempt.
func (s State) IsTerminal() bool {
return s == StateComplete || s == StateCancelled || s == StateFailed
}
// Progress is a transport's periodic report. Total is 0 when the
// provider cannot say how large the transfer is.
type Progress struct {
Current int64
Total int64
Phase string
}
// ProgressFunc receives transport progress. Implementations must
// tolerate being called from any goroutine and at high frequency.
type ProgressFunc func(Progress)
// Result is what a transport produced.
type Result struct {
// Dir is the staging directory the files landed in.
Dir string
// Files are absolute paths, all under Dir.
Files []string
// BytesTransferred is what actually moved, for reporting.
BytesTransferred int64
// Delegated marks a result produced by an external manager that has
// already imported the files into its own library. Files are then
// absolute paths outside staging, and the pipeline records them
// where they are instead of tagging and moving them.
Delegated bool
}
// DelegateStatus is a delegating manager's answer to "are we there
// yet?".
type DelegateStatus struct {
State State
// Progress is 0..1 when the manager reports it, -1 when it does not.
Progress float64
// ImportedPaths are files the manager has already placed on disk.
// A delegate that imports into its own library reports them here so
// the pipeline can reconcile rather than re-import.
ImportedPaths []string
Message string
}