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 }