feat: duplicates resolve themselves without Lidarr (M502 #5517)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
Lidarr is off, and with it the resolver had stopped merging: it only merged when Lidarr had said which copies it mapped. Minstrel is now the only program writing the library, so the rule is about the copies: - A song held twice on one album: the extra copies merge into the one whose tags fit best (then lossless, larger, older). - Across releases or under another title: when every copy but one is a video rip (held back, or named like one and not marked fine), the rips merge into the one that is not. - Clean copies on different releases are linked as one song, as before. - Anything else waits for the operator. Gone with Lidarr: release switches and their settle window, the unmapped-file read, the rip search, the merge guard and its 409/503, lidarr.Client's release methods, and duplicate_group_members.lidarr_state (migration 0078). The duplicates page drops the per-copy Lidarr badge and the blocked-merge notice; past release changes stay in its history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,175 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package lidarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListUnmappedTrackFiles(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/trackfile" || r.URL.Query().Get("unmapped") != "true" {
|
||||
t.Errorf("request = %s?%s, want /api/v1/trackfile?unmapped=true", r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
_, _ = w.Write([]byte(`[{"id":7,"path":"/music/A/B (2017)/A - B - 02 - X.mp3","albumId":0}]`))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
got, err := c.ListUnmappedTrackFiles(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListUnmappedTrackFiles: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != 7 || got[0].Path != "/music/A/B (2017)/A - B - 02 - X.mp3" {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseReads(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/v1/album/4723":
|
||||
_, _ = w.Write([]byte(`{"id":4723,"releases":[
|
||||
{"id":10148,"title":"Humanz","format":"14x12\" Vinyl, Digital Media","trackCount":68,"mediumCount":15,"monitored":true},
|
||||
{"id":10149,"title":"Humanz","format":"Digital Media","trackCount":40,"mediumCount":1,"monitored":false}]}`))
|
||||
case r.URL.Path == "/api/v1/track" && r.URL.Query().Get("albumId") == "4723":
|
||||
_, _ = w.Write([]byte(`[{"id":1,"title":"Ascension","mediumNumber":1,"trackFileId":9}]`))
|
||||
case r.URL.Path == "/api/v1/track" && r.URL.Query().Get("albumReleaseId") == "10149":
|
||||
_, _ = w.Write([]byte(`[{"id":2,"title":"Ascension","mediumNumber":1,"trackFileId":0}]`))
|
||||
default:
|
||||
t.Errorf("unexpected request %s?%s", r.URL.Path, r.URL.RawQuery)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
defer srv.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
rels, err := c.GetAlbumReleases(ctx, 4723)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbumReleases: %v", err)
|
||||
}
|
||||
if len(rels) != 2 || !rels[0].Monitored || rels[0].MediumCount != 15 || rels[1].TrackCount != 40 {
|
||||
t.Errorf("releases = %+v", rels)
|
||||
}
|
||||
tr, err := c.ListAlbumTracks(ctx, 4723)
|
||||
if err != nil || len(tr) != 1 || tr[0].TrackFileID != 9 {
|
||||
t.Errorf("ListAlbumTracks = %+v, %v", tr, err)
|
||||
}
|
||||
tr, err = c.ListReleaseTracks(ctx, 10149)
|
||||
if err != nil || len(tr) != 1 || tr[0].Title != "Ascension" {
|
||||
t.Errorf("ListReleaseTracks = %+v, %v", tr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The PUT carries the whole resource back, with only the releases' monitored
|
||||
// flags changed: exactly the chosen release is monitored, and a field Minstrel
|
||||
// does not model survives the round trip.
|
||||
func TestSetMonitoredRelease_RoundTripsTheResource(t *testing.T) {
|
||||
var put map[string]any
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/album/4723" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
_, _ = w.Write([]byte(`{"id":4723,"anyReleaseOk":false,"secondaryTypes":["Studio"],"releases":[
|
||||
{"id":10148,"monitored":true},{"id":10149,"monitored":false}]}`))
|
||||
case http.MethodPut:
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(b, &put); err != nil {
|
||||
t.Errorf("PUT body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
if err := c.SetMonitoredRelease(context.Background(), 4723, 10149); err != nil {
|
||||
t.Fatalf("SetMonitoredRelease: %v", err)
|
||||
}
|
||||
rels := put["releases"].([]any)
|
||||
if m := rels[0].(map[string]any)["monitored"]; m != false {
|
||||
t.Errorf("old release monitored = %v, want false", m)
|
||||
}
|
||||
if m := rels[1].(map[string]any)["monitored"]; m != true {
|
||||
t.Errorf("new release monitored = %v, want true", m)
|
||||
}
|
||||
if st, _ := put["secondaryTypes"].([]any); len(st) != 1 || st[0] != "Studio" {
|
||||
t.Errorf("secondaryTypes = %v, want it carried through unchanged", put["secondaryTypes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMonitoredRelease_UnknownReleaseSendsNothing(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut {
|
||||
t.Error("PUT sent for a release the album does not have")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":4723,"releases":[{"id":10148,"monitored":true}]}`))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
if err := c.SetMonitoredRelease(context.Background(), 4723, 999); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchAlbums_PostsTheCommand(t *testing.T) {
|
||||
var got map[string]any
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/command" {
|
||||
t.Errorf("request = %s %s, want POST /api/v1/command", r.Method, r.URL.Path)
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &got)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1,"name":"AlbumSearch"}`))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
if err := c.SearchAlbums(context.Background(), []int{4723, 12}); err != nil {
|
||||
t.Fatalf("SearchAlbums: %v", err)
|
||||
}
|
||||
ids, _ := got["albumIds"].([]any)
|
||||
if got["name"] != "AlbumSearch" || len(ids) != 2 || ids[0] != float64(4723) || ids[1] != float64(12) {
|
||||
t.Errorf("command = %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user