feat: a held album below the quality you settle for, or holding a video rip, stays wanted for a better copy, and the import replaces the old tracks with their likes and plays carried over (M502 #5524)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's cutoff, note #5527; "" is off), an "Upgrade until" row under Searching. - The wanted worker judges held albums hourly: tier per format from size per second (the scanner records no bitrate), the album's worst. Below the cutoff it is wanted for quality; a video rip among its tracks always. - The schedule searches upgrades weekly, after open wants; a release no better than the copy held is refused ("not_better"). - The import matches each file to the track it would replace (recording, then disc/track) and replaces it when it is a rip or the file is a better tier: over the same path in one rename, or filed beside it and the old track folded in (library.ReplaceTrack: file first, then foldTrackInto). Nothing better: the release is set aside. - Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a clean copy was looked for instead of Lidarr's search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,9 @@ type searchSettingsView struct {
|
||||
SeedRatio float32 `json:"seed_ratio"`
|
||||
SeedDays int32 `json:"seed_days"`
|
||||
StallHours int32 `json:"stall_hours"`
|
||||
// UpgradeUntil is the tier a held album is upgraded to (#5524); "" is
|
||||
// no quality upgrades.
|
||||
UpgradeUntil string `json:"upgrade_until"`
|
||||
}
|
||||
|
||||
func searchSettings(s dbq.AcquisitionSetting, roots []string) searchSettingsView {
|
||||
@@ -51,6 +54,7 @@ func searchSettings(s dbq.AcquisitionSetting, roots []string) searchSettingsView
|
||||
AllowedQualities: s.AllowedQualities, Tiers: release.Tiers, LastPass: s.LastPass,
|
||||
ImportRoot: s.ImportRoot, ScanPaths: roots,
|
||||
SeedRatio: s.SeedRatio, SeedDays: s.SeedDays, StallHours: s.StallHours,
|
||||
UpgradeUntil: s.UpgradeUntil,
|
||||
}
|
||||
if v.ScanPaths == nil {
|
||||
v.ScanPaths = []string{}
|
||||
@@ -86,6 +90,7 @@ type putSearchSettingsBody struct {
|
||||
SeedRatio float32 `json:"seed_ratio"`
|
||||
SeedDays int32 `json:"seed_days"`
|
||||
StallHours int32 `json:"stall_hours"`
|
||||
UpgradeUntil string `json:"upgrade_until"`
|
||||
}
|
||||
|
||||
// handlePutSearchSettings implements PUT /api/admin/acquisition/settings.
|
||||
@@ -108,6 +113,10 @@ func (h *handlers) handlePutSearchSettings(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.UpgradeUntil != "" && !release.KnownTier(body.UpgradeUntil) {
|
||||
writeErr(w, apierror.BadRequest("bad_upgrade_until", "unknown quality "+body.UpgradeUntil))
|
||||
return
|
||||
}
|
||||
if body.ImportRoot != "" && !slices.Contains(h.importer.Roots, body.ImportRoot) {
|
||||
writeErr(w, apierror.BadRequest("bad_import_root", "file new music into one of the library's folders"))
|
||||
return
|
||||
@@ -121,6 +130,7 @@ func (h *handlers) handlePutSearchSettings(w http.ResponseWriter, r *http.Reques
|
||||
s, err := dbq.New(h.pool).UpdateAcquisitionSettings(r.Context(), dbq.UpdateAcquisitionSettingsParams{
|
||||
AutoGrab: body.AutoGrab, SearchIntervalHours: body.SearchIntervalHours, AllowedQualities: body.AllowedQualities,
|
||||
ImportRoot: body.ImportRoot, SeedRatio: body.SeedRatio, SeedDays: body.SeedDays, StallHours: body.StallHours,
|
||||
UpgradeUntil: body.UpgradeUntil,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin search settings: put", apierror.Internal(err))
|
||||
@@ -322,7 +332,7 @@ func (h *handlers) writeSearchErr(w http.ResponseWriter, err error) bool {
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
writeErr(w, apierror.NotFound("want"))
|
||||
case errors.Is(err, wanted.ErrWantHeld):
|
||||
writeErr(w, apierror.Conflict("want_held", "the library already holds this album"))
|
||||
writeErr(w, apierror.Conflict("want_held", "the library already holds this album, and it isn't wanted for a better copy"))
|
||||
default:
|
||||
writeErrWithLog(w, h.logger, "admin wanted: search", apierror.Internal(err))
|
||||
}
|
||||
|
||||
@@ -31,8 +31,9 @@ type suspectTrackView struct {
|
||||
// first pass (M498 #5439). VerdictAt is when it was last set.
|
||||
Verdict *string `json:"verdict"`
|
||||
VerdictAt *string `json:"verdict_at"`
|
||||
// SearchedAt is when the resolver last asked Lidarr to search the album for
|
||||
// a better copy (#5447): null when it has not.
|
||||
// Replacing: the album is on the wanted list for a clean copy (#5524);
|
||||
// SearchedAt is when one was last searched for, null before the first.
|
||||
Replacing bool `json:"replacing"`
|
||||
SearchedAt *string `json:"searched_at"`
|
||||
}
|
||||
|
||||
@@ -115,7 +116,8 @@ func groupSuspectByDirectory(rows []dbq.ListSuspectSourceTracksRow) []suspectGro
|
||||
at := row.SourceVerdictAt.Time.UTC().Format(time.RFC3339)
|
||||
t.VerdictAt = &at
|
||||
}
|
||||
if row.SearchedAt.Valid {
|
||||
t.Replacing = row.WantUpgrade == "rip"
|
||||
if t.Replacing && row.SearchedAt.Valid {
|
||||
at := row.SearchedAt.Time.UTC().Format(time.RFC3339)
|
||||
t.SearchedAt = &at
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ type wantView struct {
|
||||
WantedAt string `json:"wanted_at"`
|
||||
HeldAt *string `json:"held_at"`
|
||||
ImageURL string `json:"image_url"`
|
||||
// Upgrade: why a held album is wanted again, quality or rip, and
|
||||
// HeldQuality what the library's copy is (#5524).
|
||||
Upgrade string `json:"upgrade,omitempty"`
|
||||
HeldQuality string `json:"held_quality,omitempty"`
|
||||
// Search is the schedule's last look; Grab the download running (#5521).
|
||||
Search *wantSearchView `json:"search"`
|
||||
Grab *wantGrabView `json:"grab"`
|
||||
@@ -80,6 +84,8 @@ type wantCounts struct {
|
||||
Wanted int64 `json:"wanted"`
|
||||
Missing int64 `json:"missing"`
|
||||
Held int64 `json:"held"`
|
||||
// Upgrades: held albums wanted for a better copy (#5524).
|
||||
Upgrades int64 `json:"upgrades"`
|
||||
Following int64 `json:"following"`
|
||||
// Activity: downloads running; Attention: of those, the ones waiting on
|
||||
// a person; Blocked: releases set aside (#5522).
|
||||
@@ -173,7 +179,7 @@ func (h *handlers) wantsHeader(r *http.Request, q *dbq.Queries) (wantCounts, lib
|
||||
lib.ListedAt = &at
|
||||
}
|
||||
return wantCounts{Artists: counts.Artists, Unmatched: counts.Unmatched, Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held,
|
||||
Following: counts.Following, Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked}, lib, nil
|
||||
Upgrades: counts.Upgrades, Following: counts.Following, Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked}, lib, nil
|
||||
}
|
||||
|
||||
func offsetParam(r *http.Request) int {
|
||||
@@ -220,8 +226,9 @@ func (h *handlers) handleListWantArtists(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// handleListWants implements
|
||||
// GET /api/admin/wanted?state=wanted|missing|held|all&artist=&q=&offset=.
|
||||
// wanted is everything open but missing files. Each list runs artist by
|
||||
// GET /api/admin/wanted?state=wanted|missing|upgrade|held|all&artist=&q=&offset=.
|
||||
// wanted is everything open but missing files; upgrade the held albums
|
||||
// wanted for a better copy (#5524). Each list runs artist by
|
||||
// artist, A–Z, like the library (#5572); q narrows it to an artist or
|
||||
// album, and artist to one artist's page, which it then heads.
|
||||
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -229,8 +236,8 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
||||
if state == "" {
|
||||
state = "wanted"
|
||||
}
|
||||
if state != "wanted" && state != "missing" && state != "held" && state != "all" {
|
||||
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing, held or all"))
|
||||
if state != "wanted" && state != "missing" && state != "upgrade" && state != "held" && state != "all" {
|
||||
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing, upgrade, held or all"))
|
||||
return
|
||||
}
|
||||
offset := offsetParam(r)
|
||||
@@ -269,7 +276,7 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
||||
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
|
||||
PrimaryType: row.PrimaryType, SecondaryTypes: row.SecondaryTypes,
|
||||
Year: first4(row.FirstReleaseDate), Reason: row.Reason,
|
||||
WantedAt: formatTimestamp(row.WantedAt),
|
||||
WantedAt: formatTimestamp(row.WantedAt), Upgrade: row.Upgrade, HeldQuality: row.HeldQuality,
|
||||
ImageURL: groupArtURL(musicbrainz.ReleaseGroup{MBID: row.ReleaseGroupMbid, Title: row.Title, ArtistCredit: row.ArtistName}),
|
||||
}
|
||||
if row.LibraryArtistID.Valid {
|
||||
|
||||
Reference in New Issue
Block a user