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
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>
176 lines
6.0 KiB
Go
176 lines
6.0 KiB
Go
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
|
||
}
|