Compare commits

..
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 bf0bc45c4b fix(web): filter a refetched mix inside the self-heal step, not by wrapping the closure (#5418)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m42s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / integration (push) Successful in 17m42s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 22s
release / Verify release artifacts (tag releases only) (push) Skipped
The wrapper added a promise hop, so the self-heal test's single
microtask wait saw 'loading' instead of 'error'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:33:10 -04:00
bvandeusenandClaude Opus 5.5 8bd2f66416 fix(web): missing files show greyed in track lists and never enter the queue (#5418)
release / web (push) Failing after 1m23s
release / govulncheck (push) Successful in 25s
release / go (push) Successful in 1m35s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m34s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m21s
TrackRef gains the unavailable flag the server already sends on album
detail and search. TrackRow greys such a row with '· File missing' and
hides like, radio, add and the menu, as playlist rows already do. Every
player-store entry point (playQueue, enqueue, play next, and a refetched
mix) drops missing files; playQueue keeps the chosen track as the start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:28:36 -04:00
bvandeusenandClaude Opus 5.5 015fe1d3b2 fix(web): admin error lines use text-error-fg, not the untokened text-oxblood (#5417)
release / govulncheck (push) Successful in 36s
release / web (push) Successful in 1m42s
release / go (push) Successful in 1m52s
release / integration (push) Successful in 5m7s
release / android (push) Successful in 5m48s
release / Build signed APK (releases and dev) (push) Successful in 5m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m28s
release / Verify release artifacts (tag releases only) (push) Skipped
text-oxblood is not a theme token, so Tailwind emits nothing and the scan
error on Admin → Overview and "It stopped early" on Duplicates rendered as
body text. Same fix as the album page in #5392; no uses remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:17:34 -04:00
bvandeusenandClaude Opus 5.5 0f9ddbca97 feat(web): every paged list loads as you scroll; Liked becomes tabs (#5416)
release / go (push) Successful in 1m53s
release / govulncheck (push) Successful in 49s
release / web (push) Successful in 1m50s
release / android (push) Successful in 6m44s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 21m41s
Preference 172: no "Load more" buttons. Eight remained, on Liked (three
sections), the three Search overflow pages, Genres and Years.

- ListContinuation: the one bottom-of-list block. It loads the next page
  ahead of the reader, announces "Loading more…" through aria-live, shows an
  optional end line, and on a failed page shows the error with Try again,
  dropping the sentinel so a dead endpoint isn't re-hit on every scroll.
  Used on all ten paged lists, including the four that already autoloaded.
- A failed next page no longer replaces the list. TanStack sets isError for
  it, so the page-level error branches now apply only to a failed first
  load. Genres and Years did the same with their own loader; a later
  page's failure now keeps the grid.
- Liked: Artists | Albums | Tracks tabs (operator's choice). Stacked, a
  long Artists list loading as you scroll would bury the other two. It
  opens on the first tab that has likes.
- TabStrip: the in-page tab strip, now shared by Liked, Playback errors
  and Requests.
- test-utils/intersectionObserver: a stand-in so tests can scroll to the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:55:33 -04:00
bvandeusenandClaude Opus 5.5 2205b2927b test: fix the Suspect sources tests CI rejected (#5410)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 4m36s
release / android (push) Successful in 5m4s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m11s
release / Verify release artifacts (tag releases only) (push) Skipped
- staticcheck QF1001: the nil-vs-empty check in TestSourceMarkersFor is
  now an early continue instead of a negated conjunction.
- The page test mocks #lib/api/admin.js whole, so suspectSourcesNextOffset
  was undefined there. Its test moves to admin.suspect-sources.test.ts,
  beside admin.scan.test.ts, against the real module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:56:18 -04:00
bvandeusenandClaude Opus 5.5 3f0540cb7a feat: admin Suspect sources report, for files named like video rips (#5410)
release / govulncheck (push) Successful in 37s
release / go (push) Failing after 1m10s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / web (push) Failing after 1m24s
release / integration (push) Successful in 4m38s
release / android (push) Successful in 5m5s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 5m18s
Humanz turned out to be YouTube rips: "(Official Video)", "Visualizer", a
reaction video filed as a song, junk disc numbers (#5401). The library holds
about 200 more files named the same way. This report lists them, grouped by
folder like Missing files, with the markers each file name carries.

- GET /api/admin/library/suspect-sources: one marker list in Go builds both
  the Postgres ~* filter and each row's labels, so they cannot drift.
  Basename only; missing files are left out.
- Markers calibrated on the live library: "live in/at" dropped (real live
  albums), "reaction" narrowed (it caught "Chain Reaction").
- Admin tab "Suspect sources", read-only, loads as you scroll; a folder split
  across pages is joined back into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:51:01 -04:00
bvandeusenandClaude Opus 5.5 918db27fec feat(web): shared collection header, Play and Shuffle, and the centred page column (M494)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 5m51s
release / android (push) Successful in 6m53s
release / Build signed APK (releases and dev) (push) Successful in 7m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped
- CollectionHeader holds cover, title, facts, then Play and Shuffle, for
  album and playlist. The playlist page gains Play and Shuffle; it had
  neither. The cover uses the shared Cover component, so the playlist
  header also gets the fallback image.
- PlayActions is the one Play and Shuffle pair: filled Play, outlined
  Shuffle, on album, playlist and artist pages (operator, 2026-10-08).
  The artist page's round icon button said Play but shuffled; Play now
  keeps the server's order, and Shuffle shuffles.
- PageColumn is the playlist page's centred column, now used by album,
  playlist, Liked, History and Search → Tracks.
- The playlist page builds its queue with playlistTrackToRef, the shared
  converter, in place of its own copy. Its copy skipped only removed
  tracks, so a missing file (#2527) could still be queued from there.
- shuffled() in lib/utils is the one shuffle, replacing three private
  copies (album page, artist page, ArtistCard).
- The album's refetch error now uses text-error-fg. `text-oxblood` is
  not a colour in the theme, so it never rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:44 -04:00
bvandeusenandClaude Opus 5.5 aaa6fa15d6 test(web): import history components after their mocks (M494)
HistoryRow now renders LikeButton, and both history test files imported
the component before the likes mock helper. LikeButton then loaded while
emptyLikesMock was still uninitialised, and the suites failed at import.
Every other suite imports components after its mocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:44 -04:00
bvandeusenandClaude Opus 5.5 b7f50c42a9 feat(web): one track-list row shape, and drag reorder that snaps (M494, #5393)
release / govulncheck (push) Successful in 34s
release / go (push) Successful in 1m37s
release / web (push) Failing after 1m20s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m38s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m49s
Every track list (album, Liked, Search, Search → Tracks, playlist,
History) now draws its rows with TrackListRow inside a TrackList box,
in place of four row components that had drifted apart:

- TrackListRow owns the look: lead column, title over "artist · album",
  like, radio, add to queue, menu, duration, then a trailing slot. It
  also owns the stripe, hover, the now-playing band and the
  unavailable state. The stripe is dropped on now-playing and selected
  rows, where Tailwind would otherwise let `odd:` win.
- TrackRow, PlaylistTrackRow and HistoryRow are wrappers that keep only
  their behaviour: track number and multi-select; grip, remove and
  dead-entry states; cover and timestamp. Playlist and History rows
  gain radio, add and the menu, so every list offers the same actions.
- The radio control is Lucide's Radio icon, not the 📻 emoji.
- Enter on a control inside a row now belongs to that control. The old
  TrackRow played the track when Enter was pressed on "add to queue".
- TrackList has no fill, so the stripe shows on playlists too.

Drag reorder (#5393): neodrag kept the drop offset, so after the list
re-rendered the dragged row sat between two slots. reorderDrag, shared
by the playlist and queue rows:
- snaps the row to whole rows while dragging;
- resets it to its slot on drop;
- on playlists, gives the touch gesture to the grip only, so the list
  still scrolls by touch.
The playlist page reorders optimistically and rolls back on error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:06 -04:00
bvandeusenandClaude Opus 5.5 e16d182417 fix(web): drop the shelf-header accent rule Tailwind 4 started drawing (#5021)
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 4m47s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
The rule after each shelf title used `after:bg-accent/60`. Tailwind 3
cannot put an opacity modifier on a `var()` colour, so it emitted nothing
for that class and the rule never showed. Tailwind 4 mixes the opacity
in with color-mix, so the rule appeared after the upgrade. Removed, so
the headers look as they did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:17:15 -04:00
bvandeusenandClaude Opus 5.5 ca9917d8cd chore(web): clear kit 3's alias and Vite's config-loader deprecations (#5021)
release / govulncheck (push) Successful in 23s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 4m49s
release / android (push) Successful in 5m32s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
CI run 8789 warned on both:

- kit 3 deprecates `config.alias`. The $test-utils alias becomes a
  `#test-utils/*` subpath import, matching #lib, and its 43 import sites
  move with it.
- Vite's coming native config loader needs the extension on
  vitest.config.ts's import of vite.config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:03:38 -04:00
bvandeusenandClaude Opus 5.5 b1b10b0c77 fix(web): run Tailwind 4 through @tailwindcss/vite, not PostCSS (#5021)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 56s
release / go (push) Successful in 2m22s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
The image build on fecd030b failed in `vite build`:

  [postcss] ENOENT: no such file or directory, open '/web/tailwindcss'

With a PostCSS config present, Vite's own @import inliner resolves
`@import 'tailwindcss'` before @tailwindcss/postcss sees it, and reads it
as a relative file. svelte-check and Vitest never build CSS, so only the
image job caught it. The Vite plugin is Tailwind's documented setup for
Vite projects and handles the import itself, so postcss.config.cjs and
the direct postcss and @tailwindcss/postcss dependencies go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:55:48 -04:00
bvandeusenandClaude Opus 5.5 94a9c8cbe3 chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:

- svelte.config.js is gone. Its options move into sveltekit() in
  vite.config.ts, exported as kitOptions so vitest.config.ts runs the
  same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
  src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
  ^8.0.12, svelte-check ^4.7.5.

By hand, from the codemod's list of non-automated tasks:

- goto's replaceState option is now replace; keepFocus becomes
  reset: false. For the search typeahead, reset: false also stops the
  scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
  base/assets, which kit 3 removed, and mock refreshAll in place of
  invalidateAll.
- The other flagged files only read page.url or goto internal routes,
  so they needed no change.

TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.

With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:50:39 -04:00
bvandeusen beba5b2082 Merge remote-tracking branch 'origin/renovate/sveltejs-kit-3.x' into dev 2026-10-08 10:49:44 -04:00
bvandeusenandClaude Opus 5.5 fecd030b68 chore(deps): Tailwind 4 (#5021)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m53s
release / integration (push) Successful in 5m37s
release / android (push) Successful in 6m54s
release / Build signed APK (releases and dev) (push) Successful in 7m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Failing after 34s
release / Verify release artifacts (tag releases only) (push) Skipped
Renovate's tailwindcss bump (PR #150) plus the migration it needs:

- Theme moves from tailwind.config.js into app.css as `@theme inline`,
  mapping the same FabledSword tokens. tailwind.config.js is gone.
- PostCSS runs @tailwindcss/postcss; autoprefixer is dropped, since
  Tailwind 4 prefixes through Lightning CSS.
- Class renames from @tailwindcss/upgrade 4.3.3, reviewed: outline-none
  -> outline-hidden, focus-visible:outline -> outline-solid, shadow ->
  shadow-sm, shadow-sm -> shadow-xs, flex-shrink-0 -> shrink-0. Bare
  `rounded` stays: v4 keeps it at 0.25rem, as before.
- Three v3 preflight defaults kept in a base layer so nothing changes on
  screen: gray-200 default border colour, gray-400 placeholder text and
  the pointer cursor on buttons.
- The unused class-based dark variant is not carried over; no template
  uses `dark:`.

Clears the five high and two moderate npm audit findings that came in
through Tailwind 3 (braces, chokidar, micromatch, fast-glob,
postcss-selector-parser).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:46:07 -04:00
Renovate Bot 0500f6aac5 chore(deps): update dependency @sveltejs/kit to v3
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:45:01 +00:00
Renovate Bot c83933b61a chore(deps): update dependency @sveltejs/adapter-static to v4
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:45:00 +00:00
222 changed files with 4299 additions and 2945 deletions
+7 -6
View File
@@ -295,12 +295,13 @@ jobs:
- name: Install deps
run: npm ci
# What ships to browsers: `dependencies` and the runtime they pull in
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind,
# kit's dev server) is left out because none of it reaches a user, and
# its open advisories need major-version upgrades tracked separately.
- name: npm audit (shipped dependencies)
run: npm audit --omit=dev --audit-level=moderate
# The whole tree, build and test tooling included. Until #5021 this
# audited only what ships to browsers (`--omit=dev`), because vite,
# vitest, tailwind and kit carried advisories that needed major
# upgrades. Those upgrades landed and the full tree audits clean, so
# the tooling that builds the shipped bundle is held to the same bar.
- name: npm audit (all dependencies)
run: npm audit --audit-level=moderate
- name: Type-check + svelte-check
run: npm run check
+1 -1
View File
@@ -94,7 +94,7 @@ Keystore, so a copy of the app's files doesn't yield a usable session.
Nothing is published unless every check passes: the Go, integration, web and
Android test suites, `govulncheck` against the toolchain that builds the
image, and `npm audit` on the packages that ship to the browser. See
image, and `npm audit` on every web dependency, build tooling included. See
`.gitea/workflows/release.yml`.
## Reporting a problem
+173
View File
@@ -0,0 +1,173 @@
package api
import (
"net/http"
"path"
"regexp"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// sourceMarker is one sign that a file was ripped from a video rather than
// taken from a release: words a video title carries and an album track does
// not (#5410).
//
// Each pattern is used twice, so it is written in the subset that Postgres's
// regex flavour and Go's RE2 read the same way: no \b (Postgres spells word
// boundaries \y), no lookaround, and brackets written as (\(|\[) rather than
// as a bracket expression. The SQL side matches it case-insensitively with ~*,
// the Go side with (?i); [^a-zA-Z] is spelled out so neither has to fold case
// inside a negated class.
type sourceMarker struct {
label string
pattern string
}
// suspectSourceMarkers was calibrated against the operator's library on
// 2026-10-08. Two candidates were left out on purpose:
// - "live in"/"live at": 229 files, nearly all from real live albums.
// - a bare "reaction": it caught Beck's "Chain Reaction". The marker below
// wants the phrases a reaction video actually uses.
var suspectSourceMarkers = []sourceMarker{
{"music video", `official[^a-zA-Z]*(music[^a-zA-Z]*)?video|music[^a-zA-Z]*video`},
{"official audio", `official[^a-zA-Z]*audio`},
{"lyric video", `lyrics?[^a-zA-Z]*video`},
{"visualiser", `visuali[sz]er`},
{"reaction", `reaction[^a-zA-Z]*(video|mashup)|reacts?[^a-zA-Z]+to[^a-zA-Z]|first[^a-zA-Z]*time[^a-zA-Z]*(hearing|listening)`},
{"MV", `(^|[^a-zA-Z])(mv|m/v)([^a-zA-Z]|$)`},
{"[Audio]", `(\(|\[)audio(\)|\])`},
{"[HD]", `(\(|\[)(hd|hq|4k)(\)|\])`},
}
// suspectSourcePattern is every marker as one alternation, for the SQL filter.
var suspectSourcePattern = func() string {
parts := make([]string, len(suspectSourceMarkers))
for i, m := range suspectSourceMarkers {
parts[i] = "(" + m.pattern + ")"
}
return strings.Join(parts, "|")
}()
var suspectSourceRegexps = func() []*regexp.Regexp {
out := make([]*regexp.Regexp, len(suspectSourceMarkers))
for i, m := range suspectSourceMarkers {
out[i] = regexp.MustCompile("(?i)" + m.pattern)
}
return out
}()
// sourceMarkersFor returns the labels of every marker the file's basename
// carries, in list order. Empty for a file the SQL filter would not return.
func sourceMarkersFor(filePath string) []string {
base := path.Base(filePath)
labels := make([]string, 0, 2)
for i, re := range suspectSourceRegexps {
if re.MatchString(base) {
labels = append(labels, suspectSourceMarkers[i].label)
}
}
return labels
}
// suspectTrackView is one flagged track. FilePath is the evidence: the
// markers are read from its basename, and the operator needs to see it to
// judge whether the flag is right.
type suspectTrackView struct {
TrackID string `json:"track_id"`
Title string `json:"title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
FilePath string `json:"file_path"`
DurationSec int32 `json:"duration_sec"`
DiscNumber *int32 `json:"disc_number"`
TrackNumber *int32 `json:"track_number"`
Markers []string `json:"markers"`
}
// suspectGroupView is a folder's worth of flagged tracks. As on the
// missing-files report, the folder is the unit of decision: Humanz was 62 rows
// in one folder, which is one problem to look at, not 62.
type suspectGroupView struct {
Directory string `json:"directory"`
Tracks []suspectTrackView `json:"tracks"`
}
// adminSuspectSourcesResponse is the paged envelope. Total counts tracks.
type adminSuspectSourcesResponse struct {
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Groups []suspectGroupView `json:"groups"`
}
// handleListSuspectSources implements GET /api/admin/library/suspect-sources.
//
// Read-only. A marker is a reason to look, not proof: a band can title a song
// "Music Video". What to do about a flagged file — merge it in Duplicates,
// quarantine it, replace it in Lidarr — stays the operator's call.
func (h *handlers) handleListSuspectSources(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging")
return
}
q := dbq.New(h.pool)
total, err := q.CountSuspectSourceTracks(r.Context(), suspectSourcePattern)
if err != nil {
h.logger.Error("admin: count suspect-source tracks", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
rows, err := q.ListSuspectSourceTracks(r.Context(), dbq.ListSuspectSourceTracksParams{
Pattern: suspectSourcePattern,
PageLimit: int32(limit),
PageOffset: int32(offset),
})
if err != nil {
h.logger.Error("admin: list suspect-source tracks", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
writeJSON(w, http.StatusOK, adminSuspectSourcesResponse{
Total: total,
Limit: limit,
Offset: offset,
Groups: groupSuspectByDirectory(rows),
})
}
// groupSuspectByDirectory folds the directory-ordered rows into per-folder
// groups, the same run-length fold as groupMissingByDirectory. A page boundary
// can split a folder; the web page joins the halves.
func groupSuspectByDirectory(rows []dbq.ListSuspectSourceTracksRow) []suspectGroupView {
groups := make([]suspectGroupView, 0, 8)
for _, row := range rows {
t := suspectTrackView{
TrackID: uuidToString(row.ID),
Title: row.Title,
ArtistID: uuidToString(row.ArtistID),
ArtistName: row.ArtistName,
AlbumID: uuidToString(row.AlbumID),
AlbumTitle: row.AlbumTitle,
FilePath: row.FilePath,
DurationSec: row.DurationMs / 1000,
DiscNumber: row.DiscNumber,
TrackNumber: row.TrackNumber,
Markers: sourceMarkersFor(row.FilePath),
}
if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory {
groups[n-1].Tracks = append(groups[n-1].Tracks, t)
continue
}
groups = append(groups, suspectGroupView{
Directory: row.Directory,
Tracks: []suspectTrackView{t},
})
}
return groups
}
+116
View File
@@ -0,0 +1,116 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
// Real basenames from the operator's library (2026-10-08), each with the
// labels it should carry. The negatives are the near misses that shaped the
// list: a song called "Chain Reaction", a live album, an ordinary track.
func TestSourceMarkersFor(t *testing.T) {
cases := []struct {
base string
want []string
}{
{"Daft Punk - Random Access Memories - 06 - Daft Punk - Doin' It Right (Music Video) ft. Panda Bear.mp3", []string{"music video"}},
{"Gorillaz - Humanz - 03 - Gorillaz - Saturnz Barz (Official Video).mp3", []string{"music video"}},
{"Gorillaz - Humanz - 05 - Gorillaz - Andromeda (Official Audio).mp3", []string{"official audio"}},
{"Gorillaz - Gorillaz - 06 - Gorillaz - P45 (Visualizer).mp3", []string{"visualiser"}},
{"Gorillaz - Demon Days - 16 - Gorillaz - Don Quixote's Christmas Bonanza (Visualiser).mp3", []string{"visualiser"}},
{"Artist - Album - 01 - Artist - Song (Lyric Video).mp3", []string{"lyric video"}},
{"Gorillaz - Humanz - 02 - FIRST TIME HEARING Gorillaz - Ascension REACTION.mp3", []string{"reaction"}},
{"Watsky - INTENTION - 05 - MANIAC Reacts to Watsky - AWW SHiT.mp3", []string{"reaction"}},
{"米津玄師 - diorama - 09 - 【MV】米津玄師 - 恋と病熱.mp3", []string{"MV"}},
{"Andora - Ego - 01 - Andora - Ego (feat. Will Stetson) MV.mp3", []string{"MV"}},
{"Jimmy Eat World - Something(s) Loud - 05 - Jimmy Eat World - Call to Love (Audio).mp3", []string{"[Audio]"}},
{"Record Heat - World War IV - 03 - Record Heat - Front Seat Feelin' [Audio].mp3", []string{"[Audio]"}},
{"Aphex Twin - Come To Daddy - 03 - Aphex Twin - Bucephalus Bouncing Ball (HQ).mp3", []string{"[HD]"}},
{"Artist - Album - 01 - Artist - Song (Official Lyric Video) [4K].mp3", []string{"lyric video", "[HD]"}},
{"Beck - Guero - 15 - Beck - Chain Reaction.mp3", nil},
{"Nirvana - MTV Unplugged in New York - 01 - About a Girl (live in New York).flac", nil},
{"Boards of Canada - Music Has the Right to Children - 05 - Roygbiv.flac", nil},
{"Artist - Album - 01 - Mvula.flac", nil},
}
for _, c := range cases {
got := sourceMarkersFor("/music/x/" + c.base)
if len(got) == 0 && len(c.want) == 0 {
continue // nil and empty both mean "no markers"
}
if !slices.Equal(got, c.want) {
t.Errorf("%s:\n got %v\n want %v", c.base, got, c.want)
}
}
}
// The folder is not evidence: a marker word in a directory name must not
// flag the files inside it.
func TestSourceMarkersFor_ReadsOnlyTheBasename(t *testing.T) {
if got := sourceMarkersFor("/music/Official Video Collection/01 - Song.flac"); len(got) != 0 {
t.Errorf("directory name flagged the file: %v", got)
}
}
// The integration test runs the same alternation through Postgres's ~*, so the
// one pattern is exercised in both regex dialects it has to work in.
func TestHandleListSuspectSources_Integration(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "suspect-admin", "pw", true)
artist := seedArtist(t, pool, "Gorillaz")
humanz := seedAlbum(t, pool, artist.ID, "Humanz", 2017)
seedTrack(t, pool, humanz.ID, artist.ID, "Saturnz Barz (Official Video)", 1, 180_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Momentz (Visualizer)", 2, 200_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Andromeda [Audio]", 3, 190_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Chain Reaction", 4, 190_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Busted and Blue", 5, 190_000)
gone := seedTrack(t, pool, humanz.ID, artist.ID, "Ascension (Lyric Video)", 6, 156_000)
if _, err := pool.Exec(context.Background(),
`UPDATE tracks SET missing_since = now() WHERE id = $1`, gone.ID); err != nil {
t.Fatalf("mark missing: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/admin/library/suspect-sources?limit=50", nil)
req = withUser(req, admin)
w := httptest.NewRecorder()
h.handleListSuspectSources(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var resp adminSuspectSourcesResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
// Three present files carry a marker. "Chain Reaction" and "Busted and
// Blue" carry none, and the lyric video's file is missing.
if resp.Total != 3 {
t.Errorf("total = %d, want 3", resp.Total)
}
if len(resp.Groups) != 1 {
t.Fatalf("groups = %d, want 1 (one folder)", len(resp.Groups))
}
got := map[string][]string{}
for _, tr := range resp.Groups[0].Tracks {
got[tr.Title] = tr.Markers
}
want := map[string][]string{
"Saturnz Barz (Official Video)": {"music video"},
"Momentz (Visualizer)": {"visualiser"},
"Andromeda [Audio]": {"[Audio]"},
}
if len(got) != len(want) {
t.Fatalf("tracks = %v, want %v", got, want)
}
for title, labels := range want {
if !slices.Equal(got[title], labels) {
t.Errorf("%s: markers %v, want %v", title, got[title], labels)
}
}
}
+1
View File
@@ -252,6 +252,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// and because the destructive /tracks/{id} route above must
// not be mistaken for it (#2527).
admin.Get("/library/missing", h.handleListMissingTracks)
admin.Get("/library/suspect-sources", h.handleListSuspectSources)
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
+97
View File
@@ -70,6 +70,20 @@ func (q *Queries) CountMissingTracks(ctx context.Context) (int64, error) {
return count, err
}
const countSuspectSourceTracks = `-- name: CountSuspectSourceTracks :one
SELECT COUNT(*) FROM tracks t
WHERE t.missing_since IS NULL
AND regexp_replace(t.file_path, '^.*/', '') ~* $1::text
`
// Total for the report's paging; the same filter as the list above.
func (q *Queries) CountSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
row := q.db.QueryRow(ctx, countSuspectSourceTracks, pattern)
var count int64
err := row.Scan(&count)
return count, err
}
const countTracksByAlbum = `-- name: CountTracksByAlbum :one
SELECT count(*) FROM tracks WHERE album_id = $1
`
@@ -576,6 +590,89 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
return items, nil
}
const listSuspectSourceTracks = `-- name: ListSuspectSourceTracks :many
SELECT t.id,
t.title,
t.file_path,
regexp_replace(t.file_path, '/[^/]*$', '') AS directory,
t.duration_ms,
t.disc_number,
t.track_number,
albums.id AS album_id,
albums.title AS album_title,
artists.id AS artist_id,
artists.name AS artist_name
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.missing_since IS NULL
AND regexp_replace(t.file_path, '^.*/', '') ~* $1::text
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
LIMIT $3 OFFSET $2
`
type ListSuspectSourceTracksParams struct {
Pattern string
PageOffset int32
PageLimit int32
}
type ListSuspectSourceTracksRow struct {
ID pgtype.UUID
Title string
FilePath string
Directory string
DurationMs int32
DiscNumber *int32
TrackNumber *int32
AlbumID pgtype.UUID
AlbumTitle string
ArtistID pgtype.UUID
ArtistName string
}
// The admin report of present tracks whose filename looks like a video rip
// (#5410): "(Official Video)", "[Audio]", "Visualizer" and the like.
//
// pattern is built in Go (internal/api/admin_suspect_sources.go) from the
// same marker list that labels each row, so the filter and the labels cannot
// drift. It is matched against the basename only: a folder called "Reaction
// Sessions" says nothing about how a file was sourced.
//
// Ordered by directory, like ListMissingTracks, so the handler's fold gets
// each folder as one contiguous run.
func (q *Queries) ListSuspectSourceTracks(ctx context.Context, arg ListSuspectSourceTracksParams) ([]ListSuspectSourceTracksRow, error) {
rows, err := q.db.Query(ctx, listSuspectSourceTracks, arg.Pattern, arg.PageOffset, arg.PageLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListSuspectSourceTracksRow
for rows.Next() {
var i ListSuspectSourceTracksRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.FilePath,
&i.Directory,
&i.DurationMs,
&i.DiscNumber,
&i.TrackNumber,
&i.AlbumID,
&i.AlbumTitle,
&i.ArtistID,
&i.ArtistName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTrackPathsForReconcile = `-- name: ListTrackPathsForReconcile :many
SELECT id, file_path, missing_since FROM tracks
`
+36
View File
@@ -263,3 +263,39 @@ SELECT t.id,
-- Total for the admin surface's badge and paging. Uses the same partial index
-- (tracks_missing_since_idx) as the list above.
SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL;
-- name: ListSuspectSourceTracks :many
-- The admin report of present tracks whose filename looks like a video rip
-- (#5410): "(Official Video)", "[Audio]", "Visualizer" and the like.
--
-- pattern is built in Go (internal/api/admin_suspect_sources.go) from the
-- same marker list that labels each row, so the filter and the labels cannot
-- drift. It is matched against the basename only: a folder called "Reaction
-- Sessions" says nothing about how a file was sourced.
--
-- Ordered by directory, like ListMissingTracks, so the handler's fold gets
-- each folder as one contiguous run.
SELECT t.id,
t.title,
t.file_path,
regexp_replace(t.file_path, '/[^/]*$', '') AS directory,
t.duration_ms,
t.disc_number,
t.track_number,
albums.id AS album_id,
albums.title AS album_title,
artists.id AS artist_id,
artists.name AS artist_name
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.missing_since IS NULL
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset);
-- name: CountSuspectSourceTracks :one
-- Total for the report's paging; the same filter as the list above.
SELECT COUNT(*) FROM tracks t
WHERE t.missing_since IS NULL
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
+669 -1063
View File
File diff suppressed because it is too large Load Diff
+12 -9
View File
@@ -12,25 +12,28 @@
"tokens": "node scripts/tokens-to-css.js"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/adapter-static": "^4.0.0",
"@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/node": "^25.9.1",
"autoprefixer": "^10.5.0",
"jsdom": "^25.0.1",
"postcss": "^8.5.15",
"svelte": "^5.1.9",
"svelte-check": "^4.4.8",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.3",
"vite": "^8.0.0",
"svelte": "^5.57.1",
"svelte-check": "^4.7.5",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.0",
"vite": "^8.0.12",
"vitest": "^5.0.0"
},
"dependencies": {
"@neodrag/svelte": "^2.3.3",
"@tanstack/svelte-query": "^5.90.2",
"lucide-svelte": "^1.0.1"
},
"imports": {
"#lib/*": "./src/lib/*",
"#test-utils/*": "./src/test-utils/*"
}
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+71 -4
View File
@@ -1,8 +1,75 @@
@import './lib/styles/tokens.generated.css';
@import './lib/styles/tokens.generated.css' layer(base);
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
/* The FabledSword tokens as Tailwind's theme. `inline` makes each utility emit
* the var(--fs-*) itself, so a theme swap that redefines the tokens reaches the
* utilities without going through a second variable. */
@theme inline {
--color-background: var(--fs-obsidian);
--color-surface: var(--fs-iron);
--color-surface-hover: var(--fs-slate);
--color-border: var(--fs-pewter);
--color-text-primary: var(--fs-parchment);
--color-text-secondary: var(--fs-vellum);
--color-text-muted: var(--fs-ash);
--color-action-primary: var(--fs-moss);
--color-action-secondary: var(--fs-bronze);
--color-action-destructive: var(--fs-oxblood);
--color-action-fg: var(--fs-on-action);
--color-accent: var(--fs-accent);
--color-accent-tint: color-mix(in srgb, var(--fs-accent) 12%, transparent);
--color-accent-fg: var(--fs-accent-fg);
--color-warning: var(--fs-warning);
--color-warning-fg: var(--fs-warning-fg);
--color-error: var(--fs-error);
--color-error-fg: var(--fs-error-fg);
--color-info: var(--fs-info);
--color-info-fg: var(--fs-info-fg);
--radius-sm: var(--fs-radius-sm);
--radius-md: var(--fs-radius-md);
--radius-lg: var(--fs-radius-lg);
--radius-xl: var(--fs-radius-xl);
--font-display: var(--fs-font-display);
--font-sans: var(--fs-font-body);
--font-mono: var(--fs-font-mono);
}
/* Tailwind 3's preflight defaults that 4 changed (#5021). Kept so the upgrade
* changes nothing on screen; each can go once the elements relying on it name
* the value themselves.
* - A border with no colour utility was gray-200; 4 makes it currentcolor.
* - Placeholder text was gray-400; 4 makes it the text colour at 50%.
* - Buttons had a pointer cursor; 4 leaves the browser default. */
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
input::placeholder,
textarea::placeholder {
color: var(--color-gray-400);
}
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
}
:root {
color-scheme: dark;
@@ -0,0 +1,43 @@
import { describe, expect, test } from 'vitest';
import { suspectSourcesNextOffset } from './admin';
import type { AdminSuspectResponse, AdminSuspectTrack } from './types';
function track(id: string): AdminSuspectTrack {
return {
track_id: id,
title: id,
artist_id: 'ar',
artist_name: 'Artist',
album_id: 'al',
album_title: 'Album',
file_path: `/music/${id}.mp3`,
duration_sec: 180,
disc_number: null,
track_number: null,
markers: []
};
}
function page(offset: number, total: number): AdminSuspectResponse {
return {
total,
limit: 50,
offset,
groups: [
{ directory: 'a', tracks: [track('1'), track('2')] },
{ directory: 'b', tracks: [track('3')] }
]
};
}
// limit and total are in tracks, so the next offset must count tracks across
// groups, not the number of groups.
describe('suspectSourcesNextOffset', () => {
test('counts tracks, not groups', () => {
expect(suspectSourcesNextOffset(page(0, 10))).toBe(3);
});
test('stops at the total', () => {
expect(suspectSourcesNextOffset(page(7, 10))).toBeUndefined();
});
});
+30 -1
View File
@@ -1,9 +1,10 @@
import { createQuery } from '@tanstack/svelte-query';
import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
import type {
ActionResult,
AdminMissingResponse,
AdminSuspectResponse,
AdminDuplicatesResponse,
MergeDuplicateResult,
AdminPlaybackError,
@@ -896,6 +897,34 @@ export function createMissingFilesQuery(offset: number = 0, limit: number = 50)
});
}
// Suspect sources (#5410) --------------------------------------------------
export const SUSPECT_SOURCES_PAGE_SIZE = 50;
export async function listSuspectSources(offset: number = 0): Promise<AdminSuspectResponse> {
return api.get<AdminSuspectResponse>(
`/api/admin/library/suspect-sources?limit=${SUSPECT_SOURCES_PAGE_SIZE}&offset=${offset}`
);
}
// The next offset counts tracks, not groups: limit and total are in tracks.
export function suspectSourcesNextOffset(last: AdminSuspectResponse): number | undefined {
const loaded = last.offset + last.groups.reduce((n, g) => n + g.tracks.length, 0);
return loaded >= last.total ? undefined : loaded;
}
// Loads as the page scrolls (rule 172). Changes only when files are added or
// removed, so it is not re-fetched on every focus.
export function createSuspectSourcesQuery() {
return createInfiniteQuery({
queryKey: qk.adminSuspectSources(),
queryFn: ({ pageParam }) => listSuspectSources(pageParam as number),
initialPageParam: 0,
getNextPageParam: suspectSourcesNextOffset,
staleTime: 120_000
});
}
// Missing-file re-acquisition (#2527 / milestone #290) ----------------------
export type ReacquisitionSettings = {
+2 -2
View File
@@ -83,7 +83,7 @@ describe('api.get/post/del', () => {
describe('apiFetch 401 interceptor', () => {
test('401 response triggers auth.logout({silent:true}) and still throws', async () => {
const logoutSpy = vi.fn();
vi.doMock('$lib/auth/store.svelte', () => ({
vi.doMock('#lib/auth/store.svelte.js', () => ({
logout: logoutSpy,
login: vi.fn(),
bootstrap: vi.fn(),
@@ -97,6 +97,6 @@ describe('apiFetch 401 interceptor', () => {
status: 401
});
expect(logoutSpy).toHaveBeenCalledWith({ silent: true });
vi.doUnmock('$lib/auth/store.svelte');
vi.doUnmock('#lib/auth/store.svelte.js');
});
});
+1 -1
View File
@@ -44,7 +44,7 @@ export async function apiFetch(path: string, init?: RequestInit): Promise<unknow
// Lazy import: auth/store imports from this file, so a top-level
// import would be circular. By the time any 401 actually happens
// at runtime, both modules have finished loading.
const { logout } = await import('$lib/auth/store.svelte');
const { logout } = await import('#lib/auth/store.svelte.js');
await logout({ silent: true });
}
// Two error envelope shapes ship in this codebase today:
+1
View File
@@ -58,6 +58,7 @@ export const qk = {
['adminMissingFiles', { offset: offset ?? 0 }] as const,
adminDuplicates: (offset?: number) =>
['adminDuplicates', { offset: offset ?? 0 }] as const,
adminSuspectSources: () => ['adminSuspectSources'] as const,
adminDiagnosticDevices: (userId?: string) =>
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
smtpConfig: () => ['smtpConfig'] as const,
+35
View File
@@ -41,6 +41,10 @@ export type TrackRef = {
disc_number?: number;
duration_sec: number;
stream_url: string;
// #5418: the file is gone from the library (missing_since is set). Sent on
// direct lookups such as album detail and search; lists that pick tracks
// for playback leave missing files out instead.
unavailable?: boolean;
};
export type ArtistDetail = ArtistRef & {
@@ -419,6 +423,37 @@ export type AdminMissingResponse = {
groups: AdminMissingGroup[];
};
// Suspect sources (#5410) --------------------------------------------------
// A present track whose filename carries a video-rip marker. markers are the
// reasons, read from the file's basename: "music video", "[Audio]", "MV"...
export type AdminSuspectTrack = {
track_id: string;
title: string;
artist_id: string;
artist_name: string;
album_id: string;
album_title: string;
file_path: string;
duration_sec: number;
disc_number: number | null;
track_number: number | null;
markers: string[];
};
// A folder's worth of flagged tracks; the server orders and groups by folder.
export type AdminSuspectGroup = {
directory: string;
tracks: AdminSuspectTrack[];
};
export type AdminSuspectResponse = {
total: number;
limit: number;
offset: number;
groups: AdminSuspectGroup[];
};
// Duplicates report (#3912) -------------------------------------------------
// One copy in a proposed duplicate group. like_count and play_count cover every
+4 -4
View File
@@ -1,10 +1,10 @@
import { api, type User, type LoginResponse } from '$lib/api/client';
import { putMyTimezone } from '$lib/api/me';
import { queryClient } from '$lib/query/client';
import { api, type User, type LoginResponse } from '#lib/api/client.js';
import { putMyTimezone } from '#lib/api/me.js';
import { queryClient } from '#lib/query/client.js';
import { signalSessionEnd } from './sessionEnd.svelte';
import { user, setUser } from './user.svelte';
// Re-export so existing `import { user } from '$lib/auth/store.svelte'`
// Re-export so existing `import { user } from '#lib/auth/store.svelte'`
// callers keep working. New code can import directly from auth/user.svelte.
export { user };
+4 -4
View File
@@ -1,14 +1,14 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { apiClientMock } from '../../test-utils/mocks/client';
vi.mock('$lib/api/client', () => apiClientMock());
vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('$lib/query/client', () => ({
vi.mock('#lib/query/client.js', () => ({
queryClient: { clear: vi.fn() }
}));
import { api } from '$lib/api/client';
import { queryClient } from '$lib/query/client';
import { api } from '#lib/api/client.js';
import { queryClient } from '#lib/query/client.js';
import { bootstrap, login, logout, user } from './store.svelte';
beforeEach(() => {
+1 -1
View File
@@ -4,7 +4,7 @@
// component that needs the current user can import `user` from either
// this module or auth/store — auth/store re-exports it for backward
// compatibility.
import type { User } from '$lib/api/client';
import type { User } from '#lib/api/client.js';
let _user = $state<User | null>(null);
+6 -6
View File
@@ -7,9 +7,9 @@
updateAcoustIDSettings,
type AcoustIDOverview,
type AcoustIDUnsettled
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// AcoustID recording-id lookup (M401): fills the MusicBrainz ids that
// untagged tracks lack, so the similarity arms can see them. Off until the
@@ -125,7 +125,7 @@
const rowClass = 'flex items-center justify-between gap-4 py-2';
const inputClass =
'rounded-md border border-border bg-background px-3 py-1.5 text-sm text-text-primary ' +
'placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent';
'placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent';
</script>
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="acoustid-card">
@@ -238,7 +238,7 @@
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || !scoreOk}
onclick={save}
@@ -293,7 +293,7 @@
<button
type="button"
class="rounded-md border border-border px-4 py-2 text-sm text-text-primary hover:bg-background
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!overview.ready || overview.status.running || starting}
title={overview.ready ? undefined : 'Switch the lookup on and add a key first'}
+4 -4
View File
@@ -1,15 +1,15 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
import type { AcoustIDOverview } from "$lib/api/admin";
import type { AcoustIDOverview } from "#lib/api/admin.js";
vi.mock("$lib/api/admin", () => ({
vi.mock("#lib/api/admin.js", () => ({
getAcoustID: vi.fn(),
updateAcoustIDSettings: vi.fn(),
runAcoustIDLookup: vi.fn(),
listUnsettledAcoustID: vi.fn(),
}));
vi.mock("$lib/stores/toast.svelte", () => ({ pushToast: vi.fn() }));
vi.mock("#lib/stores/toast.svelte.js", () => ({ pushToast: vi.fn() }));
import AcoustIDCard from "./AcoustIDCard.svelte";
import {
@@ -17,7 +17,7 @@ import {
listUnsettledAcoustID,
runAcoustIDLookup,
updateAcoustIDSettings,
} from "$lib/api/admin";
} from "#lib/api/admin.js";
const base: AcoustIDOverview = {
settings: { enabled: true, min_score: 0.85, api_key_set: true },
+5 -5
View File
@@ -6,9 +6,9 @@
revokeSession,
revokeOtherSessions,
type ActiveSession
} from '$lib/api/me';
import { errCode } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/me.js';
import { errCode } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Self-contained: nothing else in the app reads this data, so it holds its
// own state and reloads explicitly rather than joining the query cache.
@@ -143,7 +143,7 @@
<li class="flex items-start gap-3 py-3">
<MonitorSmartphone
size={18}
class="mt-0.5 flex-shrink-0 text-text-secondary"
class="mt-0.5 shrink-0 text-text-secondary"
aria-hidden="true"
/>
<div class="min-w-0 flex-1">
@@ -173,7 +173,7 @@
{#if !s.current}
<button
type="button"
class="flex-shrink-0 rounded border border-border px-2 py-1 text-sm
class="shrink-0 rounded border border-border px-2 py-1 text-sm
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50"
disabled={busy}
@@ -6,13 +6,13 @@ const listSessions = vi.fn();
const revokeSession = vi.fn();
const revokeOtherSessions = vi.fn();
vi.mock('$lib/api/me', () => ({
vi.mock('#lib/api/me.js', () => ({
listSessions: (...a: unknown[]) => listSessions(...a),
revokeSession: (...a: unknown[]) => revokeSession(...a),
revokeOtherSessions: (...a: unknown[]) => revokeOtherSessions(...a)
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
type Row = {
id: string;
@@ -1,12 +1,12 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createPlaylistsQuery, appendTracks, createPlaylist } from '$lib/api/playlists';
import { qk } from '$lib/api/queries';
import { errMessage } from '$lib/api/errors';
import { createPlaylistsQuery, appendTracks, createPlaylist } from '#lib/api/playlists.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
let {
tracks,
@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
const playlistsData = vi.hoisted(() => ({
owned: [
@@ -43,11 +43,11 @@ const playlistsData = vi.hoisted(() => ({
public: [] as unknown[]
}));
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
}));
vi.mock('$lib/api/playlists', () => ({
vi.mock('#lib/api/playlists.js', () => ({
createPlaylistsQuery: () =>
readable({ data: playlistsData, isPending: false, isError: false }),
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
@@ -86,7 +86,7 @@ describe('AddToPlaylistMenu', () => {
test('clicking a playlist appends and closes', async () => {
const onClose = vi.fn();
const { appendTracks } = await import('$lib/api/playlists');
const { appendTracks } = await import('#lib/api/playlists.js');
render(AddToPlaylistMenu, { props: { tracks: [track], onClose } });
await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
+1
View File
@@ -10,6 +10,7 @@
{ href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/missing-files', label: 'Missing files' },
{ href: '/admin/duplicates', label: 'Duplicates' },
{ href: '/admin/suspect-sources', label: 'Suspect sources' },
{ href: '/admin/playback-errors', label: 'Playback errors' },
{ href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/tuning', label: 'Tuning' },
+5 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { pageUrlModule } from '$test-utils/mocks/appState';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/admin')
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
);
});
test('renders all ten tabs in order', () => {
test('renders all eleven tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs);
const links = screen.getAllByRole('link');
@@ -67,6 +67,9 @@ describe('AdminTabs', () => {
// Duplicates follows Missing files: both are library-health reports on
// what the library holds, rather than a queue of user reports.
'Duplicates',
// Suspect sources is the third library-health report: files whose
// names say they were ripped from a video.
'Suspect sources',
'Playback errors',
'Diagnostics',
'Tuning',
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
import { api } from '$lib/api/client';
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
import type { AlbumRef, AlbumDetail } from '#lib/api/types.js';
import { api } from '#lib/api/client.js';
import { enqueueTracks, playQueue } from '#lib/player/store.svelte.js';
import { Play } from 'lucide-svelte';
import AlbumMenu from './AlbumMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte';
@@ -31,7 +31,7 @@
>
<div
class="art-wrap relative aspect-square w-full overflow-hidden rounded-md
shadow-sm transition-all duration-150
shadow-xs transition-all duration-150
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
>
<Cover
+10 -10
View File
@@ -1,27 +1,27 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types';
import { FALLBACK_COVER } from '$lib/media/covers';
import type { AlbumRef, AlbumDetail, TrackRef } from '#lib/api/types.js';
import { FALLBACK_COVER } from '#lib/media/covers.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { apiClientMock } from '../../test-utils/mocks/client';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
vi.mock('$lib/api/client', () => apiClientMock());
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('#lib/player/store.svelte.js', () => ({
enqueueTracks: vi.fn(),
playQueue: vi.fn()
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/albums', async (orig) => {
vi.mock('#lib/api/albums.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
});
vi.mock('$lib/api/playlists', async (orig) => {
vi.mock('#lib/api/playlists.js', async (orig) => {
const { readable } = await import('svelte/store');
const actual = (await orig()) as Record<string, unknown>;
return {
@@ -34,8 +34,8 @@ vi.mock('$lib/api/playlists', async (orig) => {
});
import AlbumCard from './AlbumCard.svelte';
import { api } from '$lib/api/client';
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
import { api } from '#lib/api/client.js';
import { enqueueTracks, playQueue } from '#lib/player/store.svelte.js';
const album: AlbumRef = {
id: 'xyz',
+4 -4
View File
@@ -2,13 +2,13 @@
import { MoreVertical, Heart, HeartOff, ListMusic, Plus, Disc3 } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
import { listAlbumTracks } from '$lib/api/albums';
import { enqueueTracks } from '$lib/player/store.svelte';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
import { listAlbumTracks } from '#lib/api/albums.js';
import { enqueueTracks } from '#lib/player/store.svelte.js';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
let {
albumId,
+9 -9
View File
@@ -2,28 +2,28 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
import type { TrackRef } from '$lib/api/types';
import { makeTracks } from '$test-utils/fixtures/track';
import type { TrackRef } from '#lib/api/types.js';
import { makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/albums', async (orig) => {
vi.mock('#lib/api/albums.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
});
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
enqueueTracks: vi.fn()
}));
import AlbumMenu from './AlbumMenu.svelte';
import { enqueueTracks } from '$lib/player/store.svelte';
import { listAlbumTracks } from '$lib/api/albums';
import { likeEntity } from '$lib/api/likes';
import { enqueueTracks } from '#lib/player/store.svelte.js';
import { listAlbumTracks } from '#lib/api/albums.js';
import { likeEntity } from '#lib/api/likes.js';
import { goto } from '$app/navigation';
const TRACKS: TrackRef[] = makeTracks(2, {
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { ApiError } from '$lib/api/client';
import type { ApiError } from '#lib/api/client.js';
let {
error,
+6 -14
View File
@@ -1,32 +1,24 @@
<script lang="ts">
import type { ArtistRef, TrackRef } from '$lib/api/types';
import { api } from '$lib/api/client';
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
import { shuffled } from '#lib/utils/shuffle.js';
import type { ArtistRef, TrackRef } from '#lib/api/types.js';
import { api } from '#lib/api/client.js';
import { playQueue, enqueueTracks } from '#lib/player/store.svelte.js';
import { Play } from 'lucide-svelte';
import ArtistMenu from './ArtistMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte';
import Cover from './Cover.svelte';
import { listArtistTracks } from '$lib/api/artists';
import { listArtistTracks } from '#lib/api/artists.js';
let { artist }: { artist: ArtistRef } = $props();
let queueing = $state(false);
function shuffle<T>(items: T[]): T[] {
const arr = items.slice();
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
async function onPlayClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`);
if (tracks.length === 0) return;
playQueue(shuffle(tracks), 0);
playQueue(shuffled(tracks), 0);
}
async function onAddToQueue(e: MouseEvent) {
+10 -10
View File
@@ -2,21 +2,21 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { apiClientMock } from '../../test-utils/mocks/client';
import type { ArtistRef, TrackRef } from '$lib/api/types';
import { makeTrack, makeTracks } from '$test-utils/fixtures/track';
import type { ArtistRef, TrackRef } from '#lib/api/types.js';
import { makeTrack, makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$lib/api/client', () => apiClientMock());
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn(),
enqueueTracks: vi.fn()
}));
vi.mock('$lib/api/artists', () => ({
vi.mock('#lib/api/artists.js', () => ({
listArtistTracks: vi.fn().mockResolvedValue([])
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
import ArtistCard from './ArtistCard.svelte';
import { api } from '$lib/api/client';
import { playQueue } from '$lib/player/store.svelte';
import { api } from '#lib/api/client.js';
import { playQueue } from '#lib/player/store.svelte.js';
const artist: ArtistRef = {
id: 'art-1',
@@ -82,7 +82,7 @@ describe('ArtistCard', () => {
const fakeTracks = [
makeTrack({ artist_id: 'art-1', artist_name: 'BoC' })
];
const { listArtistTracks } = await import('$lib/api/artists');
const { listArtistTracks } = await import('#lib/api/artists.js');
(listArtistTracks as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fakeTracks);
render(ArtistCard, { props: { artist } });
const btn = screen.getByRole('button', { name: /Add Boards of Canada to queue/i });
@@ -90,7 +90,7 @@ describe('ArtistCard', () => {
// Allow promise microtask to flush.
await new Promise((r) => setTimeout(r, 0));
expect(listArtistTracks).toHaveBeenCalledWith('art-1');
const { enqueueTracks } = await import('$lib/player/store.svelte');
const { enqueueTracks } = await import('#lib/player/store.svelte.js');
expect(enqueueTracks).toHaveBeenCalledWith(fakeTracks);
});
});
+4 -4
View File
@@ -1,13 +1,13 @@
<script lang="ts">
import { MoreVertical, Heart, HeartOff, ListMusic, Plus } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
import { listArtistTracks } from '$lib/api/artists';
import { enqueueTracks } from '$lib/player/store.svelte';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
import { listArtistTracks } from '#lib/api/artists.js';
import { enqueueTracks } from '#lib/player/store.svelte.js';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
let {
artistId,
+9 -9
View File
@@ -2,27 +2,27 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
import type { TrackRef } from '$lib/api/types';
import { makeTracks } from '$test-utils/fixtures/track';
import type { TrackRef } from '#lib/api/types.js';
import { makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/artists', () => ({
vi.mock('#lib/api/artists.js', () => ({
listArtistTracks: vi.fn().mockResolvedValue([])
}));
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
enqueueTracks: vi.fn()
}));
import ArtistMenu from './ArtistMenu.svelte';
import { enqueueTracks } from '$lib/player/store.svelte';
import { listArtistTracks } from '$lib/api/artists';
import { likeEntity, unlikeEntity } from '$lib/api/likes';
import { enqueueTracks } from '#lib/player/store.svelte.js';
import { listArtistTracks } from '#lib/api/artists.js';
import { likeEntity, unlikeEntity } from '#lib/api/likes.js';
const TRACKS: TrackRef[] = makeTracks(2, {
album_id: 'al1', artist_id: 'a1', artist_name: 'Test'
@@ -0,0 +1,60 @@
<script lang="ts">
// The header of every page that is one collection of tracks, album and
// playlist (milestone 494): cover, title, a line or two of facts, then
// Play and Shuffle (PlayActions).
import type { Snippet } from 'svelte';
import PlayActions from './PlayActions.svelte';
import Cover from './Cover.svelte';
let {
title,
coverUrl,
coverAlt = '',
playDisabled = false,
onPlay,
onShuffle,
titleExtra,
tools,
children,
actions
}: {
title: string;
coverUrl: string | null | undefined;
coverAlt?: string;
playDisabled?: boolean;
onPlay: () => void;
onShuffle: () => void;
/** Beside the title, e.g. the album's like button. */
titleExtra?: Snippet;
/** Top right: refresh, link, edit, delete. */
tools?: Snippet;
/** The facts under the title. */
children?: Snippet;
/** After Play and Shuffle, e.g. an admin's Refetch cover. */
actions?: Snippet;
} = $props();
</script>
<header class="mb-6 flex items-start gap-4">
<div class="h-32 w-32 shrink-0 overflow-hidden rounded-md md:h-40 md:w-40">
<Cover src={coverUrl ?? undefined} alt={coverAlt} />
</div>
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start gap-3">
<h1 class="min-w-0 truncate font-display text-2xl font-medium text-text-primary">{title}</h1>
{#if titleExtra}{@render titleExtra()}{/if}
{#if tools}
<div class="ml-auto flex shrink-0 items-center gap-1">{@render tools()}</div>
{/if}
</div>
{#if children}{@render children()}{/if}
<div class="pt-1">
<PlayActions name={title} disabled={playDisabled} {onPlay} {onShuffle}>
{#if actions}{@render actions()}{/if}
</PlayActions>
</div>
</div>
</header>
@@ -1,8 +1,8 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
import type { TrackRef } from '$lib/api/types';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
import { coverUrl } from '$lib/media/covers';
import type { TrackRef } from '#lib/api/types.js';
import { playQueue, enqueueTrack } from '#lib/player/store.svelte.js';
import { coverUrl } from '#lib/media/covers.js';
import TrackMenu from './TrackMenu.svelte';
import LikeButton from './LikeButton.svelte';
import Cover from './Cover.svelte';
@@ -43,7 +43,7 @@
class="flex w-full items-center gap-2 rounded-md p-1 pr-24 text-left
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
>
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
<div class="h-12 w-12 shrink-0 overflow-hidden rounded bg-surface-hover">
<Cover src={cover} />
</div>
<div class="min-w-0 flex-1">
@@ -1,31 +1,31 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import { makeTracks } from '$test-utils/fixtures/track';
import { makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { get value() { return { id: 'u1', username: 'u', is_admin: false }; } }
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn(),
enqueueTrack: vi.fn(),
playNext: vi.fn()
}));
import CompactTrackCard from './CompactTrackCard.svelte';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
import { playQueue, enqueueTrack } from '#lib/player/store.svelte.js';
const titles = ['First', 'Second', 'Third'];
const tracks: TrackRef[] = makeTracks(3, { artist_name: 'Artist' }).map((t, i) => ({
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { FALLBACK_COVER } from '$lib/media/covers';
import { FALLBACK_COVER } from '#lib/media/covers.js';
import { Disc3 } from 'lucide-svelte';
// Shared cover-artwork renderer. The web counterpart of Android's
@@ -86,7 +86,7 @@
<button
type="button"
aria-label={`Bring ${title} back`}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-hidden focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze?.()}
>
Undo
@@ -112,7 +112,7 @@
type="button"
aria-label={`Not right now — hide ${title} for a while`}
title="Not right now"
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
onclick={() => onSnooze?.()}
>
<Clock size={16} strokeWidth={1} />
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { pageUrlModule } from '$test-utils/mocks/appState';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/discover')
@@ -5,9 +5,9 @@
getFingerprintSettings,
updateFingerprintSettings,
type FingerprintSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// How Minstrel fingerprints tracks and when it looks for duplicates (#3913).
// Lives on the Duplicates page, beside the results these settings shape.
@@ -83,7 +83,7 @@
const inputClass =
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
</script>
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
@@ -183,7 +183,7 @@
class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary"
data-testid="length-warning"
>
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
Saving re-fingerprints {libraryTotal > 0
? `all ${libraryTotal.toLocaleString()} tracks`
@@ -206,7 +206,7 @@
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || problems.length > 0}
onclick={save}
@@ -1,18 +1,18 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { FingerprintSettings } from '$lib/api/admin';
import { ERROR_COPY } from '$lib/api/error-copy';
import type { FingerprintSettings } from '#lib/api/admin.js';
import { ERROR_COPY } from '#lib/api/error-copy.js';
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getFingerprintSettings: vi.fn(),
updateFingerprintSettings: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import FingerprintSettingsCard from './FingerprintSettingsCard.svelte';
import { getFingerprintSettings, updateFingerprintSettings } from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
import { getFingerprintSettings, updateFingerprintSettings } from '#lib/api/admin.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
const base: FingerprintSettings = {
enabled: true,
+6 -6
View File
@@ -2,10 +2,10 @@
import { untrack } from 'svelte';
import { Flag } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { flagTrack } from '$lib/api/quarantine';
import { errCode } from '$lib/api/errors';
import { qk } from '$lib/api/queries';
import type { TrackRef, LidarrQuarantineReason } from '$lib/api/types';
import { flagTrack } from '#lib/api/quarantine.js';
import { errCode } from '#lib/api/errors.js';
import { qk } from '#lib/api/queries.js';
import type { TrackRef, LidarrQuarantineReason } from '#lib/api/types.js';
let {
track,
@@ -59,7 +59,7 @@
<span class="block text-xs text-text-secondary">Reason</span>
<select
bind:value={reason}
class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
>
<option value="bad_rip">Bad rip</option>
<option value="wrong_file">Wrong file</option>
@@ -74,7 +74,7 @@
bind:value={notes}
maxlength="200"
placeholder="What's wrong with it?"
class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
rows="2"
></textarea>
</label>
+3 -3
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import FlagPopover from './FlagPopover.svelte';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
const invalidateMock = vi.fn();
vi.mock('@tanstack/svelte-query', async (orig) => {
@@ -12,11 +12,11 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
};
});
vi.mock('$lib/api/quarantine', () => ({
vi.mock('#lib/api/quarantine.js', () => ({
flagTrack: vi.fn().mockResolvedValue({ track_id: 't1', reason: 'bad_rip' })
}));
import { flagTrack } from '$lib/api/quarantine';
import { flagTrack } from '#lib/api/quarantine.js';
const track = makeTrack();
+22 -22
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import type { HistoryEvent } from '$lib/api/history';
import { playQueue } from '$lib/player/store.svelte';
import { coverUrl } from '$lib/media/covers';
import type { HistoryEvent } from '#lib/api/history.js';
import { playQueue } from '#lib/player/store.svelte.js';
import { coverUrl } from '#lib/media/covers.js';
import TrackListRow from './TrackListRow.svelte';
import Cover from './Cover.svelte';
let { event } = $props<{ event: HistoryEvent }>();
@@ -40,25 +42,23 @@
const tooltip = $derived(new Date(event.played_at).toISOString());
</script>
<button
type="button"
onclick={handleClick}
aria-label={`Play ${event.track.title}`}
class="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-surface-hover"
<TrackListRow
title={event.track.title}
subtitle={`${event.track.artist_name} · ${event.track.album_title}`}
durationSec={event.track.duration_sec}
track={event.track}
ariaLabel={`Play ${event.track.title}`}
onActivate={handleClick}
>
<img
src={coverUrl(event.track.album_id)}
alt=""
class="h-11 w-11 flex-shrink-0 rounded object-cover"
loading="lazy"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-text-primary">{event.track.title}</div>
<div class="truncate text-xs text-text-secondary">
{event.track.artist_name} · {event.track.album_title}
</div>
</div>
<span title={tooltip} class="flex-shrink-0 text-xs text-text-secondary">
{#snippet lead()}
<span class="block h-8 w-8 overflow-hidden rounded">
<Cover src={coverUrl(event.track.album_id)} />
</span>
{/snippet}
{#snippet trailing()}
<span title={tooltip} class="w-16 shrink-0 text-right text-xs text-text-secondary">
{timestamp}
</span>
</button>
{/snippet}
</TrackListRow>
+32 -5
View File
@@ -1,15 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import HistoryRow from './HistoryRow.svelte';
import type { HistoryEvent } from '$lib/api/history';
import { makeTrack } from '$test-utils/fixtures/track';
import type { HistoryEvent } from '#lib/api/history.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
const playQueue = vi.fn();
vi.mock('$lib/player/store.svelte', () => ({
playQueue: (...args: unknown[]) => playQueue(...args)
// The row is a TrackListRow, so LikeButton and TrackMenu render too.
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: (...args: unknown[]) => playQueue(...args),
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playRadio: vi.fn(),
player: { current: undefined }
}));
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { value: { id: 'u1', username: 'me', is_admin: false } }
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
// Imported after the mocks: the component pulls in LikeButton, whose
// mocked module must already be initialised.
import HistoryRow from './HistoryRow.svelte';
const sampleTrack = makeTrack({
title: 'Song Title',
album_title: 'Album',
@@ -41,6 +61,13 @@ describe('HistoryRow', () => {
expect(playQueue).toHaveBeenCalledWith([sampleTrack], 0);
});
it('carries the shared row controls: like, radio, add and the menu', () => {
render(HistoryRow, { props: { event: sampleEvent } });
expect(screen.getByRole('button', { name: /play radio/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add song title to queue/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /track actions for/i })).toBeInTheDocument();
});
it('renders a relative timestamp for events under 1 hour ago', () => {
render(HistoryRow, { props: { event: sampleEvent } });
expect(screen.getByText(/\d+m ago/)).toBeInTheDocument();
@@ -62,14 +62,8 @@
<div class="section" aria-label={ariaLabel}>
<header class="section-header">
{#if title}
<!-- Section header: Fraunces (font-display) at 24px with an
inline accent rule that bleeds out from the title — gives
each shelf a clear chapter break without adding chrome. -->
<h2
class="font-display text-2xl font-medium text-text-primary
flex items-baseline gap-3 after:h-[2px] after:w-12 after:flex-shrink-0
after:rounded-full after:bg-accent/60 after:content-['']"
>{title}</h2>
<!-- Section header: Fraunces (font-display) at 24px. -->
<h2 class="font-display text-2xl font-medium text-text-primary">{title}</h2>
{:else}
<span></span>
{/if}
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { createLikedIdsQuery, likeEntity, unlikeEntity, type EntityKind } from '$lib/api/likes';
import { createLikedIdsQuery, likeEntity, unlikeEntity, type EntityKind } from '#lib/api/likes.js';
let {
entityType,
+2 -2
View File
@@ -6,14 +6,14 @@ const cacheState = vi.hoisted(() => ({
data: { track_ids: ['t1'] as string[], album_ids: [] as string[], artist_ids: [] as string[] }
}));
vi.mock('$lib/api/likes', () => ({
vi.mock('#lib/api/likes.js', () => ({
createLikedIdsQuery: () => readable({ data: cacheState.data, isPending: false, isError: false }),
likeEntity: vi.fn().mockResolvedValue(undefined),
unlikeEntity: vi.fn().mockResolvedValue(undefined)
}));
import LikeButton from './LikeButton.svelte';
import { likeEntity, unlikeEntity } from '$lib/api/likes';
import { likeEntity, unlikeEntity } from '#lib/api/likes.js';
afterEach(() => {
vi.clearAllMocks();
@@ -0,0 +1,49 @@
<script lang="ts">
// The bottom of every paged list (#5416, preference 172): the next page
// loads on its own as the reader nears the end, with no "Load more"
// button. What the button used to give for free is kept here:
//
// - In flight: a "Loading more…" line, announced through aria-live, so the
// list doesn't look finished while it isn't.
// - Finished: an optional quiet end line (`endLabel`).
// - Failed: the error and a Try again button. The sentinel is removed while
// a page has failed, so a dead endpoint isn't re-requested on every scroll.
import InfiniteScrollSentinel from './InfiniteScrollSentinel.svelte';
let {
hasMore,
loading,
failed = false,
onMore,
endLabel
}: {
/** Whether another page exists. */
hasMore: boolean;
/** A next page is in flight. */
loading: boolean;
/** The last next-page fetch failed. */
failed?: boolean;
/** Fetch the next page (TanStack's fetchNextPage, or a page's own loader). */
onMore: () => void;
/** Shown once the list is complete, e.g. "End of history". */
endLabel?: string;
} = $props();
</script>
{#if failed}
<div role="alert" class="flex items-center justify-center gap-2 py-2 text-sm text-error-fg">
Couldn't load more.
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-text-primary hover:bg-surface-hover
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
onclick={onMore}>Try again</button
>
</div>
{:else if hasMore}
<InfiniteScrollSentinel enabled={!loading} onIntersect={onMore} />
{/if}
<p class="py-2 text-center text-sm text-text-secondary empty:hidden" aria-live="polite">
{#if !failed && hasMore && loading}Loading more…{:else if !hasMore && endLabel}{endLabel}{/if}
</p>
@@ -0,0 +1,43 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import ListContinuation from './ListContinuation.svelte';
// InfiniteScrollSentinel renders <div aria-hidden="true" class="h-px">.
const SENTINEL = 'div[aria-hidden="true"].h-px';
describe('ListContinuation', () => {
test('more to come: a sentinel and no button', () => {
const { container } = render(ListContinuation, {
props: { hasMore: true, loading: false, onMore: vi.fn() }
});
expect(container.querySelector(SENTINEL)).not.toBeNull();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
test('in flight: announces loading', () => {
render(ListContinuation, { props: { hasMore: true, loading: true, onMore: vi.fn() } });
const status = screen.getByText('Loading more…');
expect(status).toHaveAttribute('aria-live', 'polite');
});
test('finished: the end line, no sentinel', () => {
const { container } = render(ListContinuation, {
props: { hasMore: false, loading: false, onMore: vi.fn(), endLabel: 'End of history' }
});
expect(screen.getByText('End of history')).toBeInTheDocument();
expect(container.querySelector(SENTINEL)).toBeNull();
});
// A failed page must not be re-fetched by the observer on every scroll:
// the sentinel goes away and the reader retries by hand.
test('failed: an error with Try again, and no sentinel', async () => {
const onMore = vi.fn();
const { container } = render(ListContinuation, {
props: { hasMore: true, loading: false, failed: true, onMore }
});
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load more.");
expect(container.querySelector(SENTINEL)).toBeNull();
await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(onMore).toHaveBeenCalledTimes(1);
});
});
@@ -6,9 +6,9 @@
updateLoudnessSettings,
type LoudnessCoverage,
type LoudnessSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Loudness analysis (#4995): the background measurement that loudness
// normalization levels playback from, its progress, and its knobs. The
@@ -79,7 +79,7 @@
const inputClass =
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
</script>
<section class="space-y-4 rounded border border-border bg-surface p-4">
@@ -185,7 +185,7 @@
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || !valid}
onclick={save}
@@ -1,18 +1,18 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { LoudnessCoverage, LoudnessSettings } from '$lib/api/admin';
import type { LoudnessCoverage, LoudnessSettings } from '#lib/api/admin.js';
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getLoudnessSettings: vi.fn(),
updateLoudnessSettings: vi.fn(),
getLoudnessCoverage: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import LoudnessSettingsCard from './LoudnessSettingsCard.svelte';
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '#lib/api/admin.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 };
const coverage: LoudnessCoverage = {
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Smartphone } from 'lucide-svelte';
import { api } from '$lib/api/client';
import { api } from '#lib/api/client.js';
// Polls /api/client/version once on mount. Renders nothing on 404
// (no bundled APK — graceful degradation in dev / pre-CI images)
@@ -5,8 +5,8 @@
getNetworkSettings,
updateNetworkSettings,
type NetworkSettings
} from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
let settings = $state<NetworkSettings | null>(null);
let hops = $state(1);
@@ -77,7 +77,7 @@
max={settings.max_hops}
bind:value={hops}
class="w-24 rounded border border-border bg-background px-2 py-1
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
</label>
<button
@@ -116,7 +116,7 @@
<div class="space-y-2 rounded border border-border bg-background p-3 text-sm">
<p class="flex items-start gap-2 text-text-secondary">
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
Count your proxies — don't guess high. This number tells Minstrel how much of the
<span class="font-mono">X-Forwarded-For</span> header to believe, and that header is
@@ -5,12 +5,12 @@ import NetworkSettingsCard from './NetworkSettingsCard.svelte';
const getNetworkSettings = vi.fn();
const updateNetworkSettings = vi.fn();
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getNetworkSettings: () => getNetworkSettings(),
updateNetworkSettings: (hops: number) => updateNetworkSettings(hops)
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
function settings(over: Record<string, unknown> = {}) {
return {
@@ -4,13 +4,13 @@
NORMALIZATION_MODES,
NORMALIZATION_TARGETS,
type NormalizationPrefs
} from '$lib/api/normalization';
} from '#lib/api/normalization.js';
import {
normalization,
loadNormalization,
setNormalization
} from '$lib/stores/normalization.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/stores/normalization.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Volume leveling (M464 #4998). The choice is the user's, stored on the
// server, so it follows them to the Android app and to casts.
@@ -1,17 +1,17 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
vi.mock('$lib/api/normalization', async (importOriginal) => ({
...(await importOriginal<typeof import('$lib/api/normalization')>()),
vi.mock('#lib/api/normalization.js', async (importOriginal) => ({
...(await importOriginal<typeof import('#lib/api/normalization.js')>()),
getNormalization: vi.fn(),
putNormalization: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import NormalizationSetting from './NormalizationSetting.svelte';
import { getNormalization, putNormalization } from '$lib/api/normalization';
import { normalization } from '$lib/stores/normalization.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
import { getNormalization, putNormalization } from '#lib/api/normalization.js';
import { normalization } from '#lib/stores/normalization.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
afterEach(() => {
vi.clearAllMocks();
@@ -9,9 +9,9 @@
markAllNotificationsRead,
markNotificationRead,
type NotificationItem
} from '$lib/api/notifications';
import { relativeTime } from '$lib/utils/relativeTime';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/notifications.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// The notifications inbox (M489): a bell in the header with an unread badge,
// opening a panel of what happened while you were away.
@@ -67,7 +67,7 @@
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (open = false)} />
<div class="relative flex-shrink-0">
<div class="relative shrink-0">
<button
bind:this={buttonRef}
type="button"
@@ -92,7 +92,7 @@
{#if open}
<div
bind:this={panelRef}
class="absolute right-0 z-50 mt-1 flex max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] flex-col rounded border border-border bg-surface shadow"
class="absolute right-0 z-50 mt-1 flex max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] flex-col rounded border border-border bg-surface shadow-sm"
role="dialog"
aria-label="Notifications"
>
@@ -123,7 +123,7 @@
onclick={() => openItem(item)}
>
<span
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full {item.read_at ? '' : 'bg-text-primary'}"
class="mt-1.5 h-2 w-2 shrink-0 rounded-full {item.read_at ? '' : 'bg-text-primary'}"
aria-hidden="true"
></span>
<span class="min-w-0 flex-1">
@@ -8,7 +8,7 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
});
vi.mock('$lib/api/notifications', async (orig) => {
vi.mock('#lib/api/notifications.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
badgeLabel: actual.badgeLabel,
@@ -19,7 +19,7 @@ vi.mock('$lib/api/notifications', async (orig) => {
};
});
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import NotificationBell from './NotificationBell.svelte';
import { goto } from '$app/navigation';
@@ -30,7 +30,7 @@ import {
markAllNotificationsRead,
markNotificationRead,
type NotificationItem
} from '$lib/api/notifications';
} from '#lib/api/notifications.js';
function item(over: Partial<NotificationItem> = {}): NotificationItem {
return {
@@ -4,9 +4,9 @@
getNotificationEmailSettings,
updateNotificationEmailSettings,
type NotificationEmailSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// When notification emails go out (M489 #5346). Nothing is emailed per
// event: new music is a daily summary, everything else a batch.
@@ -84,7 +84,7 @@
<select
bind:value={form.summary_hour}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
>
{#each hours as h (h)}
@@ -104,7 +104,7 @@
max="1440"
bind:value={form.batch_window_minutes}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -114,7 +114,7 @@
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving}
onclick={save}
@@ -1,16 +1,16 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getNotificationEmailSettings: vi.fn(),
updateNotificationEmailSettings: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import NotificationEmailCard from './NotificationEmailCard.svelte';
import { getNotificationEmailSettings, updateNotificationEmailSettings } from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
import { getNotificationEmailSettings, updateNotificationEmailSettings } from '#lib/api/admin.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
afterEach(() => vi.clearAllMocks());
@@ -8,10 +8,10 @@
type NotificationKindSetting,
type NotificationSettingChange,
type NotificationSettings
} from '$lib/api/notifications';
import { qk } from '$lib/api/queries';
import { user } from '$lib/auth/store.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/notifications.js';
import { qk } from '#lib/api/queries.js';
import { user } from '#lib/auth/store.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Per-user notification settings (M489): one row per kind, a toggle per
// channel. Toggles are optimistic; a failed save puts the switch back.
@@ -8,7 +8,7 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
return { ...actual, useQueryClient: () => ({ setQueryData, invalidateQueries: vi.fn() }) };
});
vi.mock('$lib/api/notifications', async (orig) => {
vi.mock('#lib/api/notifications.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
@@ -18,12 +18,12 @@ vi.mock('$lib/api/notifications', async (orig) => {
});
const userState = vi.hoisted(() => ({ current: { id: '1', username: 'alice', is_admin: false } }));
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { get value() { return userState.current; } }
}));
const pushToast = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) }));
import NotificationSettings from './NotificationSettings.svelte';
import {
@@ -31,7 +31,7 @@ import {
putNotificationSettings,
type NotificationKindSetting,
type NotificationSettings as Settings
} from '$lib/api/notifications';
} from '#lib/api/notifications.js';
const requesterKinds: NotificationKindSetting[] = [
{ kind: 'request_approved', admin_only: false, inbox: true, phone: true, email: true },
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
// The centred column every track-list page sits in: album, playlist,
// Liked, History and Search → Tracks (milestone 494). The playlist page
// had it alone from May 2026; the operator picked it as the page shape
// on 2026-10-08. Shelf and grid pages (Home, Library, Discover, Artist,
// Search) stay full width, since a narrow column would cost them cards.
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="mx-auto w-full max-w-4xl space-y-4 px-4 py-6">
{@render children()}
</div>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
// Play and Shuffle, the same on every page that has them: album,
// playlist and artist (milestone 494). Play is filled and Shuffle
// outlined. The fill is the accent: the operator's call on 2026-10-08,
// made with the house rule against accent on action buttons stated to
// them.
import type { Snippet } from 'svelte';
import { Play, Shuffle } from 'lucide-svelte';
let {
name,
disabled = false,
onPlay,
onShuffle,
children
}: {
/** What is played, for the buttons' accessible names. */
name: string;
disabled?: boolean;
onPlay: () => void;
onShuffle: () => void;
/** Further actions after Shuffle. */
children?: Snippet;
} = $props();
</script>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
aria-label={`Play ${name}`}
onclick={onPlay}
{disabled}
class="flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-medium
text-text-primary hover:opacity-90 focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50"
>
<Play size={16} strokeWidth={1.5} fill="currentColor" />
Play
</button>
<button
type="button"
aria-label={`Shuffle ${name}`}
onclick={onShuffle}
{disabled}
class="flex items-center gap-2 rounded-md border border-border bg-surface px-4 py-2
text-sm font-medium text-text-primary hover:border-accent
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
>
<Shuffle size={16} strokeWidth={1.5} />
Shuffle
</button>
{#if children}{@render children()}{/if}
</div>
+9 -9
View File
@@ -10,11 +10,11 @@
togglePlay, skipNext, skipPrev, seekTo, setVolume,
toggleShuffle, cycleRepeat, playQueue,
toggleQueueDrawer
} from '$lib/player/store.svelte';
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
} from '#lib/player/store.svelte.js';
import { formatDuration } from '#lib/media/duration.js';
import { FALLBACK_COVER, coverUrl } from '#lib/media/covers.js';
import { dominantColorFromUrl, rgbToCssString } from '#lib/media/dominantColor.js';
import { useSmoothPosition } from '#lib/player/smoothPosition.svelte.js';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
@@ -118,7 +118,7 @@
<a
href="/now-playing"
aria-label="Open now playing"
class="flex min-w-0 items-center gap-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
class="flex min-w-0 items-center gap-2 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
style="flex: 1 1 35%;"
>
<img
@@ -153,7 +153,7 @@
type="button"
aria-label={player.isPlaying ? 'Pause' : 'Play'}
onclick={togglePlay}
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow-sm transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
>
{#if player.state === 'loading' || player.state === 'idle'}
<Loader2 data-testid="play-spinner" size={22} strokeWidth={1.5} class="animate-spin" />
@@ -281,7 +281,7 @@
<a
href="/now-playing"
aria-label="Open now playing"
class="shrink-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
class="shrink-0 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
>
<img
src={coverUrl(current.album_id)}
@@ -341,7 +341,7 @@
type="button"
aria-label={player.isPlaying ? 'Pause' : 'Play'}
onclick={togglePlay}
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow-sm transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
>
{#if player.state === 'loading' || player.state === 'idle'}
<Loader2 size={24} strokeWidth={1.5} class="animate-spin" />
+6 -6
View File
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
// Mutable state the mocked store reads from.
const state = vi.hoisted(() => ({
@@ -20,7 +20,7 @@ const state = vi.hoisted(() => ({
queueDrawerOpen: false
}));
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
player: {
get queue() { return state.queue; },
get index() { return state.index; },
@@ -46,16 +46,16 @@ vi.mock('$lib/player/store.svelte', () => ({
toggleQueueDrawer: vi.fn()
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
import PlayerBar from './PlayerBar.svelte';
import {
togglePlay, skipNext, skipPrev, seekTo, setVolume,
toggleShuffle, cycleRepeat, playQueue,
toggleQueueDrawer
} from '$lib/player/store.svelte';
} from '#lib/player/store.svelte.js';
function track(): TrackRef {
return makeTrack({
+11 -11
View File
@@ -1,15 +1,15 @@
<script lang="ts">
import { Play, MoreVertical, RefreshCcw } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import type { Playlist, PlaylistTrack, TrackRef } from '$lib/api/types';
import { user } from '$lib/auth/store.svelte';
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
import { errCode } from '$lib/api/errors';
import { qk } from '$lib/api/queries';
import { playQueue } from '$lib/player/store.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
import type { Playlist, PlaylistTrack, TrackRef } from '#lib/api/types.js';
import { user } from '#lib/auth/store.svelte.js';
import { getPlaylist, systemShuffle, refreshSystem } from '#lib/api/playlists.js';
import { playlistTrackToRef } from '#lib/playlists/playlistTrackToRef.js';
import { systemPlaylistRefetch } from '#lib/playlists/systemRefetch.js';
import { errCode } from '#lib/api/errors.js';
import { qk } from '#lib/api/queries.js';
import { playQueue } from '#lib/player/store.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
let { playlist }: { playlist: Playlist } = $props();
@@ -184,7 +184,7 @@
>
<div
class="art-wrap relative aspect-square w-full overflow-hidden rounded-md
bg-surface-hover shadow-sm transition-all duration-150
bg-surface-hover shadow-xs transition-all duration-150
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
>
{#if playlist.cover_url}
@@ -203,7 +203,7 @@
is often a 2x2 collage; the gradient mutes the collage so
the playlist NAME becomes the dominant element. -->
<div
class="pointer-events-none absolute inset-0 bg-gradient-to-t
class="pointer-events-none absolute inset-0 bg-linear-to-t
from-black/85 via-black/40 to-transparent"
></div>
<!-- Title burned in at the bottom of the cover. Two lines max;
+15 -15
View File
@@ -1,13 +1,13 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import PlaylistCard from './PlaylistCard.svelte';
import type { Playlist, PlaylistDetail } from '$lib/api/types';
import type { Playlist, PlaylistDetail } from '#lib/api/types.js';
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
}));
vi.mock('$lib/api/playlists', () => ({
vi.mock('#lib/api/playlists.js', () => ({
getPlaylist: vi.fn().mockResolvedValue({
id: 'p-1',
user_id: 'u-self',
@@ -75,11 +75,11 @@ vi.mock('$lib/api/playlists', () => ({
refreshSystem: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5, track_ids: ['t-1'] })
}));
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn()
}));
vi.mock('$lib/api/queries', () => ({
vi.mock('#lib/api/queries.js', () => ({
qk: {
playlist: (id: string) => ['playlist', id],
playlists: (kind?: string) => ['playlists', { kind: kind ?? 'user' }]
@@ -162,8 +162,8 @@ describe('PlaylistCard', () => {
});
test('play button calls playQueue with tracks when clicked', async () => {
const { getPlaylist } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
const { getPlaylist } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
@@ -222,7 +222,7 @@ describe('PlaylistCard', () => {
});
test('clicking Refresh calls refreshSystem with the raw variant', async () => {
const { refreshSystem } = await import('$lib/api/playlists');
const { refreshSystem } = await import('#lib/api/playlists.js');
const discoverPlaylist: Playlist = {
...base,
kind: 'system',
@@ -237,8 +237,8 @@ describe('PlaylistCard', () => {
});
test('For-You play calls systemShuffle, not getPlaylist', async () => {
const { getPlaylist, systemShuffle } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
const forYouPlaylist: Playlist = {
...base,
kind: 'system',
@@ -261,7 +261,7 @@ describe('PlaylistCard', () => {
});
test('For-You play passes source:for_you to playQueue (no client shuffle)', async () => {
const { playQueue } = await import('$lib/player/store.svelte');
const { playQueue } = await import('#lib/player/store.svelte.js');
const forYouPlaylist: Playlist = {
...base,
kind: 'system',
@@ -281,8 +281,8 @@ describe('PlaylistCard', () => {
});
test('user-playlist play uses getPlaylist + plain playQueue (no source)', async () => {
const { getPlaylist, systemShuffle } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
await fireEvent.click(playButton);
@@ -294,8 +294,8 @@ describe('PlaylistCard', () => {
});
test('user-playlist play does not refresh anything', async () => {
const { refreshSystem, getPlaylist } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
const { refreshSystem, getPlaylist } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
+45 -78
View File
@@ -1,12 +1,10 @@
<script lang="ts">
// A track in a playlist. The look is TrackListRow's; what this adds is
// the reorder grip, remove, and the two ways an entry can be dead.
import { GripVertical, X } from 'lucide-svelte';
import { draggable, type DragEventData } from '@neodrag/svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import type { PlaylistTrack } from '$lib/api/types';
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
import { player } from '$lib/player/store.svelte';
import { offsetToDelta } from './queue-row-math';
import type { PlaylistTrack } from '#lib/api/types.js';
import { playlistTrackToRef } from '#lib/playlists/playlistTrackToRef.js';
import TrackListRow from './TrackListRow.svelte';
let {
row,
@@ -32,22 +30,21 @@
const isRemoved = $derived(row.track_id === null);
const isUnavailable = $derived(isRemoved || isMissingFile);
// Reconstruct a minimal TrackRef for the kebab menu when the
// upstream track still exists. When unavailable, the menu is hidden.
// null when the entry cannot be played, which hides like, radio, add
// and the menu.
const liveTrack = $derived(playlistTrackToRef(row));
let measuredRowHeight = 56; // sensible default; replaced on dragStart
const canDrag = $derived(isOwner && !isUnavailable);
function handleDragStart(data: DragEventData) {
const rect = data.rootNode.getBoundingClientRect();
if (rect.height > 0) measuredRowHeight = rect.height;
}
function handleDragEnd(data: DragEventData) {
const delta = offsetToDelta(data.offsetY, measuredRowHeight);
if (delta === 0) return;
onMove?.(row.position, row.position + delta);
const drag = $derived(
isOwner
? {
disabled: !canDrag,
handle: '[data-reorder-handle]',
onMove: (delta: number) => onMove?.(row.position, row.position + delta)
}
: null
);
function handleHandleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowUp') {
@@ -63,87 +60,57 @@
e.preventDefault();
}
}
function format(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Drag is gated on owner+available. When neither, the action runs but
// the dragEnd handler skips by returning before onMove fires; visually
// the user never sees the row move.
const canDrag = $derived(isOwner && !isUnavailable);
// "Now playing" highlight: matches QueueTrackRow's treatment so the
// user can see which row is current without reading the player bar.
// Unavailable rows (trackId === null) never match.
const isCurrent = $derived(
!isUnavailable && player.current?.id === row.track_id,
);
</script>
<div
role="listitem"
class="flex items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-surface-hover
{isUnavailable ? 'text-text-muted' : 'text-text-primary'}
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
use:draggable={{
axis: 'y',
bounds: 'parent',
disabled: !canDrag,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd
}}
<TrackListRow
title={row.title}
subtitle={`${row.artist_name} · ${row.album_title}`}
durationSec={row.duration_sec}
track={liveTrack}
ariaLabel={`Play ${row.title}`}
rowTitle={isMissingFile
? 'This track’s file is missing from the library, so it can’t be played. It stays in the playlist, and returns automatically if the file comes back.'
: undefined}
disabled={isUnavailable}
struck={isRemoved}
{drag}
onActivate={() => onPlay(row.position)}
>
{#snippet lead()}
{#if isOwner}
<button
type="button"
data-reorder-handle
disabled={!canDrag}
aria-label="Reorder track (drag or use arrow keys)"
aria-keyshortcuts="ArrowUp ArrowDown"
onclick={(e) => e.stopPropagation()}
onkeydown={handleHandleKeydown}
class="flex-shrink-0 cursor-grab text-text-muted hover:text-text-primary
active:cursor-grabbing focus-visible:outline focus-visible:outline-2
class="cursor-grab p-1 text-text-muted hover:text-text-primary
active:cursor-grabbing focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
>
<GripVertical size={14} strokeWidth={1} />
<GripVertical size={16} strokeWidth={1} />
</button>
{/if}
{/snippet}
<button
type="button"
class="min-w-0 flex-1 text-left"
onclick={() => !isUnavailable && onPlay(row.position)}
disabled={isUnavailable}
title={isMissingFile
? 'This track’s file is missing from the library, so it can’t be played. It stays in the playlist, and returns automatically if the file comes back.'
: undefined}
>
<div class="truncate {isRemoved ? 'line-through' : ''}">{row.title}</div>
<div class="truncate text-xs text-text-muted">
{row.artist_name} · {row.album_title}
{#snippet note()}
{#if isMissingFile}
<span class="text-action-destructive"> · File missing</span>
{/if}
</div>
</button>
<span class="flex-shrink-0 text-xs text-text-muted">{format(row.duration_sec)}</span>
{#if !isUnavailable && liveTrack}
<LikeButton entityType="track" entityId={liveTrack.id} />
<TrackMenu track={liveTrack} />
<span class="text-error-fg"> · File missing</span>
{/if}
{/snippet}
{#snippet trailing()}
{#if isOwner}
<button
type="button"
onclick={() => onRemove(row.position)}
onclick={(e) => { e.stopPropagation(); onRemove(row.position); }}
aria-label={`Remove ${row.title} from playlist`}
class="flex-shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
class="shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
>
<X size={14} strokeWidth={1} />
<X size={16} strokeWidth={1} />
</button>
{/if}
</div>
{/snippet}
</TrackListRow>
@@ -2,14 +2,14 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import type { PlaylistTrack } from '$lib/api/types';
import type { PlaylistTrack } from '#lib/api/types.js';
// LikeButton + TrackMenu transitively pull in TanStack Query, the auth
// store, likes/quarantine APIs, the player store, and SvelteKit
// navigation. Mock all of them at module level so the component can be
// rendered in isolation.
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { value: { id: 'u1', username: 'me', is_admin: false } }
}));
@@ -17,11 +17,11 @@ vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playQueue: vi.fn(),
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Save, TriangleAlert } from 'lucide-svelte';
import { getNetworkSettings, updatePublicUrl } from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
import { getNetworkSettings, updatePublicUrl } from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
let saved = $state<string | null>(null);
let value = $state('');
@@ -68,7 +68,7 @@
placeholder="https://music.example.com"
bind:value
class="w-full rounded border border-border bg-background px-2 py-1 font-mono
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
</label>
{#if here && value.trim() !== here}
@@ -96,7 +96,7 @@
{#if !saved}
<p class="flex items-start gap-2 text-sm text-text-secondary" role="status">
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
Not set, so password-reset emails are not being sent. Minstrel won't build the link from
whatever address a request claims, because anyone can claim any address.
@@ -5,13 +5,13 @@ import PublicAddressCard from './PublicAddressCard.svelte';
const getNetworkSettings = vi.fn();
const updatePublicUrl = vi.fn();
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getNetworkSettings: () => getNetworkSettings(),
updatePublicUrl: (url: string) => updatePublicUrl(url)
}));
const pushToast = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({
vi.mock('#lib/stores/toast.svelte.js', () => ({
pushToast: (...args: unknown[]) => pushToast(...args)
}));
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { player, closeQueueDrawer } from '$lib/player/store.svelte';
import { player, closeQueueDrawer } from '#lib/player/store.svelte.js';
import QueueList from './QueueList.svelte';
let previouslyFocused: HTMLElement | null = null;
+4 -4
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
import type { TrackRef } from '#lib/api/types.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
// QueueTrackRow (rendered inside QueueDrawer → QueueList) now renders a
// LikeButton, which reads createLikedIdsQuery. Stub the likes API so the
@@ -10,14 +10,14 @@ import { makeTrack } from '$test-utils/fixtures/track';
// emptyLikesMock import — must precede the QueueDrawer import below, since
// loading the component evaluates the mocked modules and the hoisted
// factories need their referenced bindings already initialized.
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
const closeQueueDrawer = vi.fn();
let queueValue: TrackRef[] = [];
let indexValue = 0;
let openValue = true;
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
closeQueueDrawer: (...args: unknown[]) => closeQueueDrawer(...args),
player: {
get queue() { return queueValue; },
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { X, Trash2, ArrowDown } from 'lucide-svelte';
import { player, clearQueue } from '$lib/player/store.svelte';
import { player, clearQueue } from '#lib/player/store.svelte.js';
import QueueTrackRow from './QueueTrackRow.svelte';
// onClose: when provided, renders an X button in the header so the
+12 -26
View File
@@ -1,10 +1,9 @@
<script lang="ts">
import { GripVertical, X } from 'lucide-svelte';
import { draggable, type DragEventData } from '@neodrag/svelte';
import type { TrackRef } from '$lib/api/types';
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
import { coverUrl, FALLBACK_COVER } from '$lib/media/covers';
import { offsetToDelta } from './queue-row-math';
import type { TrackRef } from '#lib/api/types.js';
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '#lib/player/store.svelte.js';
import { coverUrl, FALLBACK_COVER } from '#lib/media/covers.js';
import { reorderDrag } from './reorder-drag';
import LikeButton from './LikeButton.svelte';
let { track, index, isCurrent } = $props<{
@@ -13,14 +12,6 @@
isCurrent: boolean;
}>();
let measuredRowHeight = 64; // sensible default; replaced on dragStart
function handleDragStart(data: DragEventData) {
// The action's `data.rootNode` is the element use:draggable was applied to.
const rect = data.rootNode.getBoundingClientRect();
if (rect.height > 0) measuredRowHeight = rect.height;
}
function handleBodyClick() {
if (isCurrent) return;
playFromQueueIndex(index);
@@ -30,12 +21,6 @@
removeFromQueue(index);
}
function handleDragEnd(data: DragEventData) {
const delta = offsetToDelta(data.offsetY, measuredRowHeight);
if (delta === 0) return;
moveQueueItem(index, index + delta);
}
function handleHandleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowUp') {
e.preventDefault();
@@ -53,14 +38,15 @@
</script>
<div
use:draggable={{ axis: 'y', bounds: 'parent', onDragStart: handleDragStart, onDragEnd: handleDragEnd }}
use:reorderDrag={{ onMove: (delta) => moveQueueItem(index, index + delta) }}
class="flex items-center gap-2 border-b border-border px-3 py-2 h-16
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
[&.neodrag-dragging]:relative [&.neodrag-dragging]:z-10 [&.neodrag-dragging]:shadow-lg
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : 'bg-background'}"
>
<!--
The album art is the grab surface (#2395). The grip used to occupy its own
column in every row; it now sits OVER the art, so it costs no horizontal
space at all. `use:draggable` is on the row (above), so dragging already
space at all. `use:reorderDrag` is on the row (above), so dragging already
worked from anywhere — the grip's real jobs are being the visual cue and
the keyboard target, and both survive here.
@@ -71,7 +57,7 @@
hover/focus so the artwork stays legible the rest of the time; the drop
shadow is what keeps the glyph readable over pale covers without one.
-->
<div class="relative h-10 w-10 flex-shrink-0">
<div class="relative h-10 w-10 shrink-0">
<img
src={coverUrl(track.album_id)}
alt=""
@@ -86,7 +72,7 @@
class="group absolute inset-0 flex cursor-grab items-center justify-center rounded
text-white/70 transition hover:bg-black/45 hover:text-white
focus-visible:bg-black/45 focus-visible:text-white
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
style="filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.9))"
>
<GripVertical size={16} />
@@ -101,7 +87,7 @@
>
<div class="flex items-center gap-2 min-w-0">
{#if isCurrent}
<span class="text-xs text-accent-fg uppercase tracking-wide flex-shrink-0">Now playing</span>
<span class="text-xs text-accent-fg uppercase tracking-wide shrink-0">Now playing</span>
{/if}
<span class="font-medium truncate text-text-primary">{track.title}</span>
</div>
@@ -114,7 +100,7 @@
type="button"
onclick={handleRemove}
aria-label="Remove from queue"
class="text-text-secondary hover:text-text-primary flex-shrink-0"
class="text-text-secondary hover:text-text-primary shrink-0"
>
<X size={16} />
</button>
+3 -3
View File
@@ -1,14 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
// QueueTrackRow now renders a LikeButton, which reads createLikedIdsQuery.
// Stub the likes API so the row doesn't need a real QueryClient in context.
// The mock (and its emptyLikesMock import) must precede the component import
// below: importing QueueTrackRow transitively loads LikeButton → the mocked
// module, and the hoisted factory needs emptyLikesMock already initialized.
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
import QueueTrackRow from './QueueTrackRow.svelte';
@@ -16,7 +16,7 @@ const playFromQueueIndex = vi.fn();
const removeFromQueue = vi.fn();
const moveQueueItem = vi.fn();
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
playFromQueueIndex: (...args: unknown[]) => playFromQueueIndex(...args),
removeFromQueue: (...args: unknown[]) => removeFromQueue(...args),
moveQueueItem: (...args: unknown[]) => moveQueueItem(...args)
+1 -1
View File
@@ -58,7 +58,7 @@
oninput={onInput}
onkeydown={onKey}
class="w-full rounded border border-border bg-surface pl-7 pr-7 py-1 text-sm
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
{#if raw}
<button
@@ -5,9 +5,9 @@
getReacquisitionSettings,
updateReacquisitionSettings,
type ReacquisitionSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Policy for turning a missing file back into a Lidarr request
// (milestone #290). Lives on the missing-files page rather than under
@@ -127,7 +127,7 @@
max="720"
bind:value={form.grace_hours}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -144,7 +144,7 @@
max="10"
bind:value={form.max_attempts}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -157,7 +157,7 @@
max="168"
bind:value={form.backoff_base_hours}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -170,7 +170,7 @@
max="720"
bind:value={form.backoff_max_hours}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -186,7 +186,7 @@
max="200"
bind:value={form.max_per_pass}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -213,7 +213,7 @@
watching those rows never get a request would reasonably conclude
the feature is broken. -->
<p class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary">
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
{saved.unnameable_albums}
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no
@@ -227,7 +227,7 @@
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving}
onclick={save}
@@ -1,18 +1,18 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { ReacquisitionSettings } from '$lib/api/admin';
import { ERROR_COPY } from '$lib/api/error-copy';
import type { ReacquisitionSettings } from '#lib/api/admin.js';
import { ERROR_COPY } from '#lib/api/error-copy.js';
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getReacquisitionSettings: vi.fn(),
updateReacquisitionSettings: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import ReacquisitionSettingsCard from './ReacquisitionSettingsCard.svelte';
import { getReacquisitionSettings, updateReacquisitionSettings } from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
import { getReacquisitionSettings, updateReacquisitionSettings } from '#lib/api/admin.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
const base: ReacquisitionSettings = {
enabled: true,
+7 -7
View File
@@ -18,16 +18,16 @@
function navigate(next: string) {
const onSearchPage = page.url.pathname.startsWith('/search');
const trimmed = next.trim();
// keepFocus: SvelteKit blurs the active element on navigation by default.
// For a typeahead-style URL update we want the user to keep typing
// without re-clicking the input every 250 ms.
// reset: false: SvelteKit blurs the active element and scrolls to the top
// on navigation by default. For a typeahead-style URL update we want the
// user to keep typing without re-clicking the input every 250 ms.
if (trimmed === '') {
if (onSearchPage) goto('/search', { replaceState: true, keepFocus: true });
if (onSearchPage) goto('/search', { replace: true, reset: false });
return;
}
goto(`/search?q=${encodeURIComponent(trimmed)}`, {
replaceState: onSearchPage,
keepFocus: true,
replace: onSearchPage,
reset: false,
});
}
@@ -66,6 +66,6 @@
bind:value
oninput={onInput}
onkeydown={onKey}
class="w-full rounded border border-border bg-surface pl-7 pr-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
class="w-full rounded border border-border bg-surface pl-7 pr-3 py-1 text-sm focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
</label>
+7 -7
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { pageUrlModule } from '$test-utils/mocks/appState';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/')
@@ -40,7 +40,7 @@ describe('SearchInput', () => {
expect(goto).not.toHaveBeenCalled();
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false, keepFocus: true });
expect(goto).toHaveBeenCalledWith('/search?q=h', { replace: false, reset: false });
});
test('rapid keystrokes debounce to a single goto', async () => {
@@ -53,16 +53,16 @@ describe('SearchInput', () => {
await fireEvent.input(input, { target: { value: 'hel' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false, keepFocus: true });
expect(goto).toHaveBeenCalledWith('/search?q=hel', { replace: false, reset: false });
});
test('typing while on /search uses replaceState=true', async () => {
test('typing while on /search uses replace=true', async () => {
state.pageUrl = new URL('http://localhost/search?q=h');
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'he' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true, keepFocus: true });
expect(goto).toHaveBeenCalledWith('/search?q=he', { replace: true, reset: false });
});
test('clearing input on /search calls goto("/search") (drops ?q=)', async () => {
@@ -71,7 +71,7 @@ describe('SearchInput', () => {
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: '' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search', { replaceState: true, keepFocus: true });
expect(goto).toHaveBeenCalledWith('/search', { replace: true, reset: false });
});
test('Escape clears the input value', async () => {
@@ -88,6 +88,6 @@ describe('SearchInput', () => {
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'a b&c' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false, keepFocus: true });
expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replace: false, reset: false });
});
});
+3 -3
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { X, ListPlus, Plus, ListMusic, Heart } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { selection, clearSelection } from '$lib/selection/store.svelte';
import { playNextMany, enqueueTracks } from '$lib/player/store.svelte';
import { likeEntity, createLikedIdsQuery } from '$lib/api/likes';
import { selection, clearSelection } from '#lib/selection/store.svelte.js';
import { playNextMany, enqueueTracks } from '#lib/player/store.svelte.js';
import { likeEntity, createLikedIdsQuery } from '#lib/api/likes.js';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
const queryClient = useQueryClient();
+7 -7
View File
@@ -2,9 +2,9 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { ChevronDown, Compass, House, LibraryBig } from 'lucide-svelte';
import { user, logout } from '$lib/auth/store.svelte';
import { player } from '$lib/player/store.svelte';
import { appName } from '$lib/branding';
import { user, logout } from '#lib/auth/store.svelte.js';
import { player } from '#lib/player/store.svelte.js';
import { appName } from '#lib/branding.js';
import MinstrelMark from './MinstrelMark.svelte';
import NotificationBell from './NotificationBell.svelte';
import PlayerBar from './PlayerBar.svelte';
@@ -35,7 +35,7 @@
async function handleLogout() {
menuOpen = false;
await logout();
goto('/login', { replaceState: true });
goto('/login', { replace: true });
}
function isActive(prefix: string): boolean {
@@ -94,13 +94,13 @@
</nav>
<div class="flex items-center gap-1 md:gap-3 justify-self-end min-w-0">
<div class="hidden md:block min-w-0 max-w-xs flex-shrink">
<div class="hidden md:block min-w-0 max-w-xs shrink">
<SearchInput />
</div>
<NotificationBell />
<div class="relative flex-shrink-0">
<div class="relative shrink-0">
<button
bind:this={menuButtonRef}
type="button"
@@ -120,7 +120,7 @@
{#if menuOpen}
<div
bind:this={menuRef}
class="absolute right-0 z-50 mt-1 w-40 rounded border border-border bg-surface shadow"
class="absolute right-0 z-50 mt-1 w-40 rounded border border-border bg-surface shadow-sm"
role="menu"
tabindex="-1"
>
+4 -4
View File
@@ -19,14 +19,14 @@ const userState = vi.hoisted(() => ({
} | null
}));
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { get value() { return userState.current; } },
logout: vi.fn().mockResolvedValue(undefined)
}));
// The bell's queries need a QueryClient the Shell tests do not provide; its own
// behaviour is covered in NotificationBell.test.ts.
vi.mock('$lib/api/notifications', async () => {
vi.mock('#lib/api/notifications.js', async () => {
const { mockQuery } = await import('../../test-utils/query');
return {
createUnreadCountQuery: () => mockQuery({ data: 0 }),
@@ -38,7 +38,7 @@ vi.mock('$lib/api/notifications', async () => {
});
import Shell from './Shell.svelte';
import { logout } from '$lib/auth/store.svelte';
import { logout } from '#lib/auth/store.svelte.js';
import { goto } from '$app/navigation';
afterEach(() => {
@@ -110,6 +110,6 @@ describe('Shell', () => {
await fireEvent.click(screen.getByRole('button', { name: /alice/i }));
await fireEvent.click(screen.getByRole('menuitem', { name: /log out/i }));
expect(logout).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/login', { replaceState: true });
expect(goto).toHaveBeenCalledWith('/login', { replace: true });
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts" module>
import type { LidarrRequestStatus } from '$lib/api/types';
import type { LidarrRequestStatus } from '#lib/api/types.js';
type Tone = 'warning' | 'info' | 'success' | 'error';
+6 -6
View File
@@ -5,12 +5,12 @@
createSnoozesQuery,
snoozeSuggestion,
unsnoozeSuggestion
} from '$lib/api/suggestions';
import { createRequest } from '$lib/api/requests';
import { qk } from '$lib/api/queries';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/suggestions.js';
import { createRequest } from '#lib/api/requests.js';
import { qk } from '#lib/api/queries.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import DiscoverResultCard from './DiscoverResultCard.svelte';
import type { ArtistSuggestion, SeedContribution, SuggestionSnooze } from '$lib/api/types';
import type { ArtistSuggestion, SeedContribution, SuggestionSnooze } from '#lib/api/types.js';
const client = useQueryClient();
const queryStore = createSuggestionsQuery();
@@ -199,7 +199,7 @@
<button
type="button"
aria-label={`Bring ${snoozed.name} back now`}
class="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
class="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-hidden focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze(snoozed.mbid, snoozed.name)}
>
Bring back
@@ -8,19 +8,19 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
});
vi.mock('$lib/api/suggestions', () => ({
vi.mock('#lib/api/suggestions.js', () => ({
createSuggestionsQuery: vi.fn(),
createSnoozesQuery: vi.fn(),
snoozeSuggestion: vi.fn().mockResolvedValue(undefined),
unsnoozeSuggestion: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('$lib/api/requests', () => ({
vi.mock('#lib/api/requests.js', () => ({
createRequest: vi.fn().mockResolvedValue({})
}));
const pushToastMock = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({
vi.mock('#lib/stores/toast.svelte.js', () => ({
pushToast: (...args: unknown[]) => pushToastMock(...args)
}));
@@ -30,9 +30,9 @@ import {
createSnoozesQuery,
snoozeSuggestion,
unsnoozeSuggestion
} from '$lib/api/suggestions';
import { createRequest } from '$lib/api/requests';
import type { ArtistSuggestion, SuggestionSnooze } from '$lib/api/types';
} from '#lib/api/suggestions.js';
import { createRequest } from '#lib/api/requests.js';
import type { ArtistSuggestion, SuggestionSnooze } from '#lib/api/types.js';
const oneSeed: ArtistSuggestion = {
mbid: 'mb1',
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts" generics="Id extends string">
// In-page tabs: one strip of buttons switching what a single page shows
// (#5416). The same look as AdminTabs and DiscoverTabs, which are route
// navigation and so stay links; this is for state inside one page, such as
// Liked's Artists | Albums | Tracks or a queue's status filter.
type Item = {
id: Id;
label: string;
/** Shown as a pill after the label. Omit for no pill. */
count?: number;
};
let {
items,
value = $bindable(),
ariaLabel
}: {
items: Item[];
value: Id;
ariaLabel: string;
} = $props();
</script>
<div role="tablist" aria-label={ariaLabel} class="flex gap-2 border-b border-border">
{#each items as item (item.id)}
{@const active = value === item.id}
<button
type="button"
role="tab"
aria-selected={active}
class="border-b-2 px-3 py-2 text-sm transition-colors {active
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
onclick={() => (value = item.id)}
>
{item.label}
{#if item.count !== undefined}
<span
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="tab-count"
>
{item.count}
</span>
{/if}
</button>
{/each}
</div>
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import TabStrip from './TabStrip.svelte';
const items = [
{ id: 'artists', label: 'Artists', count: 3 },
{ id: 'albums', label: 'Albums' }
];
describe('TabStrip', () => {
test('a tablist with the selected tab marked, and counts where given', () => {
render(TabStrip, { props: { items, value: 'artists', ariaLabel: 'Liked' } });
expect(screen.getByRole('tablist', { name: 'Liked' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /artists/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: /albums/i })).toHaveAttribute('aria-selected', 'false');
expect(screen.getAllByTestId('tab-count').map((c) => c.textContent?.trim())).toEqual(['3']);
});
test('clicking a tab selects it', async () => {
render(TabStrip, { props: { items, value: 'artists', ariaLabel: 'Liked' } });
await fireEvent.click(screen.getByRole('tab', { name: /albums/i }));
expect(screen.getByRole('tab', { name: /albums/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: /artists/i })).toHaveAttribute('aria-selected', 'false');
});
});
+2 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts">
// Single global toast renderer. Mounted once in +layout.svelte; pages call
// pushToast() from $lib/stores/toast.svelte. The {#key} re-mount on id
// pushToast() from #lib/stores/toast.svelte. The {#key} re-mount on id
// change is what makes screen readers re-announce a replacement toast.
import { toast } from '$lib/stores/toast.svelte';
import { toast } from '#lib/stores/toast.svelte.js';
</script>
{#if toast.value}
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
// The box every track list sits in (milestone 494). No fill of its own:
// the rows stripe themselves, and a filled box would hide the stripe.
// Rows are direct children so `odd:` counts them.
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="overflow-hidden rounded-md border border-border">
{@render children()}
</div>
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts">
// The one shape every track list in the web app draws (milestone 494):
// a lead column (track number, drag grip or cover), title over a muted
// subtitle, then like, radio, add-to-queue, the track menu and the
// duration, then whatever the list adds at the end (remove, a timestamp).
// The wrappers — TrackRow, PlaylistTrackRow, HistoryRow — own behaviour
// (selection, reorder, remove); this component owns how a row looks, so
// the lists cannot drift apart again.
import type { Snippet } from 'svelte';
import { Plus, Radio } from 'lucide-svelte';
import type { TrackRef } from '#lib/api/types.js';
import { formatDuration } from '#lib/media/duration.js';
import { player, enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import { reorderDrag, type ReorderDragOptions } from './reorder-drag';
let {
title,
subtitle = '',
durationSec,
track,
ariaLabel,
rowTitle,
isSelected = false,
disabled = false,
struck = false,
drag = null,
onActivate,
lead,
note,
trailing
}: {
title: string;
subtitle?: string;
durationSec: number;
/** The playable track. null when the row cannot be played, which hides
like, radio, add and the menu: there is nothing for them to act on. */
track: TrackRef | null;
ariaLabel: string;
/** Tooltip for the whole row. */
rowTitle?: string;
isSelected?: boolean;
disabled?: boolean;
/** Strike the title through: the track is gone for good. */
struck?: boolean;
drag?: ReorderDragOptions | null;
onActivate: (e: MouseEvent | KeyboardEvent) => void;
lead?: Snippet;
/** Appended to the subtitle line, e.g. a "File missing" marker. */
note?: Snippet;
trailing?: Snippet;
} = $props();
const isCurrent = $derived(!!track && player.current?.id === track.id);
// The stripe is only drawn on rows with no state of their own. Tailwind
// emits `odd:` after the plain utilities, so a striped odd row would win
// over the now-playing or selected background.
const background = $derived(
isCurrent
? 'border-l-2 border-l-accent bg-surface-hover'
: isSelected
? 'bg-surface-hover'
: 'odd:bg-surface/50 hover:bg-surface-hover'
);
function onclick(e: MouseEvent) {
if (!disabled) onActivate(e);
}
function onkeydown(e: KeyboardEvent) {
// Keys pressed on a control inside the row belong to that control.
if (e.target !== e.currentTarget) return;
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (!disabled) onActivate(e);
}
function onRadio(e: MouseEvent) {
e.stopPropagation();
if (track) playRadio(track.id);
}
function onAdd(e: MouseEvent) {
e.stopPropagation();
if (track) enqueueTrack(track);
}
</script>
<div
role="button"
tabindex="0"
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
title={rowTitle}
data-selected={isSelected}
{onclick}
{onkeydown}
use:reorderDrag={drag}
class="group relative flex w-full items-center gap-3 px-3 py-2 text-left text-sm
transition-colors focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent [&.neodrag-dragging]:z-10
[&.neodrag-dragging]:bg-surface-hover [&.neodrag-dragging]:shadow-lg
{background}
{disabled ? 'cursor-default text-text-muted' : 'cursor-pointer text-text-primary'}"
>
{#if lead}
<span class="flex w-8 shrink-0 items-center justify-center">{@render lead()}</span>
{/if}
<span class="min-w-0 flex-1">
<span class="block truncate {struck ? 'line-through' : ''}">{title}</span>
{#if subtitle || note}
<span class="block truncate text-xs text-text-secondary">
{subtitle}{#if note}{@render note()}{/if}
</span>
{/if}
</span>
{#if track}
<LikeButton entityType="track" entityId={track.id} />
<button
type="button"
aria-label="Play radio from this track"
title="Play radio"
onclick={onRadio}
class="hidden shrink-0 rounded p-1 text-text-secondary hover:text-text-primary sm:inline-flex"
>
<Radio size={16} strokeWidth={1} />
</button>
<button
type="button"
aria-label={`Add ${track.title} to queue`}
title="Add to queue"
onclick={onAdd}
class="hidden shrink-0 rounded p-1 text-text-secondary hover:text-text-primary sm:inline-flex"
>
<Plus size={16} strokeWidth={1} />
</button>
<TrackMenu {track} />
{/if}
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
{formatDuration(durationSec)}
</span>
{#if trailing}
{@render trailing()}
{/if}
</div>
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import { makeTrack } from '#test-utils/fixtures/track.js';
const player = vi.hoisted(() => ({ current: undefined as { id: string } | undefined }));
vi.mock('#lib/player/store.svelte.js', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playQueue: vi.fn(),
playRadio: vi.fn(),
player
}));
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
import TrackListRow from './TrackListRow.svelte';
import { enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
const track = makeTrack({ id: 't1', title: 'Roygbiv', duration_sec: 137 });
function base(overrides: Record<string, unknown> = {}) {
return {
title: 'Roygbiv',
subtitle: 'Boards of Canada · MHTRTC',
durationSec: 137,
track,
ariaLabel: 'Roygbiv',
onActivate: vi.fn(),
...overrides
};
}
afterEach(() => {
vi.clearAllMocks();
player.current = undefined;
});
describe('TrackListRow', () => {
test('draws title, subtitle and duration', () => {
render(TrackListRow, { props: base() });
expect(screen.getByText('Roygbiv')).toBeInTheDocument();
expect(screen.getByText('Boards of Canada · MHTRTC')).toBeInTheDocument();
expect(screen.getByText('2:17')).toBeInTheDocument();
});
test('a plain row is striped; the now-playing row is banded instead', () => {
const { unmount } = render(TrackListRow, { props: base() });
expect(screen.getByRole('button', { name: 'Roygbiv' }).className).toContain('odd:bg-surface/50');
unmount();
player.current = { id: 't1' };
render(TrackListRow, { props: base() });
const row = screen.getByRole('button', { name: 'Roygbiv' });
expect(row.className).toContain('border-l-accent');
expect(row.className).not.toContain('odd:bg-surface/50');
});
test('click and Enter activate the row', async () => {
const props = base();
render(TrackListRow, { props });
const row = screen.getByRole('button', { name: 'Roygbiv' });
await fireEvent.click(row);
await fireEvent.keyDown(row, { key: 'Enter' });
expect(props.onActivate).toHaveBeenCalledTimes(2);
});
// Enter on a control inside the row is that control's, not the row's.
// The old TrackRow played the track when Enter was pressed on "add".
test('Enter on an inner control does not activate the row', async () => {
const props = base();
render(TrackListRow, { props });
await fireEvent.keyDown(screen.getByRole('button', { name: /add roygbiv to queue/i }), { key: 'Enter' });
expect(props.onActivate).not.toHaveBeenCalled();
});
test('radio and add act on the track without activating the row', async () => {
const props = base();
render(TrackListRow, { props });
await fireEvent.click(screen.getByRole('button', { name: /play radio/i }));
await fireEvent.click(screen.getByRole('button', { name: /add roygbiv to queue/i }));
expect(playRadio).toHaveBeenCalledWith('t1');
expect(enqueueTrack).toHaveBeenCalledWith(track);
expect(props.onActivate).not.toHaveBeenCalled();
});
test('a row with no playable track hides like, radio, add and the menu', () => {
render(TrackListRow, { props: base({ track: null, disabled: true }) });
expect(screen.queryByRole('button', { name: /play radio/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /to queue/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /track actions for/i })).not.toBeInTheDocument();
});
test('a disabled row does not activate', async () => {
const props = base({ disabled: true });
render(TrackListRow, { props });
await fireEvent.click(screen.getByRole('button', { name: 'Roygbiv' }));
expect(props.onActivate).not.toHaveBeenCalled();
});
});
+5 -5
View File
@@ -18,11 +18,11 @@
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
import { qk } from '$lib/api/queries';
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import type { TrackRef } from '$lib/api/types';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
import { createMyQuarantineQuery, unflagTrack } from '#lib/api/quarantine.js';
import { qk } from '#lib/api/queries.js';
import { playNext, enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
import type { TrackRef } from '#lib/api/types.js';
let {
track,

Some files were not shown because too many files have changed in this diff Show More