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
263 lines
7.3 KiB
Go
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
|
|
}
|