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
549 lines
14 KiB
Go
549 lines
14 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Lidarr is the delegate shape, and it is genuinely different from the
|
|
// other two: we do not search, we do not move bytes, and we do not own
|
|
// the import. We hand Lidarr an album and ask periodically whether it
|
|
// is done.
|
|
//
|
|
// The consequence that shapes this adapter: when Lidarr finishes, the
|
|
// files are already in Lidarr's library, tagged by Lidarr, at paths
|
|
// Lidarr chose. Re-importing them would mean moving files out from
|
|
// under a system that is actively managing them. So a completed
|
|
// delegate returns the paths Lidarr reports and the pipeline skips
|
|
// tagging and moving — it reconciles rather than imports.
|
|
|
|
// Lidarr provider errors.
|
|
var (
|
|
// ErrLidarrUnreachable means the instance did not answer.
|
|
ErrLidarrUnreachable = errors.New("lidarr is unreachable")
|
|
|
|
// ErrLidarrAuth means the API key was rejected.
|
|
ErrLidarrAuth = errors.New("lidarr rejected the API key")
|
|
|
|
// ErrLidarrNoMatch means Lidarr could not find the release.
|
|
ErrLidarrNoMatch = errors.New("lidarr could not find this release")
|
|
|
|
// ErrLidarrNoRootFolder means no root folder is configured, so
|
|
// Lidarr has nowhere to put anything it finds.
|
|
ErrLidarrNoRootFolder = errors.New("lidarr has no root folder configured")
|
|
)
|
|
|
|
// lidarrHTTPTimeout bounds one API call.
|
|
const lidarrHTTPTimeout = 30 * time.Second
|
|
|
|
func init() {
|
|
Register(
|
|
Descriptor{
|
|
Kind: KindLidarr,
|
|
Name: "Lidarr",
|
|
Summary: "Hand album requests to an existing Lidarr instance " +
|
|
"and let it do the searching and importing.",
|
|
RequiresExternal: "Lidarr",
|
|
Caps: Caps{
|
|
CanDelegate: true,
|
|
CanCancel: true,
|
|
CanList: true,
|
|
},
|
|
Fields: []Field{
|
|
{
|
|
Key: "url",
|
|
Label: "Lidarr URL",
|
|
Placeholder: "http://localhost:8686",
|
|
Required: true,
|
|
Default: "http://localhost:8686",
|
|
},
|
|
{
|
|
Key: "apiKey",
|
|
Label: "API key",
|
|
Secret: true,
|
|
Required: true,
|
|
Help: "Lidarr → Settings → General → API Key.",
|
|
},
|
|
{
|
|
Key: "qualityProfileId",
|
|
Label: "Quality profile ID",
|
|
Help: "Numeric ID of the Lidarr quality profile to use. " +
|
|
"Leave blank to use the first one.",
|
|
},
|
|
{
|
|
Key: "metadataProfileId",
|
|
Label: "Metadata profile ID",
|
|
Help: "Leave blank to use the first one.",
|
|
},
|
|
{
|
|
Key: "rootFolderPath",
|
|
Label: "Root folder",
|
|
Help: "Leave blank to use Lidarr's first configured " +
|
|
"root folder.",
|
|
},
|
|
},
|
|
},
|
|
newLidarr,
|
|
)
|
|
}
|
|
|
|
// lidarr is the Lidarr delegate provider.
|
|
type lidarr struct {
|
|
info ProviderInfo
|
|
logger *slog.Logger
|
|
client *apiClient
|
|
|
|
qualityProfileID int
|
|
metadataProfileID int
|
|
rootFolderPath string
|
|
}
|
|
|
|
// newLidarr builds the provider from config.
|
|
func newLidarr(
|
|
cfg Config,
|
|
secrets SecretLookup,
|
|
logger *slog.Logger,
|
|
) (Provider, error) {
|
|
base := strings.TrimRight(cfg.Setting("url", ""), "/")
|
|
if base == "" {
|
|
return nil, fmt.Errorf("%w: Lidarr URL is required", ErrNotConfigured)
|
|
}
|
|
|
|
apiKey := ""
|
|
|
|
if secrets != nil {
|
|
key, err := secrets("apiKey")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: no API key stored", ErrNotConfigured)
|
|
}
|
|
|
|
apiKey = key
|
|
}
|
|
|
|
quality, _ := strconv.Atoi(cfg.Setting("qualityProfileId", "0"))
|
|
metadata, _ := strconv.Atoi(cfg.Setting("metadataProfileId", "0"))
|
|
|
|
return &lidarr{
|
|
info: ProviderInfo{
|
|
ID: cfg.ID,
|
|
Kind: KindLidarr,
|
|
Name: cfg.Name,
|
|
Enabled: cfg.Enabled,
|
|
Priority: cfg.Priority,
|
|
Caps: Caps{
|
|
CanDelegate: true,
|
|
CanCancel: true,
|
|
CanList: true,
|
|
},
|
|
},
|
|
logger: logger.With("provider", "lidarr"),
|
|
client: newAPIClient(
|
|
base, "X-Api-Key", apiKey, lidarrHTTPTimeout,
|
|
ErrLidarrUnreachable, ErrLidarrAuth,
|
|
),
|
|
qualityProfileID: quality,
|
|
metadataProfileID: metadata,
|
|
rootFolderPath: cfg.Setting("rootFolderPath", ""),
|
|
}, nil
|
|
}
|
|
|
|
// Info returns the provider's identity.
|
|
func (l *lidarr) Info() ProviderInfo {
|
|
return l.info
|
|
}
|
|
|
|
// Close is a no-op.
|
|
func (l *lidarr) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// Check verifies the instance answers and has somewhere to put music.
|
|
func (l *lidarr) Check(ctx context.Context) error {
|
|
var status struct {
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
if err := l.client.get(ctx, "/api/v1/system/status", &status); err != nil {
|
|
return err
|
|
}
|
|
|
|
folders, err := l.rootFolders(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(folders) == 0 {
|
|
return ErrLidarrNoRootFolder
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// lidarrAlbum is the subset of Lidarr's album resource used here.
|
|
type lidarrAlbum struct {
|
|
ID int `json:"id"`
|
|
Title string `json:"title"`
|
|
ForeignAlbum string `json:"foreignAlbumId"`
|
|
Monitored bool `json:"monitored"`
|
|
ArtistID int `json:"artistId"`
|
|
|
|
Artist lidarrArtist `json:"artist"`
|
|
Statistics lidarrAlbumStats `json:"statistics"`
|
|
}
|
|
|
|
// lidarrArtist is the artist an album belongs to. Lidarr models albums
|
|
// as children of artists, so an album it does not yet know about cannot
|
|
// be monitored until its artist exists.
|
|
type lidarrArtist struct {
|
|
ID int `json:"id"`
|
|
ArtistName string `json:"artistName"`
|
|
ForeignArtistID string `json:"foreignArtistId"`
|
|
}
|
|
|
|
// lidarrAlbumStats is how Lidarr reports import progress.
|
|
type lidarrAlbumStats struct {
|
|
TrackFileCount int `json:"trackFileCount"`
|
|
TrackCount int `json:"trackCount"`
|
|
PercentOfTracks float64 `json:"percentOfTracks"`
|
|
}
|
|
|
|
// lidarrRootFolder is a configured library root.
|
|
type lidarrRootFolder struct {
|
|
ID int `json:"id"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// lidarrTrackFile is one imported file.
|
|
type lidarrTrackFile struct {
|
|
ID int `json:"id"`
|
|
Path string `json:"path"`
|
|
AlbumID int `json:"albumId"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Delegate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Delegate adds the album to Lidarr, monitors it and triggers a search.
|
|
// The external ID returned is Lidarr's album ID, which is what Poll
|
|
// needs and what survives a restart.
|
|
func (l *lidarr) Delegate(ctx context.Context, dl Download) (string, error) {
|
|
album, err := l.findAlbum(ctx, dl)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// An album Lidarr already knows about only needs monitoring turned
|
|
// on; one it does not needs its artist added first, because Lidarr
|
|
// models albums as children of artists.
|
|
if album.ID == 0 {
|
|
added, err := l.addArtistForAlbum(ctx, album)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
album = added
|
|
}
|
|
|
|
if !album.Monitored {
|
|
if err := l.monitorAlbum(ctx, album.ID); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
if err := l.command(ctx, map[string]any{
|
|
"name": "AlbumSearch",
|
|
"albumIds": []int{album.ID},
|
|
}); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return strconv.Itoa(album.ID), nil
|
|
}
|
|
|
|
// findAlbum looks for the requested album, preferring the MusicBrainz
|
|
// release-group ID because that is unambiguous where a title search is
|
|
// not.
|
|
func (l *lidarr) findAlbum(
|
|
ctx context.Context,
|
|
dl Download,
|
|
) (lidarrAlbum, error) {
|
|
term := dl.SearchText()
|
|
|
|
if dl.ReleaseGroupMBID != "" {
|
|
term = "lidarr:" + dl.ReleaseGroupMBID
|
|
}
|
|
|
|
var results []struct {
|
|
Album lidarrAlbum `json:"album"`
|
|
}
|
|
|
|
endpoint := "/api/v1/search?term=" + url.QueryEscape(term)
|
|
|
|
if err := l.client.get(ctx, endpoint, &results); err != nil {
|
|
return lidarrAlbum{}, err
|
|
}
|
|
|
|
for _, r := range results {
|
|
if r.Album.Title != "" {
|
|
return r.Album, nil
|
|
}
|
|
}
|
|
|
|
return lidarrAlbum{}, fmt.Errorf("%w: %s", ErrLidarrNoMatch, dl.SearchText())
|
|
}
|
|
|
|
// addArtistForAlbum adds the album's artist so the album becomes a real
|
|
// record Lidarr can monitor.
|
|
func (l *lidarr) addArtistForAlbum(
|
|
ctx context.Context,
|
|
album lidarrAlbum,
|
|
) (lidarrAlbum, error) {
|
|
root := l.rootFolderPath
|
|
|
|
if root == "" {
|
|
folders, err := l.rootFolders(ctx)
|
|
if err != nil {
|
|
return lidarrAlbum{}, err
|
|
}
|
|
|
|
if len(folders) == 0 {
|
|
return lidarrAlbum{}, ErrLidarrNoRootFolder
|
|
}
|
|
|
|
root = folders[0].Path
|
|
}
|
|
|
|
quality, metadata, err := l.profiles(ctx)
|
|
if err != nil {
|
|
return lidarrAlbum{}, err
|
|
}
|
|
|
|
body := map[string]any{
|
|
"foreignArtistId": album.Artist.ForeignArtistID,
|
|
"artistName": album.Artist.ArtistName,
|
|
"qualityProfileId": quality,
|
|
"metadataProfileId": metadata,
|
|
"rootFolderPath": root,
|
|
"monitored": true,
|
|
"addOptions": map[string]any{
|
|
// Monitor nothing by default and turn on just the requested
|
|
// album below. Adding an artist with everything monitored
|
|
// would kick off downloads of their entire discography,
|
|
// which is emphatically not what the user asked for.
|
|
"monitor": "none",
|
|
"searchForMissingAlbums": false,
|
|
},
|
|
}
|
|
|
|
var created struct {
|
|
ID int `json:"id"`
|
|
}
|
|
|
|
if err := l.client.post(ctx, "/api/v1/artist", body, &created); err != nil {
|
|
return lidarrAlbum{}, err
|
|
}
|
|
|
|
// Re-resolve the album now that its artist exists.
|
|
var albums []lidarrAlbum
|
|
|
|
endpoint := "/api/v1/album?artistId=" + strconv.Itoa(created.ID)
|
|
|
|
if err := l.client.get(ctx, endpoint, &albums); err != nil {
|
|
return lidarrAlbum{}, err
|
|
}
|
|
|
|
for _, a := range albums {
|
|
if a.ForeignAlbum == album.ForeignAlbum {
|
|
return a, nil
|
|
}
|
|
}
|
|
|
|
return lidarrAlbum{}, fmt.Errorf(
|
|
"%w: album not present after adding artist", ErrLidarrNoMatch,
|
|
)
|
|
}
|
|
|
|
// monitorAlbum turns on monitoring for one album.
|
|
func (l *lidarr) monitorAlbum(ctx context.Context, albumID int) error {
|
|
body := map[string]any{
|
|
"albumIds": []int{albumID},
|
|
"monitored": true,
|
|
}
|
|
|
|
return l.client.put(ctx, "/api/v1/album/monitor", body, nil)
|
|
}
|
|
|
|
// Poll reports whether Lidarr has finished with the album.
|
|
//
|
|
// Completion is judged by imported track files rather than by queue
|
|
// state: the queue empties when a download finishes, which is before
|
|
// the import happens, and reporting success then would have the
|
|
// pipeline reconcile files that are not there yet.
|
|
func (l *lidarr) Poll(
|
|
ctx context.Context,
|
|
externalID string,
|
|
) (DelegateStatus, error) {
|
|
albumID, err := strconv.Atoi(externalID)
|
|
if err != nil {
|
|
return DelegateStatus{}, fmt.Errorf(
|
|
"%w: bad album id %q", ErrLidarrNoMatch, externalID,
|
|
)
|
|
}
|
|
|
|
var album lidarrAlbum
|
|
|
|
endpoint := "/api/v1/album/" + strconv.Itoa(albumID)
|
|
|
|
if err := l.client.get(ctx, endpoint, &album); err != nil {
|
|
return DelegateStatus{}, err
|
|
}
|
|
|
|
total := album.Statistics.TrackCount
|
|
got := album.Statistics.TrackFileCount
|
|
|
|
progress := -1.0
|
|
if total > 0 {
|
|
progress = float64(got) / float64(total)
|
|
}
|
|
|
|
if total > 0 && got >= total {
|
|
paths, err := l.trackFilePaths(ctx, albumID)
|
|
if err != nil {
|
|
return DelegateStatus{}, err
|
|
}
|
|
|
|
return DelegateStatus{
|
|
State: StateComplete,
|
|
Progress: 1,
|
|
ImportedPaths: paths,
|
|
Message: fmt.Sprintf(
|
|
"Lidarr imported %d of %d tracks", got, total,
|
|
),
|
|
}, nil
|
|
}
|
|
|
|
return DelegateStatus{
|
|
State: StateGrabbing,
|
|
Progress: progress,
|
|
Message: fmt.Sprintf(
|
|
"Lidarr has %d of %d tracks", got, total,
|
|
),
|
|
}, nil
|
|
}
|
|
|
|
// trackFilePaths returns the on-disk paths Lidarr imported.
|
|
func (l *lidarr) trackFilePaths(
|
|
ctx context.Context,
|
|
albumID int,
|
|
) ([]string, error) {
|
|
var files []lidarrTrackFile
|
|
|
|
endpoint := "/api/v1/trackfile?albumId=" + strconv.Itoa(albumID)
|
|
|
|
if err := l.client.get(ctx, endpoint, &files); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]string, 0, len(files))
|
|
|
|
for _, f := range files {
|
|
if f.Path != "" {
|
|
out = append(out, f.Path)
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// Withdraw stops monitoring the album so Lidarr gives up on it. The
|
|
// album record is left in place: deleting it would be a bigger action
|
|
// than the user asked for, and it may predate this request.
|
|
func (l *lidarr) Withdraw(ctx context.Context, externalID string) error {
|
|
albumID, err := strconv.Atoi(externalID)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: bad album id %q", ErrLidarrNoMatch, externalID)
|
|
}
|
|
|
|
body := map[string]any{
|
|
"albumIds": []int{albumID},
|
|
"monitored": false,
|
|
}
|
|
|
|
return l.client.put(ctx, "/api/v1/album/monitor", body, nil)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// rootFolders lists Lidarr's configured library roots.
|
|
func (l *lidarr) rootFolders(ctx context.Context) ([]lidarrRootFolder, error) {
|
|
var folders []lidarrRootFolder
|
|
|
|
if err := l.client.get(ctx, "/api/v1/rootfolder", &folders); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return folders, nil
|
|
}
|
|
|
|
// profiles resolves the quality and metadata profile IDs to use,
|
|
// falling back to the first of each when unconfigured.
|
|
func (l *lidarr) profiles(ctx context.Context) (quality, metadata int, err error) {
|
|
quality, metadata = l.qualityProfileID, l.metadataProfileID
|
|
|
|
if quality == 0 {
|
|
var profiles []struct {
|
|
ID int `json:"id"`
|
|
}
|
|
|
|
if err := l.client.get(ctx, "/api/v1/qualityprofile", &profiles); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
if len(profiles) == 0 {
|
|
return 0, 0, fmt.Errorf(
|
|
"%w: no quality profiles configured", ErrNotConfigured,
|
|
)
|
|
}
|
|
|
|
quality = profiles[0].ID
|
|
}
|
|
|
|
if metadata == 0 {
|
|
var profiles []struct {
|
|
ID int `json:"id"`
|
|
}
|
|
|
|
if err := l.client.get(ctx, "/api/v1/metadataprofile", &profiles); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
if len(profiles) == 0 {
|
|
return 0, 0, fmt.Errorf(
|
|
"%w: no metadata profiles configured", ErrNotConfigured,
|
|
)
|
|
}
|
|
|
|
metadata = profiles[0].ID
|
|
}
|
|
|
|
return quality, metadata, nil
|
|
}
|
|
|
|
// command posts to Lidarr's command endpoint.
|
|
func (l *lidarr) command(ctx context.Context, body map[string]any) error {
|
|
return l.client.post(ctx, "/api/v1/command", body, nil)
|
|
}
|