Compare commits
17
Commits
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+668
-1062
File diff suppressed because it is too large
Load Diff
+11
-8
@@ -13,24 +13,27 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^4.0.0",
|
||||
"@sveltejs/kit": "^2.8.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": "^3.4.14",
|
||||
"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/*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
+71
-4
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { ApiError } from '$lib/api/client';
|
||||
import type { ApiError } from '#lib/api/client.js';
|
||||
|
||||
let {
|
||||
error,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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">
|
||||
{timestamp}
|
||||
</span>
|
||||
</button>
|
||||
{#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>
|
||||
{/snippet}
|
||||
</TrackListRow>
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
{#if isOwner}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canDrag}
|
||||
aria-label="Reorder track (drag or use arrow keys)"
|
||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||
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
|
||||
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<GripVertical size={14} strokeWidth={1} />
|
||||
</button>
|
||||
{/if}
|
||||
{#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="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={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}
|
||||
{#if isMissingFile}
|
||||
<span class="text-action-destructive"> · File missing</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{#snippet note()}
|
||||
{#if isMissingFile}
|
||||
<span class="text-error-fg"> · File missing</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<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} />
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => 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"
|
||||
>
|
||||
<X size={14} strokeWidth={1} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet trailing()}
|
||||
{#if isOwner}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); onRemove(row.position); }}
|
||||
aria-label={`Remove ${row.title} from playlist`}
|
||||
class="shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
|
||||
>
|
||||
<X size={16} strokeWidth={1} />
|
||||
</button>
|
||||
{/if}
|
||||
{/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,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;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user