feat: duplicates resolve themselves where Lidarr says it is safe (M498)
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicate sweep proposed 4,197 groups and every one waited for the operator. Most are safe to settle, and Lidarr defines what safe means: it maps one file to each track of the release it monitors and downloads any mapped file that disappears. Deleting a mapped copy opens exactly the hole the operator saw Lidarr fill. Classify (#5435) - Migration 0075: duplicate_groups.class (same_release, cross_release, mismatch, review), resolve_note, resolved_automatically; duplicate_group_members.lidarr_state (tracked, unmapped); fingerprint_settings.auto_resolve; notification kind duplicates_resolved with both kind CHECKs swapped (rule 36). - library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring credits, remaster notes and video-rip markers, and keeping live, demo, remix and instrumental. The rip markers move from api to library. Choose the copy to keep (#5436) - ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a clash-free track number, no rip marker in the name, an MBID), then the quality rules. File size picked the wrong Humanz copy in 6 of 21 groups. Act (#5437) - An hourly resolver pass reads Lidarr's unmapped files, matched by the last three path components, and records each copy's state. - Same album, with at most one copy mapped: merged into the mapped copy. The merge is guarded, so a mapped copy can never be removed (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr). - Same album, every copy mapped: the monitored release lists the song twice (Humanz's 14x12" box set). The pass moves Lidarr to the release that lists each song once and best covers what is on disk. It never picks one covering less, and is capped at 10 albums per pass. - Fixed point (lesson #4183): the chosen release no longer repeats. - The album is left alone for 24h while Lidarr rescans, so "every copy unmapped" mid-rescan is never read as licence to merge. - Both actions are audited with no actor and summarised to admins. The operator can switch them off in the Fingerprinting card (rule 25). - Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503 lidarr_unavailable when Lidarr cannot say. Web - Duplicates gets tabs: Needs review, Across releases, Resolved automatically. Each loads as you scroll (rule 172), replacing the pager. - Each copy says whether Lidarr uses it. - The resolver's note shows on each group. - The merge confirm blocks, before sending, a merge that would remove the copy Lidarr uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user