Files
minstrel/internal/notifications/render.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -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,
// 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)
}