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

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:
2026-10-09 20:12:49 -04:00
co-authored by Claude Opus 5.5
parent 8bd48b4302
commit 5306a261c1
27 changed files with 301 additions and 1743 deletions
-175
View File
@@ -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
}
-138
View File
@@ -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)
}
}