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>
151 lines
4.9 KiB
Go
151 lines
4.9 KiB
Go
package notifications
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
)
|
||
|
||
// Rendered is a notification as people read it. The server renders it once,
|
||
// so the web inbox, the Android inbox, the phone's shade and the email digest
|
||
// all say the same thing, and a wording change is one edit here rather than
|
||
// a phrase table per client.
|
||
type Rendered struct {
|
||
Title string `json:"title"`
|
||
Body string `json:"body"`
|
||
// Link is the web path the notification opens. Android maps the same
|
||
// paths onto its screens.
|
||
Link string `json:"link"`
|
||
}
|
||
|
||
// Payload is what producers put in a notification. Every field is optional;
|
||
// each kind documents the ones it uses in Render.
|
||
type Payload struct {
|
||
RequestID string `json:"request_id,omitempty"`
|
||
RequestKind string `json:"request_kind,omitempty"` // artist | album | track
|
||
// Name is what was requested or flagged, as the user would say it:
|
||
// "WWW", "Moe Shop", "Moe Shop – WWW".
|
||
Name string `json:"name,omitempty"`
|
||
// Artist and Title are Name's parts, for request_completed: the new-music
|
||
// summary email groups arrivals by artist. Title is empty for an artist
|
||
// request.
|
||
Artist string `json:"artist,omitempty"`
|
||
Title string `json:"title,omitempty"`
|
||
ArtistID string `json:"artist_id,omitempty"`
|
||
AlbumID string `json:"album_id,omitempty"`
|
||
// Actor is the other person involved: who asked, who flagged.
|
||
Actor string `json:"actor,omitempty"`
|
||
Reason string `json:"reason,omitempty"`
|
||
// Count is the coalesced kinds' running total.
|
||
Count int64 `json:"count,omitempty"`
|
||
// Detail is free text: scan_failed's error message, and what the duplicate
|
||
// resolver changed in Lidarr for duplicates_resolved.
|
||
Detail string `json:"detail,omitempty"`
|
||
}
|
||
|
||
// Map turns a Payload into the map Notify takes, dropping empty fields.
|
||
func (p Payload) Map() map[string]any {
|
||
b, _ := json.Marshal(p)
|
||
var m map[string]any
|
||
_ = json.Unmarshal(b, &m)
|
||
return m
|
||
}
|
||
|
||
// Render turns a stored row into what people read. An unreadable payload
|
||
// still renders, from the kind alone, rather than failing the whole inbox.
|
||
func Render(kind Kind, payload []byte) Rendered {
|
||
var p Payload
|
||
_ = json.Unmarshal(payload, &p)
|
||
name := p.Name
|
||
if name == "" {
|
||
name = "Your request"
|
||
}
|
||
|
||
switch kind {
|
||
case KindRequestApproved:
|
||
return Rendered{Title: "Request approved", Body: name + " is on its way.", Link: "/requests"}
|
||
case KindRequestRejected:
|
||
body := name + " was declined."
|
||
if p.Reason != "" {
|
||
body = name + " was declined: " + p.Reason
|
||
}
|
||
return Rendered{Title: "Request declined", Body: body, Link: "/requests"}
|
||
case KindRequestCompleted:
|
||
link := "/requests"
|
||
switch {
|
||
case p.AlbumID != "":
|
||
link = "/albums/" + p.AlbumID
|
||
case p.ArtistID != "":
|
||
link = "/artists/" + p.ArtistID
|
||
}
|
||
return Rendered{Title: "Now in your library", Body: name + " has arrived.", Link: link}
|
||
case KindRequestPending:
|
||
who := p.Actor
|
||
if who == "" {
|
||
who = "Someone"
|
||
}
|
||
return Rendered{Title: "Request to review", Body: who + " asked for " + name + ".", Link: "/admin/requests"}
|
||
case KindQuarantineFlagged:
|
||
who := p.Actor
|
||
if who == "" {
|
||
who = "Someone"
|
||
}
|
||
body := who + " flagged " + name + "."
|
||
if p.Reason != "" {
|
||
body = who + " flagged " + name + ": " + p.Reason
|
||
}
|
||
return Rendered{Title: "Track flagged", Body: body, Link: "/admin/quarantine"}
|
||
case KindScanFailed:
|
||
title := "Library scan failed"
|
||
if p.Count > 1 {
|
||
title = fmt.Sprintf("Library scan failed %d times", p.Count)
|
||
}
|
||
body := p.Detail
|
||
if body == "" {
|
||
body = "The last scan stopped with an error."
|
||
}
|
||
return Rendered{Title: title, Body: body, Link: "/admin"}
|
||
case KindTracksMissing:
|
||
return Rendered{
|
||
Title: plural(p.Count, "track went missing", "tracks went missing"),
|
||
Body: "Their files are no longer where the library expects them.",
|
||
Link: "/admin/missing-files",
|
||
}
|
||
case KindDuplicatesFound:
|
||
return Rendered{
|
||
Title: plural(p.Count, "duplicate group to review", "duplicate groups to review"),
|
||
Body: "The duplicate sweep found tracks holding the same recording.",
|
||
Link: "/admin/duplicates",
|
||
}
|
||
case KindDuplicatesResolved:
|
||
body := "Copies Lidarr does not need were merged into the one it tracks."
|
||
if p.Detail != "" {
|
||
body = p.Detail
|
||
}
|
||
return Rendered{
|
||
Title: plural(p.Count, "duplicate resolved automatically", "duplicates resolved automatically"),
|
||
Body: body,
|
||
Link: "/admin/duplicates?view=resolved",
|
||
}
|
||
case KindPlaybackErrors:
|
||
return Rendered{
|
||
Title: plural(p.Count, "playback error reported", "playback errors reported"),
|
||
Body: "A track failed to play for someone.",
|
||
Link: "/admin/playback-errors",
|
||
}
|
||
default:
|
||
return Rendered{Title: "Notification", Link: "/"}
|
||
}
|
||
}
|
||
|
||
func plural(n int64, one, many string) string {
|
||
if n == 1 {
|
||
return "1 " + one
|
||
}
|
||
if n < 1 {
|
||
// A coalesced kind always carries a count; this only guards a
|
||
// malformed row from reading "0 tracks went missing".
|
||
return "Some " + many
|
||
}
|
||
return fmt.Sprintf("%d %s", n, many)
|
||
}
|