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 }