feat: notifications say what changed and alert once: a grouped notice names the tracks, grows without re-alerting, and a phone pile lists its titles (M542 #6146-#6151)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m33s
release / android (push) Failing after 1m42s
release / integration (push) Failing after 4m42s
release / Build signed APK (releases and dev) (push) Successful in 4m55s
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

- An update to a grouped notice keeps its arrival time and email state, so the
  phone and the email no longer go off again each time it grows.
- Notices carry the names of what changed (newest ten), shown as three and
  "and N more": tracks still missing after the scan's move/replacement fold,
  duplicate groups new to a sweep, songs merged automatically, and the track
  and listener a playback error was for.
- Duplicates found counts only the groups new to that sweep; playback errors
  count reports, not the unresolved total.
- A scan failure says what stopped and links to Admin > Library.
- Android: a pile of more than three alerts lists the newest five titles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:44:19 -04:00
co-authored by Claude Opus 5.5
parent 2365514e5b
commit 854c307834
26 changed files with 486 additions and 190 deletions
+54 -13
View File
@@ -3,6 +3,7 @@ package notifications
import (
"encoding/json"
"fmt"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/words"
)
@@ -37,10 +38,15 @@ type Payload struct {
// 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 is the coalesced kinds' running total since the notice was last
// read.
Count int64 `json:"count,omitempty"`
// Detail is free text: scan_failed's error message, and what the duplicate
// resolver merged for duplicates_resolved.
// 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"`
}
@@ -101,37 +107,52 @@ func Render(kind Kind, payload []byte) Rendered {
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."
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"}
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: "Their files are no longer where the library expects them.",
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, "duplicate group to review", "duplicate groups to review"),
Body: "The duplicate sweep found tracks holding the same recording.",
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."
if p.Detail != "" {
switch names := nameList(p.Items, p.Count); {
case names != "":
body = "Extra copies merged into the one the album keeps: " + names + "."
case p.Detail != "":
body = p.Detail
}
return Rendered{
Title: plural(p.Count, "duplicate resolved automatically", "duplicates resolved automatically"),
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: "A track failed to play for someone.",
Body: body,
Link: "/collection/reports",
}
default:
@@ -139,6 +160,26 @@ func Render(kind Kind, payload []byte) Rendered {
}
}
// 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