Files
minstrel/internal/lidarr/releases.go
T
bvandeusenandClaude Opus 5.5 e17556dce9
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m26s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m6s
release / android (push) Successful in 5m58s
release / Build signed APK (releases and dev) (push) Successful in 6m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: Lidarr searches for a better copy of a rip that has none (M498 #5447)
The operator chose option 2: an album holding a video rip that is the only
copy of its song is searched in Lidarr (AlbumSearch), at most once a week
and ten albums a pass. Lidarr grabs only when the quality profile allows an
upgrade, so nothing is removed and the rip stays held back either way.

- lidarr.Client.SearchAlbums posts the AlbumSearch command.
- ListAlbumsWithSoleCopyRips finds the albums: a held-back rip with no
  present clean copy sharing its song or its pending group, and not searched
  in the last week (audit action lidarr_rip_search).
- Suspect sources shows when Lidarr last searched the track's album.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:32:30 -04:00

176 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lidarr
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
)
// What Lidarr holds on disk and how it maps it (Scribe milestone #498).
//
// Lidarr maps one file to each track of the ONE release it monitors per album.
// A file it holds but maps to no track is "unmapped": removing it opens no hole.
// Removing a mapped file does, and Lidarr fills the hole by downloading again —
// which is why Minstrel's duplicate resolver asks before it deletes anything.
// TrackFile is a file Lidarr holds on disk.
type TrackFile struct {
ID int `json:"id"`
Path string `json:"path"`
AlbumID int `json:"albumId"`
}
// ListUnmappedTrackFiles hits GET /api/v1/trackfile?unmapped=true: files under
// Lidarr's root folders that it has scanned but matched to no track.
func (c *Client) ListUnmappedTrackFiles(ctx context.Context) ([]TrackFile, error) {
resp, err := c.get(ctx, "/api/v1/trackfile", url.Values{"unmapped": {"true"}})
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
var out []TrackFile
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("%w: decode unmapped track files: %v", ErrInvalidPayload, err)
}
return out, nil
}
// AlbumRelease is one edition of an album as Lidarr knows it: the 14×12" vinyl
// box set, the 2×CD deluxe, the digital standard. Exactly one is Monitored.
type AlbumRelease struct {
ID int `json:"id"`
Title string `json:"title"`
Disambiguation string `json:"disambiguation"`
Format string `json:"format"`
TrackCount int `json:"trackCount"`
MediumCount int `json:"mediumCount"`
Monitored bool `json:"monitored"`
}
// ReleaseTrack is one track of a release.
type ReleaseTrack struct {
ID int `json:"id"`
Title string `json:"title"`
MediumNumber int `json:"mediumNumber"`
TrackFileID int `json:"trackFileId"`
}
// GetAlbumReleases hits GET /api/v1/album/{id} and returns the album's releases.
func (c *Client) GetAlbumReleases(ctx context.Context, albumID int) ([]AlbumRelease, error) {
resp, err := c.get(ctx, "/api/v1/album/"+strconv.Itoa(albumID), nil)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
var album struct {
Releases []AlbumRelease `json:"releases"`
}
if err := json.NewDecoder(resp.Body).Decode(&album); err != nil {
return nil, fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err)
}
return album.Releases, nil
}
// ListAlbumTracks hits GET /api/v1/track?albumId= : the tracks of the release
// Lidarr monitors for that album.
func (c *Client) ListAlbumTracks(ctx context.Context, albumID int) ([]ReleaseTrack, error) {
return c.listTracks(ctx, url.Values{"albumId": {strconv.Itoa(albumID)}})
}
// ListReleaseTracks hits GET /api/v1/track?albumReleaseId= : the tracks of one
// release, monitored or not.
func (c *Client) ListReleaseTracks(ctx context.Context, releaseID int) ([]ReleaseTrack, error) {
return c.listTracks(ctx, url.Values{"albumReleaseId": {strconv.Itoa(releaseID)}})
}
func (c *Client) listTracks(ctx context.Context, q url.Values) ([]ReleaseTrack, error) {
resp, err := c.get(ctx, "/api/v1/track", q)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
var out []ReleaseTrack
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("%w: decode tracks: %v", ErrInvalidPayload, err)
}
return out, nil
}
// SetMonitoredRelease makes releaseID the album's monitored release.
//
// It is the round trip Lidarr's own "edit album" makes: GET the album resource,
// flip `monitored` on its releases, PUT the whole resource back. Lidarr's
// UpdateAlbum publishes AlbumEditedEvent, and when the monitored release changed,
// AlbumEditedService unlinks the album's track files and queues
// RescanFoldersCommand for the artist's folder, which maps the files on disk to
// the new release's tracks. Files the new release has no track for come out of
// that rescan unmapped. (Lidarr source, develop: Music/Services/
// AlbumEditedService.cs and AlbumService.UpdateAlbum.)
//
// The resource is kept as raw JSON so every field Lidarr sent goes back
// unchanged; only the releases' monitored flags differ. Returns ErrNotFound when
// the album has no release with that id.
func (c *Client) SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error {
path := "/api/v1/album/" + strconv.Itoa(albumID)
resp, err := c.get(ctx, path, nil)
if err != nil {
return err
}
var album map[string]any
err = json.NewDecoder(resp.Body).Decode(&album)
_ = resp.Body.Close()
if err != nil {
return fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err)
}
releases, _ := album["releases"].([]any)
found := false
for _, r := range releases {
rel, ok := r.(map[string]any)
if !ok {
continue
}
id, _ := rel["id"].(float64)
match := int(id) == releaseID
rel["monitored"] = match
found = found || match
}
if !found {
return fmt.Errorf("%w: release %d on album %d", ErrNotFound, releaseID, albumID)
}
body, err := json.Marshal(album)
if err != nil {
return fmt.Errorf("%w: marshal album: %v", ErrInvalidPayload, err)
}
putResp, err := c.put(ctx, path, body)
if err != nil {
return err
}
_ = putResp.Body.Close()
return nil
}
// SearchAlbums asks Lidarr to search its indexers for the albums
// (POST /api/v1/command, AlbumSearch). Lidarr grabs a release only when it
// beats the files the album already has under the quality profile, so for an
// album whose tracks are all present this is an upgrade search: harmless when
// nothing better exists. The command is queued; the call does not wait for it.
func (c *Client) SearchAlbums(ctx context.Context, albumIDs []int) error {
if len(albumIDs) == 0 {
return nil
}
body, err := json.Marshal(map[string]any{"name": "AlbumSearch", "albumIds": albumIDs})
if err != nil {
return fmt.Errorf("%w: marshal command: %v", ErrInvalidPayload, err)
}
resp, err := c.post(ctx, "/api/v1/command", body)
if err != nil {
return err
}
_ = resp.Body.Close()
return nil
}