Files
minstrel/internal/notifications/render.go
T
bvandeusenandClaude Opus 5.5 854c307834
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
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)
- 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>
2026-10-19 22:44:19 -04:00

191 lines
6.3 KiB
Go
Raw 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 merged into the one the album keeps: " + 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)
}