Compare commits

...
6 Commits
Author SHA1 Message Date
bvandeusen 97e84a79eb Merge pull request 'Multi-value genres for FLAC/Ogg/MP4, readable tinted and error text, no missing-track seeds' (#143) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 31s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m41s
release / android (push) Successful in 4m44s
release / integration (push) Successful in 17m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 23:53:09 -04:00
bvandeusenandClaude Opus 5.5 37d3a5bcd3 fix(library): read every genre of FLAC, Ogg, Opus and MP4 files (#2500)
release / govulncheck (push) Successful in 50s
release / web (push) Successful in 2m3s
release / go (push) Successful in 2m18s
release / integration (push) Successful in 5m42s
release / android (push) Successful in 6m30s
release / Build signed APK (releases and dev) (push) Successful in 6m0s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
Vorbis comments repeat a field to give it several values (GENRE=Boom Bap,
GENRE=Downtempo, ...). dhowden/tag keeps comments in a map keyed by field
name, so each repeat overwrote the previous one and only the last genre
was stored. MP4 has the same gap: several data atoms in one ©gen atom, or
repeated ©gen atoms, collapse to one value.

On the operator's library 3,658 of 4,092 FLACs declare more than one
genre. Kupla's Life Forms carries eight and was stored as "Instrumental
Hip Hop" alone, so browse and the taste profile never saw the other seven.

- vorbisgenre.go reads the comment block directly: FLAC's metadata block
  (including FLACs behind an ID3v2 tag), and the comment packet of Ogg
  Vorbis and Opus, reassembled across pages when cover art makes it span
  several.
- mp4genre.go walks moov > udta > meta > ilst and returns every ©gen text
  value. It handles ISO and QuickTime meta layouts and a moov placed after
  mdat. A file with only the numeric gnre atom still falls back to
  dhowden, which resolves it.
- extractGenres routes VORBIS and MP4 through them, as #2499 did for
  ID3v2.
- tagReadVersion 3 -> 4, so the next scan re-reads the tags of files
  already indexed. Unchanged files keep their duration and fingerprint,
  so the pass costs tag reads only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:40:56 -04:00
bvandeusenandClaude Opus 5.5 efa3bf54ce fix(playlists): a missing track never seeds For You or Songs-like (#2701)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m27s
release / android (push) Successful in 5m2s
release / Build signed APK (releases and dev) (push) Successful in 5m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m15s
release / Verify release artifacts (tag releases only) (push) Skipped
PickTopPlayedTracksForUser's liked tier read general_likes without
joining tracks. With no plays to seed from, For You could pick a liked
track whose file is gone. The play tiers were already safe: their
play_events join filters missing_since.

The same gap was in PickTopPlayedTrackForArtistByUser's fallback, which
seeds Songs-like from the artist's newest album when there are no recent
plays. It could pick a missing track, and since #5296 a missing track is
never fetched for similarity, so that seed has no edges either. The
caller already skips an empty seed, so an artist whose tracks are all
missing gets no Songs-like mix instead of one aimed at nothing.

Integration tests cover both cases: a liked-but-missing track is not a
seed, and the Songs-like fallback moves to the next album once the
newest one's track goes missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:10:17 -04:00
bvandeusenandClaude Opus 5.5 743b6f5eac fix(web): error text uses error-fg on every surface, not only on tints (#3150)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m6s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m30s
release / android (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Raw error red fails AA as text even with no tint behind it: in dark mode
it measures 3.63:1 on obsidian, 3.23 on iron and 2.64 on slate, against
4.5. error-fg (the house formula, 50% toward parchment) measures 5.30 at
worst across both modes.

- All 19 text-error uses become text-error-fg: the "Couldn't load"
  messages on the admin pages, the integrations form errors, the flag
  popover, and the error toast's text. The toast keeps its error border,
  since a border is a graphic with a 3:1 floor.
- check-tint-contrast flags raw error text anywhere (text-error,
  class:text-error, color: var(--fs-error)) and leaves borders and
  outlines alone. Run against the files before the swap, it finds all 19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:00:28 -04:00
bvandeusenandClaude Opus 5.5 fdee77eaec fix(web): text on a tint of its own hue uses the house -fg tokens (#3150)
release / govulncheck (push) Successful in 38s
release / integration (push) Successful in 5m1s
release / Build + push container image (push) Successful in 1m17s
release / Verify release artifacts (tag releases only) (push) Skipped
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m48s
release / android (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
A hue painted as text on a color-mix tint of itself sits close to the
surface under it. On Minstrel's surfaces the raw accent on its 15% tint
measures 1.97:1 at worst (dark mode, hover surface), against AA's 4.5.

- tokens.json gains colors.fg: the five FabledSword -fg formulas (accent
  45%, success 45%, warning, error and info 50%), each mixed toward
  parchment so one declaration serves both modes. Success is Minstrel's
  moss. tokens-to-css emits them in :root.
- Tailwind exposes them as text-accent-fg, text-warning-fg, text-error-fg
  and text-info-fg.
- 23 sites swapped: 14 Tailwind class strings (PlayerBar and the admin
  count pills) and 9 CSS rules (StatusPill's four tones and five accent
  chips). Worst case after: accent-fg 5.03, error-fg 4.75, warning-fg
  4.92, success-fg 4.85.
- scripts/check-tint-contrast.js finds the pair in either spelling. Its
  test scans src in the web Vitest lane and fails on any new site, with
  fixture cases showing it can fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:51:52 -04:00
bvandeusenandClaude Opus 5.5 c17f4273c6 test(web): stub SvelteKit app modules suite-wide so no test loads the client runtime (#3943)
release / govulncheck (push) Successful in 32s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 4m55s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m42s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m4s
The home page reaches the real $app/navigation through AlbumCard and
AlbumMenu. That loads SvelteKit's client runtime, whose $app/paths reads
__SVELTEKIT_PAYLOAD__ at module load. The global is only there when the
kit plugin's define reaches the module, and under vitest that is not
reliable: page.test.ts failed to load on CI run 6576 and passed on its
re-run. #374 was the same class of failure.

vitest.setup.ts now mocks $app/navigation, $app/state and $app/paths for
every test. Per-file mocks still win. A small guard test fails if the
suite-wide mocks are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:26:21 -04:00
32 changed files with 1117 additions and 53 deletions
+6
View File
@@ -452,6 +452,7 @@ SELECT COALESCE(
FROM tracks t
JOIN albums a ON a.id = t.album_id
WHERE t.artist_id = $2
AND t.missing_since IS NULL -- #2701: the play branch filters; this one must too
ORDER BY a.release_date DESC NULLS LAST,
t.disc_number NULLS LAST,
t.track_number NULLS LAST,
@@ -497,6 +498,7 @@ alltime AS (
liked AS (
SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id AND t.missing_since IS NULL
WHERE gl.user_id = $1
),
chosen AS (
@@ -526,6 +528,10 @@ SELECT id
// Widened from a hard 7-day window, which made For-You disappear
// after a week of not listening and never recover on a self-hosted
// library with sparse history.
// A likes-only read still joins tracks: a like outlives its file, and a
// seed whose file is gone points For You at something the user can't hear
// (#2701). The other two tiers get the same filter from their play_events
// join.
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
if err != nil {
+6
View File
@@ -161,9 +161,14 @@ alltime AS (
AND pe.was_skipped = false
GROUP BY t.id
),
-- A likes-only read still joins tracks: a like outlives its file, and a
-- seed whose file is gone points For You at something the user can't hear
-- (#2701). The other two tiers get the same filter from their play_events
-- join.
liked AS (
SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id AND t.missing_since IS NULL
WHERE gl.user_id = $1
),
chosen AS (
@@ -201,6 +206,7 @@ SELECT COALESCE(
FROM tracks t
JOIN albums a ON a.id = t.album_id
WHERE t.artist_id = $2
AND t.missing_since IS NULL -- #2701: the play branch filters; this one must too
ORDER BY a.release_date DESC NULLS LAST,
t.disc_number NULLS LAST,
t.track_number NULLS LAST,
+19 -5
View File
@@ -32,11 +32,25 @@ func extractGenres(meta tag.Metadata, rs io.ReadSeeker) (genres []string, fellBa
// No frame at all is the common case for untagged files, and
// dhowden/tag will have nothing either — not worth flagging.
fellBack = meta.Genre() != ""
default:
// Vorbis comments (FLAC/OGG/Opus) and MP4 atoms don't go through
// dhowden's welding path, so its value is already a faithful read of
// the primary genre. Multi-value handling for those containers is a
// separate, unproven concern — see #2500.
case tag.VORBIS, tag.MP4:
// dhowden/tag keeps only the last of a repeated field here (#2500):
// see vorbisgenre.go and mp4genre.go.
var values []string
var err error
switch {
case meta.Format() == tag.MP4:
values, err = readMP4GenreValues(rs)
case meta.FileType() == tag.OGG:
values, err = readOggGenreValues(rs)
default:
values, err = readFLACGenreValues(rs)
}
if err == nil && len(values) > 0 {
return normaliseGenres(values), false
}
// err == nil with no values: the file declares no genre of its own
// (or, for MP4, only the numeric gnre atom, which dhowden resolves).
fellBack = err != nil && meta.Genre() != ""
}
return normaliseGenres([]string{meta.Genre()}), fellBack
}
+168
View File
@@ -0,0 +1,168 @@
package library
import (
"encoding/binary"
"errors"
"io"
"strings"
)
// MP4 has the same gap as Vorbis comments (#2500). A tag writer stores several
// genres as several "data" atoms inside one ©gen atom (mutagen and Picard do),
// or occasionally as repeated ©gen atoms. dhowden/tag keeps atoms in a map keyed
// by name (mp4.go: m.data[name] = data), so one value survives either way.
//
// Path to the values: moov > udta > meta > ilst > ©gen > data. A file that
// carries only the numeric "gnre" atom has no ©gen; this reader returns
// (nil, nil) for it, and extractGenres falls back to dhowden/tag, which resolves
// gnre to a name.
// maxMP4GenreAtomSize caps how much of one ©gen atom is buffered. Genre text is
// bytes; the cap only stops a corrupt size from making the scanner allocate.
const maxMP4GenreAtomSize = 1 << 20
var errMalformedAtom = errors.New("library: malformed MP4 atom")
type mp4Atom struct {
kind string
body, limit int64 // body start and end offsets in the file
}
// readMP4Atom reads the atom header at pos, which must lie within [pos, limit).
func readMP4Atom(rs io.ReadSeeker, pos, limit int64) (mp4Atom, error) {
if _, err := rs.Seek(pos, io.SeekStart); err != nil {
return mp4Atom{}, err
}
var hdr [8]byte
if _, err := io.ReadFull(rs, hdr[:]); err != nil {
return mp4Atom{}, errMalformedAtom
}
size := int64(binary.BigEndian.Uint32(hdr[0:4]))
hdrLen := int64(8)
switch size {
case 0: // runs to the end of the enclosing atom
size = limit - pos
case 1: // 64-bit size follows the type
var large [8]byte
if _, err := io.ReadFull(rs, large[:]); err != nil {
return mp4Atom{}, errMalformedAtom
}
size = int64(binary.BigEndian.Uint64(large[:]))
hdrLen = 16
}
if size < hdrLen || size > limit-pos {
return mp4Atom{}, errMalformedAtom
}
return mp4Atom{kind: string(hdr[4:8]), body: pos + hdrLen, limit: pos + size}, nil
}
// findMP4Child returns the first child of the given kind in [start, end).
func findMP4Child(rs io.ReadSeeker, start, end int64, kind string) (mp4Atom, bool, error) {
for pos := start; pos+8 <= end; {
a, err := readMP4Atom(rs, pos, end)
if err != nil {
return mp4Atom{}, false, err
}
if a.kind == kind {
return a, true, nil
}
pos = a.limit
}
return mp4Atom{}, false, nil
}
// readMP4GenreValues returns every ©gen value, in file order. rs is seeked
// as needed, so it is safe to call after dhowden/tag has consumed the reader.
func readMP4GenreValues(rs io.ReadSeeker) ([]string, error) {
end, err := rs.Seek(0, io.SeekEnd)
if err != nil {
return nil, err
}
// moov may sit after mdat; walking headers and seeking past bodies finds
// it either way without reading the audio.
cur := mp4Atom{body: 0, limit: end}
for _, kind := range []string{"moov", "udta", "meta"} {
next, found, err := findMP4Child(rs, cur.body, cur.limit, kind)
if err != nil {
return nil, errNoGenreFrame
}
if !found {
return nil, nil // no iTunes metadata at all
}
cur = next
}
// meta is a "full box" with four bytes of version and flags before its
// children in the ISO layout. QuickTime-style files omit them, so the
// first child (hdlr) starts straight away. Look before skipping.
if _, err := rs.Seek(cur.body, io.SeekStart); err != nil {
return nil, errNoGenreFrame
}
var peek [8]byte
if _, err := io.ReadFull(rs, peek[:]); err != nil {
return nil, errNoGenreFrame
}
if string(peek[4:8]) != "hdlr" {
cur.body += 4
}
ilst, found, err := findMP4Child(rs, cur.body, cur.limit, "ilst")
if err != nil {
return nil, errNoGenreFrame
}
if !found {
return nil, nil
}
var out []string
for pos := ilst.body; pos+8 <= ilst.limit; {
a, err := readMP4Atom(rs, pos, ilst.limit)
if err != nil {
return nil, errNoGenreFrame
}
pos = a.limit
if a.kind != "\xa9gen" {
continue
}
n := a.limit - a.body
if n > maxMP4GenreAtomSize {
return nil, errNoGenreFrame
}
if _, err := rs.Seek(a.body, io.SeekStart); err != nil {
return nil, errNoGenreFrame
}
body := make([]byte, n)
if _, err := io.ReadFull(rs, body); err != nil {
return nil, errNoGenreFrame
}
values, ok := mp4DataValues(body)
if !ok {
return nil, errNoGenreFrame
}
out = append(out, values...)
}
return out, nil
}
// mp4DataValues reads the "data" atoms in a ©gen body. Each is: size, "data",
// a 4-byte type indicator (1 = UTF-8 text), a 4-byte locale, then the value.
// Atoms of other types carry no text and are skipped.
func mp4DataValues(b []byte) ([]string, bool) {
var out []string
for len(b) >= 8 {
size := binary.BigEndian.Uint32(b[0:4])
if size < 8 || uint64(size) > uint64(len(b)) {
return nil, false
}
atom := b[:size]
b = b[size:]
if string(atom[4:8]) != "data" || len(atom) < 16 {
continue
}
if binary.BigEndian.Uint32(atom[8:12])&0x00FFFFFF != 1 {
continue
}
if v := strings.TrimSpace(string(atom[16:])); v != "" {
out = append(out, v)
}
}
return out, true
}
+5 -1
View File
@@ -60,7 +60,11 @@ var audioExtensions = map[string]bool{
// (#5241). Lidarr names albums by release group, not by the release id
// albums.mbid holds, so re-acquisition and request completion need it for
// the albums already in the library, not only for files added from now on.
const tagReadVersion int16 = 3
// 4: every value of a repeated genre field is read for FLAC, Ogg Vorbis, Opus
// and MP4 (#2500). dhowden/tag kept only the last, and 3,658 of the
// operator's 4,092 FLACs declare more than one, so most of their genres
// were never stored.
const tagReadVersion int16 = 4
type Stats struct {
Scanned int `json:"scanned"`
+214
View File
@@ -0,0 +1,214 @@
package library
import (
"bufio"
"bytes"
"encoding/binary"
"errors"
"io"
"strings"
)
// Why this file exists: Vorbis comments (FLAC, Ogg Vorbis, Opus) say a field
// has several values by repeating it:
//
// GENRE=Boom Bap
// GENRE=Downtempo
//
// dhowden/tag stores comments in a map keyed by field name
// (vorbis.go: m.c[strings.ToLower(k)] = v), so each repeat overwrites the last
// and only the final value survives. On the operator's library that was 3,658
// of 4,092 FLAC files: Kupla's Life Forms declares eight genres and was stored
// as "Instrumental Hip Hop" alone (#2500). Nothing looked corrupt; the other
// seven were simply never seen by browse or the taste profile.
//
// The comment block is read here directly, as the ID3v2 genre frame is in
// id3v2genre.go. Every other field still comes from dhowden/tag.
// maxVorbisCommentSize caps a comment block or packet. Comments are kilobytes;
// an embedded METADATA_BLOCK_PICTURE can take them to a few megabytes. The cap
// only stops a corrupt length from making the scanner allocate wildly.
const maxVorbisCommentSize = 16 << 20
var errMalformedComment = errors.New("library: malformed Vorbis comment")
// readFLACGenreValues returns every GENRE value in a FLAC file's comment block.
// (nil, nil) means the file is readable and declares no genre. rs is seeked to
// the start, so it is safe to call after dhowden/tag has consumed the reader.
func readFLACGenreValues(rs io.ReadSeeker) ([]string, error) {
if _, err := rs.Seek(0, io.SeekStart); err != nil {
return nil, err
}
var magic [4]byte
if _, err := io.ReadFull(rs, magic[:]); err != nil {
return nil, errNoGenreFrame
}
// Some taggers prepend an ID3v2 tag to a FLAC. The FLAC stream starts after it.
if string(magic[:3]) == "ID3" {
var rest [6]byte
if _, err := io.ReadFull(rs, rest[:]); err != nil {
return nil, errNoGenreFrame
}
size := syncsafeInt(rest[2:6])
if size < 0 {
return nil, errNoGenreFrame
}
skip := int64(10 + size)
if rest[1]&0x10 != 0 {
skip += 10 // 2.4 footer
}
if _, err := rs.Seek(skip, io.SeekStart); err != nil {
return nil, errNoGenreFrame
}
if _, err := io.ReadFull(rs, magic[:]); err != nil {
return nil, errNoGenreFrame
}
}
if string(magic[:]) != "fLaC" {
return nil, errNoGenreFrame
}
for {
var hdr [4]byte
if _, err := io.ReadFull(rs, hdr[:]); err != nil {
return nil, errNoGenreFrame
}
last := hdr[0]&0x80 != 0
blockType := hdr[0] & 0x7F
size := int64(hdr[1])<<16 | int64(hdr[2])<<8 | int64(hdr[3])
switch blockType {
case 4: // VORBIS_COMMENT
body := make([]byte, size)
if _, err := io.ReadFull(rs, body); err != nil {
return nil, errNoGenreFrame
}
return vorbisCommentGenres(body)
case 127: // invalid by spec; nothing after it can be trusted
return nil, errNoGenreFrame
}
if last {
return nil, nil // no comment block at all
}
if _, err := rs.Seek(size, io.SeekCurrent); err != nil {
return nil, errNoGenreFrame
}
}
}
// readOggGenreValues returns every GENRE value in an Ogg Vorbis or Opus file.
// The comments are the stream's second packet, which can span several pages
// when it carries cover art, so pages are reassembled until it is complete.
// Only the first logical stream is read; pages of any other are skipped.
func readOggGenreValues(rs io.ReadSeeker) ([]string, error) {
if _, err := rs.Seek(0, io.SeekStart); err != nil {
return nil, err
}
br := bufio.NewReader(rs)
var (
serial uint32
haveFirst bool
packet []byte
packetIdx int
)
for {
var hdr [27]byte
if _, err := io.ReadFull(br, hdr[:]); err != nil {
return nil, errNoGenreFrame
}
if string(hdr[0:4]) != "OggS" {
return nil, errNoGenreFrame
}
pageSerial := binary.LittleEndian.Uint32(hdr[14:18])
segments := make([]byte, hdr[26])
if _, err := io.ReadFull(br, segments); err != nil {
return nil, errNoGenreFrame
}
if !haveFirst {
serial, haveFirst = pageSerial, true
}
if pageSerial != serial {
var n int64
for _, l := range segments {
n += int64(l)
}
if _, err := io.CopyN(io.Discard, br, n); err != nil {
return nil, errNoGenreFrame
}
continue
}
for _, l := range segments {
if len(packet)+int(l) > maxVorbisCommentSize {
return nil, errNoGenreFrame
}
seg := make([]byte, l)
if _, err := io.ReadFull(br, seg); err != nil {
return nil, errNoGenreFrame
}
packet = append(packet, seg...)
if l == 255 {
continue // the packet carries on in the next segment
}
if packetIdx == 1 {
return oggCommentGenres(packet)
}
packetIdx++
packet = packet[:0]
}
}
}
// oggCommentGenres strips the codec's comment-packet prefix. Ogg FLAC and Speex
// are rare enough to leave to dhowden/tag.
func oggCommentGenres(packet []byte) ([]string, error) {
switch {
case bytes.HasPrefix(packet, []byte("\x03vorbis")):
return vorbisCommentGenres(packet[7:])
case bytes.HasPrefix(packet, []byte("OpusTags")):
return vorbisCommentGenres(packet[8:])
}
return nil, errNoGenreFrame
}
// vorbisCommentGenres reads a comment block (vendor string, then a count of
// length-prefixed "KEY=value" entries, little-endian lengths throughout) and
// returns the GENRE values in file order. Field names are case-insensitive by
// spec. Anything after the last entry, such as Vorbis's framing bit, is ignored.
func vorbisCommentGenres(b []byte) ([]string, error) {
next := func() ([]byte, bool) {
if len(b) < 4 {
return nil, false
}
n := binary.LittleEndian.Uint32(b)
if uint64(n) > uint64(len(b)-4) {
return nil, false
}
field := b[4 : 4+n]
b = b[4+n:]
return field, true
}
if _, ok := next(); !ok { // vendor string
return nil, errMalformedComment
}
if len(b) < 4 {
return nil, errMalformedComment
}
count := binary.LittleEndian.Uint32(b)
b = b[4:]
var out []string
// Each entry costs at least four bytes, so a corrupt count runs out of
// input long before it runs up a loop.
for i := uint32(0); i < count; i++ {
field, ok := next()
if !ok {
return nil, errMalformedComment
}
key, value, found := strings.Cut(string(field), "=")
if !found || !strings.EqualFold(key, "GENRE") {
continue
}
if value = strings.TrimSpace(value); value != "" {
out = append(out, value)
}
}
return out, nil
}
+288
View File
@@ -0,0 +1,288 @@
package library
import (
"bytes"
"encoding/binary"
"strings"
"testing"
"github.com/dhowden/tag"
)
// vorbisComment builds a comment block: vendor, count, then each "KEY=value".
func vorbisComment(fields ...string) []byte {
var b bytes.Buffer
vendor := "minstrel-test"
_ = binary.Write(&b, binary.LittleEndian, uint32(len(vendor)))
b.WriteString(vendor)
_ = binary.Write(&b, binary.LittleEndian, uint32(len(fields)))
for _, f := range fields {
_ = binary.Write(&b, binary.LittleEndian, uint32(len(f)))
b.WriteString(f)
}
return b.Bytes()
}
// buildFLAC assembles a STREAMINFO block and a comment block, optionally
// behind an ID3v2 tag the way some taggers write them.
func buildFLAC(withID3 bool, fields ...string) []byte {
var b bytes.Buffer
if withID3 {
pad := make([]byte, 20)
b.WriteString("ID3")
b.Write([]byte{4, 0, 0})
b.Write(synchsafeBytes(len(pad)))
b.Write(pad)
}
b.WriteString("fLaC")
b.Write([]byte{0x00, 0, 0, 34}) // STREAMINFO, not last
b.Write(make([]byte, 34))
vc := vorbisComment(fields...)
b.Write([]byte{0x84, byte(len(vc) >> 16), byte(len(vc) >> 8), byte(len(vc))}) // last, type 4
b.Write(vc)
return b.Bytes()
}
// oggCRC is the Ogg page checksum: CRC-32, polynomial 0x04c11db7, no
// reflection, zero initial value. dhowden/tag verifies it, so the end-to-end
// tests need real pages.
func oggCRC(p []byte) uint32 {
var crc uint32
for _, c := range p {
crc ^= uint32(c) << 24
for i := 0; i < 8; i++ {
if crc&0x80000000 != 0 {
crc = crc<<1 ^ 0x04c11db7
} else {
crc <<= 1
}
}
}
return crc
}
// buildOgg lays packets out as Ogg pages for one logical stream, splitting a
// packet across pages when it needs more than 255 lacing values.
func buildOgg(serial uint32, packets ...[]byte) []byte {
var out bytes.Buffer
var seq uint32
for _, p := range packets {
var lacing []byte
for n := len(p); ; n -= 255 {
if n >= 255 {
lacing = append(lacing, 255)
continue
}
lacing = append(lacing, byte(n))
break
}
continued := false
for off := 0; len(lacing) > 0; {
segs := lacing
if len(segs) > 255 {
segs = segs[:255]
}
lacing = lacing[len(segs):]
n := 0
for _, s := range segs {
n += int(s)
}
var page bytes.Buffer
page.WriteString("OggS")
page.WriteByte(0)
flags := byte(0)
if continued {
flags |= 0x01
}
if seq == 0 {
flags |= 0x02
}
page.WriteByte(flags)
page.Write(make([]byte, 8)) // granule position
_ = binary.Write(&page, binary.LittleEndian, serial)
_ = binary.Write(&page, binary.LittleEndian, seq)
page.Write(make([]byte, 4)) // checksum, filled below
page.WriteByte(byte(len(segs)))
page.Write(segs)
page.Write(p[off : off+n])
raw := page.Bytes()
binary.LittleEndian.PutUint32(raw[22:26], oggCRC(raw))
out.Write(raw)
off += n
seq++
continued = true
}
}
return out.Bytes()
}
func vorbisIdent() []byte { return append([]byte("\x01vorbis"), make([]byte, 23)...) }
// mp4Box wraps children in an atom of the given kind.
func mp4Box(kind string, children ...[]byte) []byte {
body := bytes.Join(children, nil)
b := make([]byte, 8, 8+len(body))
binary.BigEndian.PutUint32(b, uint32(8+len(body)))
copy(b[4:], kind)
return append(b, body...)
}
func mp4Text(value string) []byte {
return mp4Box("data", []byte{0, 0, 0, 1, 0, 0, 0, 0}, []byte(value))
}
// buildMP4 places the ©gen atoms under moov/udta/meta/ilst. isoMeta adds the
// four version/flags bytes ISO files carry before meta's children.
func buildMP4(isoMeta bool, genAtoms ...[]byte) []byte {
ilst := mp4Box("ilst", append([][]byte{mp4Box("\xa9nam", mp4Text("A Song"))}, genAtoms...)...)
hdlr := mp4Box("hdlr", make([]byte, 25))
var meta []byte
if isoMeta {
meta = mp4Box("meta", []byte{0, 0, 0, 0}, hdlr, ilst)
} else {
meta = mp4Box("meta", hdlr, ilst)
}
ftyp := mp4Box("ftyp", []byte("M4A \x00\x00\x00\x00M4A mp42isom"))
mdat := mp4Box("mdat", make([]byte, 64))
// moov after mdat, as many encoders write it.
return bytes.Join([][]byte{ftyp, mdat, mp4Box("moov", mp4Box("udta", meta))}, nil)
}
var lifeForms = []string{
"Boom Bap", "Downtempo", "Hip Hop", "Instrumental",
"Lo-Fi", "Lo-Fi Hip Hop", "Chillwave", "Instrumental Hip Hop",
}
func genreFields(values ...string) []string {
out := make([]string, len(values))
for i, v := range values {
out[i] = "GENRE=" + v
}
return out
}
// The #2500 regression, shaped like the operator's Kupla - Life Forms files:
// eight GENRE fields, of which dhowden/tag keeps the last.
func TestExtractGenres_FLACRepeatedFields(t *testing.T) {
data := buildFLAC(false, append([]string{"TITLE=Eons"}, genreFields(lifeForms...)...)...)
rs := bytes.NewReader(data)
meta, err := tag.ReadFrom(rs)
if err != nil {
t.Fatalf("tag.ReadFrom: %v", err)
}
// Confirm the upstream behaviour this fix exists for is still present.
if got := meta.Genre(); got != "Instrumental Hip Hop" {
t.Logf("note: dhowden/tag no longer keeps only the last GENRE (got %q)", got)
}
genres, fellBack := extractGenres(meta, rs)
if fellBack {
t.Error("fellBack = true, want false")
}
if !equalStrings(genres, lifeForms) {
t.Errorf("genres = %q, want %q", genres, lifeForms)
}
}
func TestReadFLACGenreValues(t *testing.T) {
tests := []struct {
name string
data []byte
want []string
err bool
}{
{"field names are case-insensitive", buildFLAC(false, "genre=Shoegaze", "Genre=Dream Pop"), []string{"Shoegaze", "Dream Pop"}, false},
{"behind an ID3v2 tag", buildFLAC(true, "GENRE=Jazz", "GENRE=Bop"), []string{"Jazz", "Bop"}, false},
{"no genre is not an error", buildFLAC(false, "TITLE=x"), nil, false},
{"empty values dropped", buildFLAC(false, "GENRE= ", "GENRE=Rock"), []string{"Rock"}, false},
{"a key that only starts with GENRE", buildFLAC(false, "GENRES=Rock", "GENRE=Jazz"), []string{"Jazz"}, false},
{"not a FLAC", []byte("RIFF....WAVEfmt "), nil, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := readFLACGenreValues(bytes.NewReader(tc.data))
if (err != nil) != tc.err {
t.Fatalf("err = %v, want error: %v", err, tc.err)
}
if !equalStrings(got, tc.want) {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestReadFLACGenreValues_TruncatedCommentIsAnError(t *testing.T) {
data := buildFLAC(false, "GENRE=Jazz", "GENRE=Bop")
if _, err := readFLACGenreValues(bytes.NewReader(data[:len(data)-2])); err == nil {
t.Error("err = nil for a cut-off comment block")
}
}
func TestExtractGenres_OggVorbisRepeatedFields(t *testing.T) {
comment := append([]byte("\x03vorbis"), vorbisComment(genreFields("Shoegaze", "Dream Pop")...)...)
comment = append(comment, 1) // framing bit
rs := bytes.NewReader(buildOgg(7, vorbisIdent(), comment))
meta, err := tag.ReadFrom(rs)
if err != nil {
t.Fatalf("tag.ReadFrom: %v", err)
}
genres, fellBack := extractGenres(meta, rs)
if fellBack || !equalStrings(genres, []string{"Shoegaze", "Dream Pop"}) {
t.Errorf("genres = %q (fellBack %v), want [Shoegaze Dream Pop]", genres, fellBack)
}
}
func TestReadOggGenreValues_Opus(t *testing.T) {
head := append([]byte("OpusHead"), make([]byte, 11)...)
tags := append([]byte("OpusTags"), vorbisComment(genreFields("Ambient", "Drone")...)...)
got, err := readOggGenreValues(bytes.NewReader(buildOgg(3, head, tags)))
if err != nil || !equalStrings(got, []string{"Ambient", "Drone"}) {
t.Errorf("got %q, %v; want [Ambient Drone]", got, err)
}
}
// Cover art makes the comment packet span pages. A reader that stopped at the
// first page boundary would read a truncated block.
func TestReadOggGenreValues_PacketSpansPages(t *testing.T) {
fields := append([]string{"METADATA_BLOCK_PICTURE=" + strings.Repeat("A", 70000)}, genreFields("Jazz", "Bop")...)
comment := append([]byte("\x03vorbis"), vorbisComment(fields...)...)
got, err := readOggGenreValues(bytes.NewReader(buildOgg(9, vorbisIdent(), comment)))
if err != nil || !equalStrings(got, []string{"Jazz", "Bop"}) {
t.Errorf("got %q, %v; want [Jazz Bop]", got, err)
}
}
func TestReadMP4GenreValues(t *testing.T) {
tests := []struct {
name string
data []byte
want []string
}{
{"several data atoms in one ©gen", buildMP4(true, mp4Box("\xa9gen", mp4Text("Hip Hop"), mp4Text("Lo-Fi"))), []string{"Hip Hop", "Lo-Fi"}},
{"repeated ©gen atoms", buildMP4(true, mp4Box("\xa9gen", mp4Text("Hip Hop")), mp4Box("\xa9gen", mp4Text("Lo-Fi"))), []string{"Hip Hop", "Lo-Fi"}},
{"QuickTime meta without version bytes", buildMP4(false, mp4Box("\xa9gen", mp4Text("Jazz"), mp4Text("Bop"))), []string{"Jazz", "Bop"}},
{"no ©gen is not an error", buildMP4(true), nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := readMP4GenreValues(bytes.NewReader(tc.data))
if err != nil {
t.Fatalf("err = %v", err)
}
if !equalStrings(got, tc.want) {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestExtractGenres_MP4MultiValue(t *testing.T) {
rs := bytes.NewReader(buildMP4(true, mp4Box("\xa9gen", mp4Text("Hip Hop"), mp4Text("Lo-Fi"))))
meta, err := tag.ReadFrom(rs)
if err != nil {
t.Fatalf("tag.ReadFrom: %v", err)
}
genres, fellBack := extractGenres(meta, rs)
if fellBack || !equalStrings(genres, []string{"Hip Hop", "Lo-Fi"}) {
t.Errorf("genres = %q (fellBack %v), want [Hip Hop Lo-Fi]", genres, fellBack)
}
}
@@ -0,0 +1,85 @@
package playlists_test
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func likeTrack(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`,
userID, trackID); err != nil {
t.Fatalf("like track: %v", err)
}
}
func markMissing(t *testing.T, pool *pgxpool.Pool, trackID pgtype.UUID) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`UPDATE tracks SET missing_since = now() WHERE id = $1`, trackID); err != nil {
t.Fatalf("mark missing: %v", err)
}
}
// TestPickTopPlayedTracksForUser_LikedTierSkipsMissing: with no plays, For
// You seeds from likes. A liked track whose file is gone is not a seed (#2701).
func TestPickTopPlayedTracksForUser_LikedTierSkipsMissing(t *testing.T) {
pool := newPool(t)
u := seedUser(t, pool, "seedmissing")
present := seedTrack(t, pool, "Present", "Seed Artist A")
gone := seedTrack(t, pool, "Gone", "Seed Artist B")
likeTrack(t, pool, u.ID, present.ID)
likeTrack(t, pool, u.ID, gone.ID)
markMissing(t, pool, gone.ID)
seeds, err := dbq.New(pool).PickTopPlayedTracksForUser(context.Background(), u.ID)
if err != nil {
t.Fatalf("pick seeds: %v", err)
}
if len(seeds) != 1 || seeds[0] != present.ID {
t.Fatalf("seeds = %v, want only the present liked track %v", seeds, present.ID)
}
}
// TestPickTopPlayedTrackForArtistByUser_FallbackSkipsMissing: with no plays,
// Songs-like seeds from the artist's newest album. When that album's track is
// missing, the seed comes from the next album rather than the missing file.
func TestPickTopPlayedTrackForArtistByUser_FallbackSkipsMissing(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
u := seedUser(t, pool, "songslikemissing")
older := seedTrack(t, pool, "Older", "Songs Like Artist")
newerAlbum := seedAlbumForArtist(t, pool, "Newer Album", older.ArtistID)
newer := seedTrackForArtist(t, pool, "Newer", newerAlbum, older.ArtistID)
if _, err := pool.Exec(ctx, `UPDATE albums SET release_date = '2010-01-01' WHERE id = $1`, older.AlbumID); err != nil {
t.Fatalf("date older album: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE albums SET release_date = '2020-01-01' WHERE id = $1`, newerAlbum); err != nil {
t.Fatalf("date newer album: %v", err)
}
q := dbq.New(pool)
args := dbq.PickTopPlayedTrackForArtistByUserParams{UserID: u.ID, ArtistID: older.ArtistID}
got, err := q.PickTopPlayedTrackForArtistByUser(ctx, args)
if err != nil {
t.Fatalf("pick seed: %v", err)
}
if got != newer.ID {
t.Fatalf("before marking: seed = %v, want the newest album's track %v", got, newer.ID)
}
markMissing(t, pool, newer.ID)
got, err = q.PickTopPlayedTrackForArtistByUser(ctx, args)
if err != nil {
t.Fatalf("pick seed: %v", err)
}
if got != older.ID {
t.Fatalf("after marking: seed = %v, want the present older track %v", got, older.ID)
}
}
+110
View File
@@ -0,0 +1,110 @@
// Finds a hue painted as TEXT on a tint of itself (#3150). The tint sits close
// to the surface under it, so the raw hue on it measures 1.6–2.4:1 against
// AA's 4.5. The fix is the hue's -fg partner (tokens.json colors.fg), mixed
// toward parchment: accent-fg on an accent tint measures 5.03.
//
// Two spellings are checked:
// Tailwind one class string holding both `bg-X-tint` / `bg-X/NN` and `text-X`
// CSS one rule block holding both a `color-mix(… var(--fs-X) N%,
// transparent)` background and `color: var(--fs-X)`
//
// A pair split across two strings (a tint in one class: directive, the text
// in another) is not seen. The lookbehind on `color:` keeps border-color and
// outline-color out: a border is a graphic with a 3:1 floor, not text.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
export const HUES = {
accent: 'accent-fg',
warning: 'warning-fg',
error: 'error-fg',
info: 'info-fg',
moss: 'success-fg'
};
// The quoted string around index i. Read outward from the tint class rather
// than pairing every quote in the file, which an apostrophe in prose
// ("Couldn't") throws out of step.
function enclosingString(source, i) {
const start = Math.max(...['"', "'", '`'].map((q) => source.lastIndexOf(q, i)));
if (start < 0) return '';
const end = source.indexOf(source[start], i);
return end < 0 ? '' : source.slice(start, end + 1);
}
/** Tailwind class strings that put text-X on a bg-X tint. */
export function findTailwind(source) {
const found = [];
for (const hue of Object.keys(HUES)) {
const tint = new RegExp(`(?<=^|[\\s'"\`])bg-${hue}(?:-tint|/\\d+)(?=$|[\\s'"\`])`, 'g');
const text = new RegExp(`(?:^|[\\s'"\`])text-${hue}(?=$|[\\s'"\`])`);
for (const m of source.matchAll(tint)) {
if (text.test(enclosingString(source, m.index))) {
found.push({ hue, index: m.index, fix: `text-${hue === 'moss' ? 'success' : hue}-fg` });
}
}
}
return found;
}
/** CSS rule blocks that put color: var(--fs-X) on a color-mix tint of X. */
export function findCss(source) {
const found = [];
for (const m of source.matchAll(/\{([^{}]*)\}/g)) {
for (const hue of Object.keys(HUES)) {
const tint = new RegExp(
`background(?:-color)?\\s*:\\s*color-mix\\(\\s*in srgb\\s*,\\s*var\\(--fs-${hue}\\)\\s*\\d+%\\s*,\\s*transparent\\s*\\)`
);
const text = new RegExp(`(?<![-\\w])color\\s*:\\s*var\\(--fs-${hue}\\)`);
if (tint.test(m[1]) && text.test(m[1])) {
found.push({ hue, index: m.index, fix: `var(--fs-${HUES[hue]})` });
}
}
}
return found;
}
// Hues that fail AA as text even on a plain surface, so the raw hue is never
// text at all: error measures 2.64:1 on slate and 3.23 on iron in dark mode,
// against 4.5; error-fg measures 5.30 at worst. A border or outline in the
// hue is a graphic with a 3:1 floor and stays allowed.
export const TEXT_NEVER_RAW = ['error'];
/** Raw text-X classes and color: var(--fs-X) for the hues above. */
export function findRawText(source) {
const found = [];
for (const hue of TEXT_NEVER_RAW) {
const patterns = [
[new RegExp(`(?<=^|[\\s'"\`:])text-${hue}(?=$|[\\s'"\`=])`, 'g'), `text-${hue}-fg`],
[new RegExp(`(?<![-\\w])color\\s*:\\s*var\\(--fs-${hue}\\)`, 'g'), `var(--fs-${HUES[hue]})`]
];
for (const [re, fix] of patterns) {
for (const m of source.matchAll(re)) found.push({ hue, index: m.index, fix });
}
}
return found;
}
function lineOf(source, index) {
return source.slice(0, index).split('\n').length;
}
function* walk(dir) {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) yield* walk(path);
else if (/\.(svelte|ts|js|css)$/.test(name) && !/\.test\.(ts|js)$/.test(name)) yield path;
}
}
/** Every violation under root, as "path:line hue → fix". */
export function scan(root, base = root) {
const out = [];
for (const path of walk(root)) {
const source = readFileSync(path, 'utf8');
for (const f of [...findTailwind(source), ...findCss(source), ...findRawText(source)]) {
out.push(`${relative(base, path)}:${lineOf(source, f.index)} ${f.hue} → ${f.fix}`);
}
}
return out;
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, test } from 'vitest';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { findCss, findRawText, findTailwind, scan } from './check-tint-contrast.js';
const web = resolve(dirname(fileURLToPath(import.meta.url)), '..');
describe('hue-on-own-tint check (#3150)', () => {
test('no source paints a hue as text on a tint of itself', () => {
expect(scan(resolve(web, 'src'), web)).toEqual([]);
});
describe('Tailwind', () => {
test('finds text-X on bg-X-tint and bg-X/NN in one class string', () => {
expect(findTailwind(`class="rounded bg-accent-tint px-2 text-accent"`)).toHaveLength(1);
expect(findTailwind(`'bg-error/15 text-error'`)).toHaveLength(1);
expect(findTailwind("`bg-moss/10 text-moss`")[0].fix).toBe('text-success-fg');
});
test('a class string spanning lines is one string', () => {
expect(findTailwind(`class="bg-accent-tint\n text-accent"`)).toHaveLength(1);
});
test('an apostrophe earlier in the file does not hide a pair', () => {
expect(
findTailwind(`<p>Couldn't add</p>\n<span class={on ? 'bg-accent-tint text-accent' : ''}>`)
).toHaveLength(1);
});
test('passes the -fg partner, another hue, and a tint with no hue text', () => {
expect(findTailwind(`"bg-accent-tint text-accent-fg"`)).toEqual([]);
expect(findTailwind(`"bg-accent-tint text-error"`)).toEqual([]);
expect(findTailwind(`"bg-accent-tint text-text-primary"`)).toEqual([]);
expect(findTailwind(`"text-accent"`)).toEqual([]);
});
});
describe('raw error text, on any surface', () => {
test('finds text-error, class:text-error and color: var(--fs-error)', () => {
expect(findRawText(`<p class="text-sm text-error">x</p>`)).toHaveLength(1);
expect(findRawText(`<div class:text-error={bad}>`)).toHaveLength(1);
expect(findRawText(`.msg { color: var(--fs-error); }`)[0].fix).toBe('var(--fs-error-fg)');
});
test('passes error-fg, and error as a border or outline', () => {
expect(findRawText(`<p class="text-error-fg">x</p>`)).toEqual([]);
expect(findRawText(`<div class="border-error" class:border-error={bad}>`)).toEqual([]);
expect(findRawText(`.a { border-color: var(--fs-error); outline-color: var(--fs-error); }`)).toEqual([]);
});
});
describe('CSS', () => {
const tinted = (color) =>
`.pill { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); ${color} }`;
test('finds color: var(--fs-X) on a color-mix tint of X', () => {
expect(findCss(tinted('color: var(--fs-warning);'))).toHaveLength(1);
expect(findCss(tinted('color: var(--fs-warning);'))[0].fix).toBe('var(--fs-warning-fg)');
});
test('passes the -fg partner and border or outline colours', () => {
expect(findCss(tinted('color: var(--fs-warning-fg);'))).toEqual([]);
expect(findCss(tinted('border-color: var(--fs-warning);'))).toEqual([]);
expect(findCss(tinted('outline-color: var(--fs-warning);'))).toEqual([]);
});
test('a tint and a colour in different rules are not a pair', () => {
expect(
findCss(
`.a { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }\n.b { color: var(--fs-accent); }`
)
).toEqual([]);
});
});
});
+8
View File
@@ -19,6 +19,14 @@ export function emit(tokens) {
for (const [name, value] of Object.entries(tokens.colors.flat)) {
lines.push(` --fs-${name}: ${value};`);
}
// The house -fg tokens (FabledSword, 2026-08-27; #3150): a hue as TEXT on a
// tint of itself, mixed toward parchment. Parchment inverts in light mode,
// so one declaration lightens the text on dark surfaces and darkens it on
// light ones. Declared on :root, the element data-theme is set on, so the
// var()s resolve against that mode's parchment.
for (const [name, value] of Object.entries(tokens.colors.fg ?? {})) {
lines.push(` --fs-${name}: ${value};`);
}
for (const [name, value] of Object.entries(tokens.radii)) {
lines.push(` --fs-radius-${name}: ${value};`);
}
+11 -1
View File
@@ -5,7 +5,8 @@ const sample = {
colors: {
dark: { obsidian: '#000', parchment: '#FFF' },
light: { obsidian: '#FFF', parchment: '#000' },
flat: { accent: '#4A6B5C', 'on-action': '#E8E4D8' }
flat: { accent: '#4A6B5C', 'on-action': '#E8E4D8' },
fg: { 'accent-fg': 'color-mix(in srgb, var(--fs-accent) 45%, var(--fs-parchment))' }
},
radii: { sm: '4px' },
fontStacks: {
@@ -40,4 +41,13 @@ describe('tokens-to-css emit()', () => {
expect(css).toContain('--fs-radius-sm: 4px;');
expect(css).toContain("--fs-font-display: 'Fraunces', serif;");
});
test('emits the -fg tokens in :root, not the light block', () => {
const css = emit(sample);
const root = css.match(/:root \{([\s\S]*?)\}/);
expect(root).not.toBeNull();
expect(root[1]).toContain('--fs-accent-fg: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-parchment));');
const light = css.match(/\[data-theme="light"\] \{([\s\S]*?)\}/);
expect(light[1]).not.toContain('-fg');
});
});
@@ -169,7 +169,7 @@
font-size: 11px;
line-height: 14px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
color: var(--fs-accent-fg);
}
/* Muted rather than accented: a parked card should recede, not compete
with the live suggestions around it. Same geometry as .kept-pill so the
+1 -1
View File
@@ -79,7 +79,7 @@
></textarea>
</label>
{#if error}
<p class="mt-2 text-xs text-error">Couldn't save flag — {error}</p>
<p class="mt-2 text-xs text-error-fg">Couldn't save flag — {error}</p>
{/if}
<div class="mt-3 flex justify-end gap-2">
<button
+6 -6
View File
@@ -184,7 +184,7 @@
aria-pressed={player.shuffle}
onclick={toggleShuffle}
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.shuffle
? 'bg-accent-tint text-accent'
? 'bg-accent-tint text-accent-fg'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
<Shuffle size={16} strokeWidth={1.5} />
@@ -194,7 +194,7 @@
aria-label={repeatLabel}
onclick={cycleRepeat}
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.repeat !== 'off'
? 'bg-accent-tint text-accent'
? 'bg-accent-tint text-accent-fg'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
{#if player.repeat === 'one'}
@@ -209,7 +209,7 @@
aria-label={player.queueDrawerOpen ? 'Close queue' : 'Open queue'}
aria-pressed={player.queueDrawerOpen}
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.queueDrawerOpen
? 'bg-accent-tint text-accent'
? 'bg-accent-tint text-accent-fg'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
<ListMusic size={16} strokeWidth={1.5} />
@@ -392,7 +392,7 @@
aria-pressed={player.shuffle}
onclick={toggleShuffle}
class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.shuffle
? 'bg-accent-tint text-accent'
? 'bg-accent-tint text-accent-fg'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
<Shuffle size={18} strokeWidth={1.5} />
@@ -402,7 +402,7 @@
aria-label={repeatLabel}
onclick={cycleRepeat}
class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.repeat !== 'off'
? 'bg-accent-tint text-accent'
? 'bg-accent-tint text-accent-fg'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
{#if player.repeat === 'one'}
@@ -417,7 +417,7 @@
aria-label={player.queueDrawerOpen ? 'Close queue' : 'Open queue'}
aria-pressed={player.queueDrawerOpen}
class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.queueDrawerOpen
? 'bg-accent-tint text-accent'
? 'bg-accent-tint text-accent-fg'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
<ListMusic size={18} strokeWidth={1.5} />
+4 -4
View File
@@ -37,18 +37,18 @@
}
.pill[data-tone='warning'] {
background: color-mix(in srgb, var(--fs-warning) 15%, transparent);
color: var(--fs-warning);
color: var(--fs-warning-fg);
}
.pill[data-tone='info'] {
background: color-mix(in srgb, var(--fs-info) 15%, transparent);
color: var(--fs-info);
color: var(--fs-info-fg);
}
.pill[data-tone='success'] {
background: color-mix(in srgb, var(--fs-moss) 15%, transparent);
color: var(--fs-moss);
color: var(--fs-success-fg);
}
.pill[data-tone='error'] {
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
color: var(--fs-error);
color: var(--fs-error-fg);
}
</style>
+1 -1
View File
@@ -15,7 +15,7 @@
class:border-border={toast.value.variant === 'info'}
class:text-text-primary={toast.value.variant === 'info'}
class:border-error={toast.value.variant === 'error'}
class:text-error={toast.value.variant === 'error'}
class:text-error-fg={toast.value.variant === 'error'}
>
{toast.value.message}
</div>
+5
View File
@@ -15,6 +15,11 @@
--fs-info: #3D5A6E;
--fs-accent: #4A6B5C;
--fs-on-action: #E8E4D8;
--fs-accent-fg: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-parchment));
--fs-success-fg: color-mix(in srgb, var(--fs-moss) 45%, var(--fs-parchment));
--fs-warning-fg: color-mix(in srgb, var(--fs-warning) 50%, var(--fs-parchment));
--fs-error-fg: color-mix(in srgb, var(--fs-error) 50%, var(--fs-parchment));
--fs-info-fg: color-mix(in srgb, var(--fs-info) 50%, var(--fs-parchment));
--fs-radius-sm: 4px;
--fs-radius-md: 8px;
--fs-radius-lg: 12px;
+7
View File
@@ -27,6 +27,13 @@
"info": "#3D5A6E",
"accent": "#4A6B5C",
"on-action": "#E8E4D8"
},
"fg": {
"accent-fg": "color-mix(in srgb, var(--fs-accent) 45%, var(--fs-parchment))",
"success-fg": "color-mix(in srgb, var(--fs-moss) 45%, var(--fs-parchment))",
"warning-fg": "color-mix(in srgb, var(--fs-warning) 50%, var(--fs-parchment))",
"error-fg": "color-mix(in srgb, var(--fs-error) 50%, var(--fs-parchment))",
"info-fg": "color-mix(in srgb, var(--fs-info) 50%, var(--fs-parchment))"
}
},
"radii": { "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px" },
@@ -325,7 +325,7 @@
<!-- Timeline -->
{#if diagQuery.isError}
<p class="text-error">Couldn't load: {errMessage(diagQuery.error)}</p>
<p class="text-error-fg">Couldn't load: {errMessage(diagQuery.error)}</p>
{:else if diagQuery.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if rows.length === 0}
+3 -3
View File
@@ -138,7 +138,7 @@
<h2 class="font-display text-2xl font-medium text-text-primary">Duplicates</h2>
{#if total > 0}
<span
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="duplicates-count-pill"
>
{total}
@@ -188,7 +188,7 @@
{#if query.isPending}
<p class="text-text-secondary">Loading duplicates…</p>
{:else if query.isError}
<p class="text-error">Couldn't load the duplicates report.</p>
<p class="text-error-fg">Couldn't load the duplicates report.</p>
{:else if groups.length === 0}
<!-- Three states all show zero groups and mean different things. Telling
them apart is what stops an empty page reading as a broken feature. -->
@@ -302,7 +302,7 @@
<!-- The proposed copy to keep, with the rule that chose it.
A default the merge will let the operator override. -->
<span
class="shrink-0 rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
class="shrink-0 rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="survivor-badge"
title="Proposed to keep: {group.survivor_reason}"
>
@@ -463,7 +463,7 @@
{/each}
</select>
{#if testListErrors.quality_profiles}
<p class="mt-1 text-xs text-error">
<p class="mt-1 text-xs text-error-fg">
Couldn't fetch quality profiles — {testListErrors.quality_profiles}
</p>
{/if}
@@ -481,7 +481,7 @@
{/each}
</select>
{#if testListErrors.metadata_profiles}
<p class="mt-1 text-xs text-error">
<p class="mt-1 text-xs text-error-fg">
Couldn't fetch metadata profiles — {testListErrors.metadata_profiles}
</p>
{/if}
@@ -501,7 +501,7 @@
{/each}
</select>
{#if testListErrors.root_folders}
<p class="mt-1 text-xs text-error">
<p class="mt-1 text-xs text-error-fg">
Couldn't fetch root folders — {testListErrors.root_folders}
</p>
{/if}
@@ -513,11 +513,11 @@
Connected — Lidarr {testResult.version}
</p>
{:else}
<p class="text-sm text-error">Connection failed — {testResult.error}</p>
<p class="text-sm text-error-fg">Connection failed — {testResult.error}</p>
{/if}
{/if}
{#if saveError}
<p class="text-sm text-error">Save failed — {saveError}</p>
<p class="text-sm text-error-fg">Save failed — {saveError}</p>
{/if}
<div class="flex items-center gap-2 pt-2">
@@ -625,7 +625,7 @@
OK{coverTestResults[provider.id].duration_ms ? ` (${coverTestResults[provider.id].duration_ms}ms)` : ''}
</p>
{:else}
<p class="text-sm text-error">
<p class="text-sm text-error-fg">
Failed — {coverTestResults[provider.id].error}
</p>
{/if}
@@ -722,7 +722,7 @@
OK{tagTestResults[provider.id].duration_ms ? ` (${tagTestResults[provider.id].duration_ms}ms)` : ''}
</p>
{:else}
<p class="text-sm text-error">
<p class="text-sm text-error-fg">
Failed — {tagTestResults[provider.id].error}
</p>
{/if}
@@ -837,7 +837,7 @@
class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
{#if disconnectError}
<p class="mt-2 text-sm text-error">Disconnect failed — {disconnectError}</p>
<p class="mt-2 text-sm text-error-fg">Disconnect failed — {disconnectError}</p>
{/if}
<div class="mt-5 flex justify-end gap-2">
<button
@@ -53,7 +53,7 @@
<h2 class="font-display text-2xl font-medium text-text-primary">Missing files</h2>
{#if total > 0}
<span
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="missing-count-pill"
>
{total}
@@ -74,7 +74,7 @@
{#if query.isPending}
<p class="text-text-secondary">Checking what's missing…</p>
{:else if query.isError}
<p class="text-error">Couldn't load the missing-files list.</p>
<p class="text-error-fg">Couldn't load the missing-files list.</p>
{:else if groups.length === 0}
<!-- Empty state explains the feature, not just the emptiness: an operator
who has never seen this page shouldn't have to guess what would put a
@@ -167,7 +167,7 @@
</p>
</div>
{#if !query.isPending && !query.isError && !resolved}
<span class="rounded-full bg-accent/15 px-3 py-1 text-sm text-accent">
<span class="rounded-full bg-accent/15 px-3 py-1 text-sm text-accent-fg">
{rows.length} unresolved
</span>
{/if}
@@ -197,7 +197,7 @@
</div>
{#if query.isError}
<p class="text-error">Couldn't load: {errMessage(query.error)}</p>
<p class="text-error-fg">Couldn't load: {errMessage(query.error)}</p>
{:else if query.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if rows.length === 0}
@@ -220,7 +220,7 @@
{kindLabel(r.kind)}
</span>
{#if resolved && r.resolution}
<span class="rounded bg-accent/15 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-accent">
<span class="rounded bg-accent/15 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-accent-fg">
{resolutionLabel(r.resolution)}
</span>
{/if}
+4 -4
View File
@@ -150,7 +150,7 @@
<h2 class="font-display text-2xl font-medium text-text-primary">Quarantine</h2>
{#if totalReports > 0}
<span
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="report-count-pill"
>
{totalReports}
@@ -163,7 +163,7 @@
{#if query.isPending}
<p class="text-text-secondary">Reading the queue…</p>
{:else if query.isError}
<p class="text-error">Couldn't load the quarantine queue.</p>
<p class="text-error-fg">Couldn't load the quarantine queue.</p>
{:else if rows.length === 0}
<p class="text-text-secondary">Nothing to triage right now.</p>
{:else}
@@ -356,7 +356,7 @@
/>
</label>
{#if deleteLidarrError}
<p class="mt-3 text-sm text-error" data-testid="delete-lidarr-error">
<p class="mt-3 text-sm text-error-fg" data-testid="delete-lidarr-error">
{deleteLidarrError}
</p>
{/if}
@@ -392,6 +392,6 @@
font-size: 11px;
line-height: 14px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
color: var(--fs-accent-fg);
}
</style>
+3 -3
View File
@@ -183,7 +183,7 @@
{tab.label}
{#if isActive}
<span
class="ml-1.5 inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
class="ml-1.5 inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="active-tab-count"
>
{rows.length}
@@ -198,7 +198,7 @@
{#if query.isPending}
<p class="text-text-secondary">Reading the queue…</p>
{:else if query.isError}
<p class="text-error">Couldn't load requests.</p>
<p class="text-error-fg">Couldn't load requests.</p>
{:else if rows.length === 0}
<p class="text-text-secondary">Nothing here.</p>
{:else}
@@ -377,7 +377,7 @@
font-size: 11px;
line-height: 14px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
color: var(--fs-accent-fg);
text-transform: capitalize;
}
</style>
+3 -3
View File
@@ -229,7 +229,7 @@
{#if usersQuery.isPending}
<p class="text-text-secondary">Reading users…</p>
{:else if usersQuery.isError}
<p class="text-error">Couldn't load users.</p>
<p class="text-error-fg">Couldn't load users.</p>
{:else if users.length === 0}
<p class="text-text-secondary">No users yet.</p>
{:else}
@@ -274,7 +274,7 @@
<span class="text-base font-medium text-text-primary">{u.username}</span>
{#if u.is_admin}
<span
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="admin-badge"
>admin</span>
{/if}
@@ -318,7 +318,7 @@
{#if invitesQuery.isPending}
<p class="text-text-secondary">Reading invites…</p>
{:else if invitesQuery.isError}
<p class="text-error">Couldn't load invites.</p>
<p class="text-error-fg">Couldn't load invites.</p>
{:else if invites.length === 0}
<p class="text-sm text-text-secondary">No active invites.</p>
{:else}
+1 -1
View File
@@ -100,6 +100,6 @@
font-size: 11px;
line-height: 14px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
color: var(--fs-accent-fg);
}
</style>
+1 -1
View File
@@ -160,7 +160,7 @@
font-size: 11px;
line-height: 14px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
color: var(--fs-accent-fg);
text-transform: capitalize;
}
</style>
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test, vi } from 'vitest';
import { goto } from '$app/navigation';
import { page } from '$app/state';
// Guards the suite-wide $app mocks in vitest.setup.ts (#3943). If they are
// removed, tests that never mock $app themselves go back to loading
// SvelteKit's client runtime, which fails at module load on some CI runs.
describe('suite-wide SvelteKit app-module mocks', () => {
test('$app/navigation is the mock, not the client runtime', () => {
expect(vi.isMockFunction(goto)).toBe(true);
});
test('$app/state gives a page at the root URL', () => {
expect(page.url.pathname).toBe('/');
});
});
+7 -4
View File
@@ -25,11 +25,14 @@ export default {
},
accent: {
DEFAULT: 'var(--fs-accent)',
tint: 'color-mix(in srgb, var(--fs-accent) 12%, transparent)'
tint: 'color-mix(in srgb, var(--fs-accent) 12%, transparent)',
// Accent TEXT on an accent tint (#3150). The raw accent on its own
// tint measures 1.97:1 at worst; this measures 5.03.
fg: 'var(--fs-accent-fg)'
},
warning: 'var(--fs-warning)',
error: 'var(--fs-error)',
info: 'var(--fs-info)'
warning: { DEFAULT: 'var(--fs-warning)', fg: 'var(--fs-warning-fg)' },
error: { DEFAULT: 'var(--fs-error)', fg: 'var(--fs-error-fg)' },
info: { DEFAULT: 'var(--fs-info)', fg: 'var(--fs-info-fg)' }
},
borderRadius: {
sm: 'var(--fs-radius-sm)',
+45
View File
@@ -15,6 +15,51 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
});
// Default SvelteKit app-module mocks for the whole suite (#3943). Without
// them, any page or component that reaches $app/navigation or $app/state —
// the home page does, through AlbumCard → AlbumMenu — loads SvelteKit's
// client runtime, whose $app/paths reads __SVELTEKIT_PAYLOAD__ at module
// load. That global only exists when the kit plugin's define reaches the
// module, which it does not reliably under vitest: page.test.ts failed to
// load on one CI run and passed on the re-run of the same commit (#374
// was the same class). Stubbing the modules keeps every test off the
// client bootstrap. Tests that assert on goto or need a particular URL
// still mock per file (pageUrlModule in test-utils/mocks/appState.ts),
// and per-file mocks win over these.
vi.mock('$app/navigation', () => ({
goto: vi.fn(() => Promise.resolve()),
invalidate: vi.fn(() => Promise.resolve()),
invalidateAll: vi.fn(() => Promise.resolve()),
preloadData: vi.fn(() => Promise.resolve()),
preloadCode: vi.fn(() => Promise.resolve()),
beforeNavigate: vi.fn(),
afterNavigate: vi.fn(),
onNavigate: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn()
}));
vi.mock('$app/state', () => ({
page: {
url: new URL('http://localhost/'),
params: {},
route: { id: null },
status: 200,
error: null,
data: {},
form: null,
state: {}
},
navigating: null,
updated: { current: false, check: () => Promise.resolve(false) }
}));
vi.mock('$app/paths', () => ({
base: '',
assets: '',
resolve: (path: string) => path,
asset: (path: string) => path
}));
// Node 25 ships a partial localStorage global that lacks getItem/setItem/clear
// unless launched with --localstorage-file. jsdom sees Node's global and skips
// installing its own. Replace with an in-memory Storage-compatible polyfill so