Files
bvandeusenandClaude Opus 5.5 0f58ebcd6c
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 1m26s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m12s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: duplicates settle themselves: retitled copies across releases link, alike versions on one album are dismissed, and a file in another song's slot is removed so the album is fetched again (M498 #6159-#6162)
The operator's library had 536 groups waiting for review, 2026-10-19. A sample
showed most were one recording under two titles on two releases.

- The headline and the Duplicates count leave out cross-release groups, which
  the resolver keeps and links: the headline read 3,479 when 536 needed review.
- Any group spanning albums is cross_release and is linked as one song,
  whatever its titles say. Nothing is deleted.
- One album, two versions named as such ("Lovesick (Instrumental)") and not
  byte-identical: a new class, distinct, dismissed by the resolver.
- One album, one recording under different song titles (mismatch): the
  album's cached MusicBrainz track list decides. When exactly one slot's song
  is as long as the file and every other slot's song is clearly not, the other
  copies are removed and their tracks marked missing, so the reacquisition
  sweeper wants the album back. Otherwise the group stays for review.
- The duplicates_found notice moves from the sweep to the resolver: it names
  only groups a pass first left for review.
- Resolved automatically lists dismissals and wrong-slot removals beside
  merges.

Migration 0094 adds 'distinct' to the class CHECK (rule 36).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:22:38 -04:00

191 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package notifications
import (
"encoding/json"
"fmt"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/words"
)
// 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 since the notice was last
// read.
Count int64 `json:"count,omitempty"`
// Items name what Count counts, one each, newest first: "Weezer – Buddy
// Holly". A coalesced update puts its own in front and keeps ten (M542),
// so the body can say which, not only how many.
Items []string `json:"items,omitempty"`
// Detail is free text: scan_failed's error message. Rows from before
// Items also carry duplicates_resolved's summary here.
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: "/collection/reports"}
case KindScanFailed:
title := "Library scan failed"
if p.Count > 1 {
title = fmt.Sprintf("Library scan failed %d times", p.Count)
}
body := "The last scan stopped with an error."
if p.Detail != "" {
body = "The last scan stopped: " + p.Detail
}
return Rendered{Title: title, Body: body, Link: "/admin/library"}
case KindTracksMissing:
body := "Their files are no longer where the library expects them."
if names := nameList(p.Items, p.Count); names != "" {
body = "No longer on disk: " + names + "."
}
return Rendered{
Title: plural(p.Count, "track went missing", "tracks went missing"),
Body: body,
Link: "/collection/missing",
}
case KindDuplicatesFound:
body := "The duplicate sweep found tracks holding the same recording."
if names := nameList(p.Items, p.Count); names != "" {
body = "Held more than once: " + names + "."
}
return Rendered{
Title: plural(p.Count, "new duplicate to review", "new duplicates to review"),
Body: body,
Link: "/collection/duplicates",
}
case KindDuplicatesResolved:
body := "Extra copies of a song were merged into the one the album keeps."
switch names := nameList(p.Items, p.Count); {
case names != "":
body = "Extra copies removed, the right one kept: " + names + "."
case p.Detail != "":
body = p.Detail
}
return Rendered{
Title: plural(p.Count, "duplicate merged automatically", "duplicates merged automatically"),
Body: body,
Link: "/collection/duplicates?view=resolved",
}
case KindPlaybackErrors:
body := "A track failed to play for someone."
if names := nameList(p.Items, p.Count); names != "" {
body = "Failed to play: " + names + "."
}
return Rendered{
Title: plural(p.Count, "playback error reported", "playback errors reported"),
Body: body,
Link: "/collection/reports",
}
default:
return Rendered{Title: "Notification", Link: "/"}
}
}
// namesShown is how many of a notice's items its body names.
const namesShown = 3
// nameList names the first few items, then how many more of the total there
// are: "A, B, C and 4 more". Empty when there are no items.
func nameList(items []string, total int64) string {
if len(items) == 0 {
return ""
}
shown := items[:min(len(items), namesShown)]
more := max(total, int64(len(items))) - int64(len(shown))
if more > 0 {
return strings.Join(shown, ", ") + " and " + words.Count(more, "more", "more")
}
if len(shown) == 1 {
return shown[0]
}
return strings.Join(shown[:len(shown)-1], ", ") + " and " + shown[len(shown)-1]
}
func plural(n int64, one, many string) string {
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 words.Count(n, one, many)
}