Files
yellowjacket/backend/download/provider_lidarr_list.go
T
yonluandClaude Sonnet 5 65333857e2
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
The durable "I asked for this" record was called Want, and the one-shot
search-and-grab attempt was called Request — names that didn't match
what either actually did. Want is now Request, and the old Request/Item
is now Download/DownloadItem, with a table-rename migration
(download_wants -> download_requests, old download_requests ->
download_downloads) safe against both fresh installs and existing data.

Every anchored manual download now upserts/reuses a durable Request
before running, so a "download now" that finds nothing is picked up by
the background reconciler automatically instead of just failing with
no trace — the gap that caused this session's repeated "no candidates
found" failures on the same album.

Also adds auto-download guardrails (file-size min/max with a preferred
target, allowed file types) that gate what the pipeline may grab
unattended, live-editable from a new settings section. The frontend's
wanted-view becomes downloads-view, with a new Downloads tab showing
attempt/transfer history that previously had no UI at all.

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

263 lines
7.3 KiB
Go

package download
import (
"context"
"fmt"
"net/url"
"strconv"
)
// Lidarr's Lister role: mirroring this app's request list into Lidarr's
// own monitoring.
//
// Lidarr already models exactly what a request is — a monitored artist or
// a monitored album — so the mapping is direct and, more usefully, it
// means an artist subscription made here keeps working through Lidarr's
// own release-checking even while this app is closed. That is the
// whole reason the Lister role exists: a desktop player is not running
// most of the time, and a always-on system that already watches for new
// releases is a better place for a subscription to live than a loop
// that only ticks when someone opens the app.
//
// The mapping is deliberately lossy in one direction only:
//
// artist request -> Lidarr artist, monitored
// release-group request -> Lidarr album, monitored (artist added if new)
// release request -> same, at release-group granularity
// recording request -> not pushed; Lidarr has no concept of wanting
// one track, and monitoring the whole album to
// get it would download far more than asked.
//
// Nothing here searches. Pushing a request expresses intent; Lidarr
// decides when to act on it, which is the point of delegating.
// PushRequest records a request in Lidarr's own monitoring.
//
// It is idempotent because Lidarr is: adding an artist that already
// exists returns the existing record, and monitoring an already-
// monitored album is a no-op. Callers rely on that — the reconciler
// pushes on every pass until it gets an ID back.
func (l *lidarr) PushRequest(ctx context.Context, r Request) (string, error) {
switch r.Entity {
case EntityArtist:
return l.pushArtistRequest(ctx, r)
case EntityReleaseGroup, EntityRelease:
return l.pushAlbumRequest(ctx, r)
case EntityRecording:
// Deliberately unsupported rather than approximated. See the
// mapping note above.
return "", nil
default:
return "", fmt.Errorf("%w: entity %q", ErrUnsupported, r.Entity)
}
}
// pushArtistRequest makes Lidarr monitor an artist.
//
// Scope is honoured through Lidarr's own monitor option rather than by
// pushing each album separately: "future" maps to monitoring new
// releases only, "all" to monitoring everything missing. Letting
// Lidarr apply the policy means it stays applied to albums released
// after this push, which is what a subscription is for.
func (l *lidarr) pushArtistRequest(ctx context.Context, r Request) (string, error) {
existing, err := l.findArtistByMBID(ctx, r.MBID)
if err != nil {
return "", err
}
monitor := "future"
if r.Scope == ScopeAll {
monitor = "missing"
}
if existing.ID != 0 {
return strconv.Itoa(existing.ID), nil
}
root, err := l.resolveRootFolder(ctx)
if err != nil {
return "", err
}
quality, metadata, err := l.profiles(ctx)
if err != nil {
return "", err
}
name := r.Artist
if name == "" {
name = r.Title
}
body := map[string]any{
"foreignArtistId": r.MBID,
"artistName": name,
"qualityProfileId": quality,
"metadataProfileId": metadata,
"rootFolderPath": root,
"monitored": true,
"addOptions": map[string]any{
"monitor": monitor,
// Searching is left off even for a full-discography
// subscription: adding an artist should not launch forty
// simultaneous searches on a system the user shares with
// their own queue. Lidarr picks the albums up on its next
// scheduled search.
"searchForMissingAlbums": false,
},
}
var created struct {
ID int `json:"id"`
}
if err := l.client.post(ctx, "/api/v1/artist", body, &created); err != nil {
return "", err
}
return strconv.Itoa(created.ID), nil
}
// pushAlbumRequest makes Lidarr monitor one album, adding its artist if
// Lidarr has never heard of them.
func (l *lidarr) pushAlbumRequest(ctx context.Context, r Request) (string, error) {
album, err := l.findAlbum(ctx, Download{
ReleaseGroupMBID: r.MBID,
Artist: r.Artist,
Album: r.Title,
})
if err != nil {
return "", err
}
if album.ID == 0 {
album, err = l.addArtistForAlbum(ctx, album)
if err != nil {
return "", err
}
}
if !album.Monitored {
if err := l.monitorAlbum(ctx, album.ID); err != nil {
return "", err
}
}
return strconv.Itoa(album.ID), nil
}
// RemoveRequest stops Lidarr monitoring something.
//
// It unmonitors rather than deletes: the user's Lidarr may have been
// monitoring that artist long before this app existed, and removing a
// request here is not permission to tear down their setup. An unmonitored
// artist stays in their library with its files intact.
func (l *lidarr) RemoveRequest(ctx context.Context, externalID string) error {
id, err := strconv.Atoi(externalID)
if err != nil {
return fmt.Errorf("%w: bad lidarr id %q", ErrLidarrNoMatch, externalID)
}
// The ID may name an album or an artist and the caller does not
// track which, so try the album endpoint first and fall back.
if err := l.client.put(ctx, "/api/v1/album/monitor", map[string]any{
"albumIds": []int{id},
"monitored": false,
}, nil); err == nil {
return nil
}
var artist map[string]any
endpoint := "/api/v1/artist/" + strconv.Itoa(id)
if err := l.client.get(ctx, endpoint, &artist); err != nil {
return err
}
artist["monitored"] = false
return l.client.put(ctx, endpoint, artist, nil)
}
// ListRequests reads Lidarr's monitored artists back, for the deliberate
// "import what Lidarr is already watching" action.
//
// Only artists are imported, not their individual monitored albums: an
// artist is the durable statement of intent, and importing every
// monitored album alongside it would produce a request list that is
// mostly redundant with the subscription that generated it.
func (l *lidarr) ListRequests(ctx context.Context) ([]Request, error) {
var artists []struct {
lidarrArtist
Monitored bool `json:"monitored"`
}
if err := l.client.get(ctx, "/api/v1/artist", &artists); err != nil {
return nil, err
}
out := make([]Request, 0, len(artists))
for _, a := range artists {
if !a.Monitored || a.ForeignArtistID == "" {
continue
}
out = append(out, Request{
MBID: a.ForeignArtistID,
Entity: EntityArtist,
Artist: a.ArtistName,
Title: a.ArtistName,
// Imported subscriptions take the conservative scope: the
// user can widen it, but silently queueing a back catalogue
// on import would be a nasty surprise.
Scope: ScopeFuture,
})
}
return out, nil
}
// findArtistByMBID looks up an artist Lidarr already has.
func (l *lidarr) findArtistByMBID(
ctx context.Context,
mbid string,
) (lidarrArtist, error) {
var artists []lidarrArtist
endpoint := "/api/v1/artist?mbId=" + url.QueryEscape(mbid)
if err := l.client.get(ctx, endpoint, &artists); err != nil {
return lidarrArtist{}, err
}
for _, a := range artists {
if a.ForeignArtistID == mbid {
return a, nil
}
}
return lidarrArtist{}, nil
}
// resolveRootFolder returns the configured root folder, or Lidarr's
// first if none is configured.
func (l *lidarr) resolveRootFolder(ctx context.Context) (string, error) {
if l.rootFolderPath != "" {
return l.rootFolderPath, nil
}
folders, err := l.rootFolders(ctx)
if err != nil {
return "", err
}
if len(folders) == 0 {
return "", ErrLidarrNoRootFolder
}
return folders[0].Path, nil
}