Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1fe3758d5 | ||
|
|
2205b2927b | ||
|
|
3f0540cb7a | ||
|
|
664b129947 | ||
|
|
918db27fec | ||
|
|
aaa6fa15d6 | ||
|
|
b7f50c42a9 | ||
|
|
e16d182417 | ||
|
|
c9fa18ecb7 | ||
|
|
ca9917d8cd | ||
|
|
b1b10b0c77 | ||
|
|
94a9c8cbe3 | ||
|
|
beba5b2082 | ||
|
|
fecd030b68 | ||
|
|
0202c86338 | ||
|
|
0500f6aac5 | ||
|
|
3077ed75fe | ||
|
|
97e84a79eb | ||
|
|
a29ceaaeab | ||
|
|
54b2720d76 | ||
|
|
fe142e3451 | ||
|
|
3bdfa94f39 | ||
|
|
22ea27efff | ||
|
|
09bd71629a | ||
|
|
c83670d216 | ||
|
|
1e9aed214b | ||
|
|
3638d1d822 | ||
|
|
18618bd135 | ||
|
|
d2985f3841 | ||
|
|
f70df9f827 | ||
|
|
439c8625d5 | ||
|
|
1d67c160b2 | ||
|
|
237380b122 | ||
|
|
4f077736b6 | ||
|
|
727f68950e | ||
|
|
aa9f534f3c | ||
|
|
011b4d9a9c | ||
|
|
d5aa081157 | ||
|
|
a99f855e98 | ||
|
|
7e4727fc49 | ||
|
|
1b7fa635d8 | ||
|
|
57d2299180 | ||
|
|
fa7ea41ccf | ||
|
|
324059b2bd | ||
|
|
1138d75a45 |
@@ -295,12 +295,13 @@ jobs:
|
|||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
# What ships to browsers: `dependencies` and the runtime they pull in
|
# The whole tree, build and test tooling included. Until #5021 this
|
||||||
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind,
|
# audited only what ships to browsers (`--omit=dev`), because vite,
|
||||||
# kit's dev server) is left out because none of it reaches a user, and
|
# vitest, tailwind and kit carried advisories that needed major
|
||||||
# its open advisories need major-version upgrades tracked separately.
|
# upgrades. Those upgrades landed and the full tree audits clean, so
|
||||||
- name: npm audit (shipped dependencies)
|
# the tooling that builds the shipped bundle is held to the same bar.
|
||||||
run: npm audit --omit=dev --audit-level=moderate
|
- name: npm audit (all dependencies)
|
||||||
|
run: npm audit --audit-level=moderate
|
||||||
|
|
||||||
- name: Type-check + svelte-check
|
- name: Type-check + svelte-check
|
||||||
run: npm run 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
|
Nothing is published unless every check passes: the Go, integration, web and
|
||||||
Android test suites, `govulncheck` against the toolchain that builds the
|
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`.
|
`.gitea/workflows/release.yml`.
|
||||||
|
|
||||||
## Reporting a problem
|
## 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
|
// and because the destructive /tracks/{id} route above must
|
||||||
// not be mistaken for it (#2527).
|
// not be mistaken for it (#2527).
|
||||||
admin.Get("/library/missing", h.handleListMissingTracks)
|
admin.Get("/library/missing", h.handleListMissingTracks)
|
||||||
|
admin.Get("/library/suspect-sources", h.handleListSuspectSources)
|
||||||
|
|
||||||
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
||||||
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
|
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ func (q *Queries) CountMissingTracks(ctx context.Context) (int64, error) {
|
|||||||
return count, err
|
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
|
const countTracksByAlbum = `-- name: CountTracksByAlbum :one
|
||||||
SELECT count(*) FROM tracks WHERE album_id = $1
|
SELECT count(*) FROM tracks WHERE album_id = $1
|
||||||
`
|
`
|
||||||
@@ -576,6 +590,89 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
return items, nil
|
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
|
const listTrackPathsForReconcile = `-- name: ListTrackPathsForReconcile :many
|
||||||
SELECT id, file_path, missing_since FROM tracks
|
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
|
-- Total for the admin surface's badge and paging. Uses the same partial index
|
||||||
-- (tracks_missing_since_idx) as the list above.
|
-- (tracks_missing_since_idx) as the list above.
|
||||||
SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL;
|
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": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^4.0.0",
|
"@sveltejs/adapter-static": "^4.0.0",
|
||||||
"@sveltejs/kit": "^2.8.0",
|
"@sveltejs/kit": "^3.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"autoprefixer": "^10.5.0",
|
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8.5.15",
|
"svelte": "^5.57.1",
|
||||||
"svelte": "^5.1.9",
|
"svelte-check": "^4.7.5",
|
||||||
"svelte-check": "^4.4.8",
|
"tailwindcss": "^4.3.3",
|
||||||
"tailwindcss": "^3.4.14",
|
"typescript": "^6.0.0",
|
||||||
"typescript": "^5.6.3",
|
"vite": "^8.0.12",
|
||||||
"vite": "^8.0.0",
|
|
||||||
"vitest": "^5.0.0"
|
"vitest": "^5.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neodrag/svelte": "^2.3.3",
|
"@neodrag/svelte": "^2.3.3",
|
||||||
"@tanstack/svelte-query": "^5.90.2",
|
"@tanstack/svelte-query": "^5.90.2",
|
||||||
"lucide-svelte": "^1.0.1"
|
"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;
|
@import 'tailwindcss';
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
/* 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 {
|
:root {
|
||||||
color-scheme: dark;
|
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 { api } from './client';
|
||||||
import { qk } from './queries';
|
import { qk } from './queries';
|
||||||
import type {
|
import type {
|
||||||
ActionResult,
|
ActionResult,
|
||||||
AdminMissingResponse,
|
AdminMissingResponse,
|
||||||
|
AdminSuspectResponse,
|
||||||
AdminDuplicatesResponse,
|
AdminDuplicatesResponse,
|
||||||
MergeDuplicateResult,
|
MergeDuplicateResult,
|
||||||
AdminPlaybackError,
|
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) ----------------------
|
// Missing-file re-acquisition (#2527 / milestone #290) ----------------------
|
||||||
|
|
||||||
export type ReacquisitionSettings = {
|
export type ReacquisitionSettings = {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ describe('api.get/post/del', () => {
|
|||||||
describe('apiFetch 401 interceptor', () => {
|
describe('apiFetch 401 interceptor', () => {
|
||||||
test('401 response triggers auth.logout({silent:true}) and still throws', async () => {
|
test('401 response triggers auth.logout({silent:true}) and still throws', async () => {
|
||||||
const logoutSpy = vi.fn();
|
const logoutSpy = vi.fn();
|
||||||
vi.doMock('$lib/auth/store.svelte', () => ({
|
vi.doMock('#lib/auth/store.svelte.js', () => ({
|
||||||
logout: logoutSpy,
|
logout: logoutSpy,
|
||||||
login: vi.fn(),
|
login: vi.fn(),
|
||||||
bootstrap: vi.fn(),
|
bootstrap: vi.fn(),
|
||||||
@@ -97,6 +97,6 @@ describe('apiFetch 401 interceptor', () => {
|
|||||||
status: 401
|
status: 401
|
||||||
});
|
});
|
||||||
expect(logoutSpy).toHaveBeenCalledWith({ silent: true });
|
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
|
// Lazy import: auth/store imports from this file, so a top-level
|
||||||
// import would be circular. By the time any 401 actually happens
|
// import would be circular. By the time any 401 actually happens
|
||||||
// at runtime, both modules have finished loading.
|
// 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 });
|
await logout({ silent: true });
|
||||||
}
|
}
|
||||||
// Two error envelope shapes ship in this codebase today:
|
// Two error envelope shapes ship in this codebase today:
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const qk = {
|
|||||||
['adminMissingFiles', { offset: offset ?? 0 }] as const,
|
['adminMissingFiles', { offset: offset ?? 0 }] as const,
|
||||||
adminDuplicates: (offset?: number) =>
|
adminDuplicates: (offset?: number) =>
|
||||||
['adminDuplicates', { offset: offset ?? 0 }] as const,
|
['adminDuplicates', { offset: offset ?? 0 }] as const,
|
||||||
|
adminSuspectSources: () => ['adminSuspectSources'] as const,
|
||||||
adminDiagnosticDevices: (userId?: string) =>
|
adminDiagnosticDevices: (userId?: string) =>
|
||||||
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
||||||
smtpConfig: () => ['smtpConfig'] as const,
|
smtpConfig: () => ['smtpConfig'] as const,
|
||||||
|
|||||||
@@ -419,6 +419,37 @@ export type AdminMissingResponse = {
|
|||||||
groups: AdminMissingGroup[];
|
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) -------------------------------------------------
|
// Duplicates report (#3912) -------------------------------------------------
|
||||||
|
|
||||||
// One copy in a proposed duplicate group. like_count and play_count cover every
|
// 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 { api, type User, type LoginResponse } from '#lib/api/client.js';
|
||||||
import { putMyTimezone } from '$lib/api/me';
|
import { putMyTimezone } from '#lib/api/me.js';
|
||||||
import { queryClient } from '$lib/query/client';
|
import { queryClient } from '#lib/query/client.js';
|
||||||
import { signalSessionEnd } from './sessionEnd.svelte';
|
import { signalSessionEnd } from './sessionEnd.svelte';
|
||||||
import { user, setUser } from './user.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.
|
// callers keep working. New code can import directly from auth/user.svelte.
|
||||||
export { user };
|
export { user };
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { apiClientMock } from '../../test-utils/mocks/client';
|
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() }
|
queryClient: { clear: vi.fn() }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '#lib/api/client.js';
|
||||||
import { queryClient } from '$lib/query/client';
|
import { queryClient } from '#lib/query/client.js';
|
||||||
import { bootstrap, login, logout, user } from './store.svelte';
|
import { bootstrap, login, logout, user } from './store.svelte';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// component that needs the current user can import `user` from either
|
// component that needs the current user can import `user` from either
|
||||||
// this module or auth/store — auth/store re-exports it for backward
|
// this module or auth/store — auth/store re-exports it for backward
|
||||||
// compatibility.
|
// compatibility.
|
||||||
import type { User } from '$lib/api/client';
|
import type { User } from '#lib/api/client.js';
|
||||||
|
|
||||||
let _user = $state<User | null>(null);
|
let _user = $state<User | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
updateAcoustIDSettings,
|
updateAcoustIDSettings,
|
||||||
type AcoustIDOverview,
|
type AcoustIDOverview,
|
||||||
type AcoustIDUnsettled
|
type AcoustIDUnsettled
|
||||||
} from '$lib/api/admin';
|
} from '#lib/api/admin.js';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// AcoustID recording-id lookup (M401): fills the MusicBrainz ids that
|
// AcoustID recording-id lookup (M401): fills the MusicBrainz ids that
|
||||||
// untagged tracks lack, so the similarity arms can see them. Off until the
|
// 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 rowClass = 'flex items-center justify-between gap-4 py-2';
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'rounded-md border border-border bg-background px-3 py-1.5 text-sm text-text-primary ' +
|
'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>
|
</script>
|
||||||
|
|
||||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="acoustid-card">
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="acoustid-card">
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
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:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={!dirty || saving || !scoreOk}
|
disabled={!dirty || saving || !scoreOk}
|
||||||
onclick={save}
|
onclick={save}
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md border border-border px-4 py-2 text-sm text-text-primary hover:bg-background
|
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:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={!overview.ready || overview.status.running || starting}
|
disabled={!overview.ready || overview.status.running || starting}
|
||||||
title={overview.ready ? undefined : 'Switch the lookup on and add a key first'}
|
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 { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
|
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(),
|
getAcoustID: vi.fn(),
|
||||||
updateAcoustIDSettings: vi.fn(),
|
updateAcoustIDSettings: vi.fn(),
|
||||||
runAcoustIDLookup: vi.fn(),
|
runAcoustIDLookup: vi.fn(),
|
||||||
listUnsettledAcoustID: 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 AcoustIDCard from "./AcoustIDCard.svelte";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
listUnsettledAcoustID,
|
listUnsettledAcoustID,
|
||||||
runAcoustIDLookup,
|
runAcoustIDLookup,
|
||||||
updateAcoustIDSettings,
|
updateAcoustIDSettings,
|
||||||
} from "$lib/api/admin";
|
} from "#lib/api/admin.js";
|
||||||
|
|
||||||
const base: AcoustIDOverview = {
|
const base: AcoustIDOverview = {
|
||||||
settings: { enabled: true, min_score: 0.85, api_key_set: true },
|
settings: { enabled: true, min_score: 0.85, api_key_set: true },
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
revokeSession,
|
revokeSession,
|
||||||
revokeOtherSessions,
|
revokeOtherSessions,
|
||||||
type ActiveSession
|
type ActiveSession
|
||||||
} from '$lib/api/me';
|
} from '#lib/api/me.js';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// Self-contained: nothing else in the app reads this data, so it holds its
|
// 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.
|
// own state and reloads explicitly rather than joining the query cache.
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
<li class="flex items-start gap-3 py-3">
|
<li class="flex items-start gap-3 py-3">
|
||||||
<MonitorSmartphone
|
<MonitorSmartphone
|
||||||
size={18}
|
size={18}
|
||||||
class="mt-0.5 flex-shrink-0 text-text-secondary"
|
class="mt-0.5 shrink-0 text-text-secondary"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
{#if !s.current}
|
{#if !s.current}
|
||||||
<button
|
<button
|
||||||
type="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
|
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
|
||||||
disabled:opacity-50"
|
disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ const listSessions = vi.fn();
|
|||||||
const revokeSession = vi.fn();
|
const revokeSession = vi.fn();
|
||||||
const revokeOtherSessions = vi.fn();
|
const revokeOtherSessions = vi.fn();
|
||||||
|
|
||||||
vi.mock('$lib/api/me', () => ({
|
vi.mock('#lib/api/me.js', () => ({
|
||||||
listSessions: (...a: unknown[]) => listSessions(...a),
|
listSessions: (...a: unknown[]) => listSessions(...a),
|
||||||
revokeSession: (...a: unknown[]) => revokeSession(...a),
|
revokeSession: (...a: unknown[]) => revokeSession(...a),
|
||||||
revokeOtherSessions: (...a: unknown[]) => revokeOtherSessions(...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 = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Plus } from 'lucide-svelte';
|
import { Plus } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { createPlaylistsQuery, appendTracks, createPlaylist } from '$lib/api/playlists';
|
import { createPlaylistsQuery, appendTracks, createPlaylist } from '#lib/api/playlists.js';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '#lib/api/queries.js';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tracks,
|
tracks,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
import { readable } from 'svelte/store';
|
import { readable } from 'svelte/store';
|
||||||
import { makeTrack } from '$test-utils/fixtures/track';
|
import { makeTrack } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
const playlistsData = vi.hoisted(() => ({
|
const playlistsData = vi.hoisted(() => ({
|
||||||
owned: [
|
owned: [
|
||||||
@@ -43,11 +43,11 @@ const playlistsData = vi.hoisted(() => ({
|
|||||||
public: [] as unknown[]
|
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 } }
|
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/api/playlists', () => ({
|
vi.mock('#lib/api/playlists.js', () => ({
|
||||||
createPlaylistsQuery: () =>
|
createPlaylistsQuery: () =>
|
||||||
readable({ data: playlistsData, isPending: false, isError: false }),
|
readable({ data: playlistsData, isPending: false, isError: false }),
|
||||||
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
|
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
|
||||||
@@ -86,7 +86,7 @@ describe('AddToPlaylistMenu', () => {
|
|||||||
|
|
||||||
test('clicking a playlist appends and closes', async () => {
|
test('clicking a playlist appends and closes', async () => {
|
||||||
const onClose = vi.fn();
|
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 } });
|
render(AddToPlaylistMenu, { props: { tracks: [track], onClose } });
|
||||||
await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
|
await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
|
||||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||||
{ href: '/admin/missing-files', label: 'Missing files' },
|
{ href: '/admin/missing-files', label: 'Missing files' },
|
||||||
{ href: '/admin/duplicates', label: 'Duplicates' },
|
{ href: '/admin/duplicates', label: 'Duplicates' },
|
||||||
|
{ href: '/admin/suspect-sources', label: 'Suspect sources' },
|
||||||
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
||||||
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
||||||
{ href: '/admin/tuning', label: 'Tuning' },
|
{ href: '/admin/tuning', label: 'Tuning' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
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(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
pageUrl: new URL('http://localhost/admin')
|
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');
|
state.pageUrl = new URL('http://localhost/admin');
|
||||||
render(AdminTabs);
|
render(AdminTabs);
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
@@ -67,6 +67,9 @@ describe('AdminTabs', () => {
|
|||||||
// Duplicates follows Missing files: both are library-health reports on
|
// Duplicates follows Missing files: both are library-health reports on
|
||||||
// what the library holds, rather than a queue of user reports.
|
// what the library holds, rather than a queue of user reports.
|
||||||
'Duplicates',
|
'Duplicates',
|
||||||
|
// Suspect sources is the third library-health report: files whose
|
||||||
|
// names say they were ripped from a video.
|
||||||
|
'Suspect sources',
|
||||||
'Playback errors',
|
'Playback errors',
|
||||||
'Diagnostics',
|
'Diagnostics',
|
||||||
'Tuning',
|
'Tuning',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
import type { AlbumRef, AlbumDetail } from '#lib/api/types.js';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '#lib/api/client.js';
|
||||||
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
import { enqueueTracks, playQueue } from '#lib/player/store.svelte.js';
|
||||||
import { Play } from 'lucide-svelte';
|
import { Play } from 'lucide-svelte';
|
||||||
import AlbumMenu from './AlbumMenu.svelte';
|
import AlbumMenu from './AlbumMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import CardActionCluster from './CardActionCluster.svelte';
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="art-wrap relative aspect-square w-full overflow-hidden rounded-md
|
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"
|
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
||||||
>
|
>
|
||||||
<Cover
|
<Cover
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types';
|
import type { AlbumRef, AlbumDetail, TrackRef } from '#lib/api/types.js';
|
||||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
import { FALLBACK_COVER } from '#lib/media/covers.js';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { apiClientMock } from '../../test-utils/mocks/client';
|
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/api/client.js', () => apiClientMock());
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
enqueueTracks: vi.fn(),
|
enqueueTracks: vi.fn(),
|
||||||
playQueue: 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('$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>;
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
|
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 { readable } = await import('svelte/store');
|
||||||
const actual = (await orig()) as Record<string, unknown>;
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
@@ -34,8 +34,8 @@ vi.mock('$lib/api/playlists', async (orig) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import AlbumCard from './AlbumCard.svelte';
|
import AlbumCard from './AlbumCard.svelte';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '#lib/api/client.js';
|
||||||
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
import { enqueueTracks, playQueue } from '#lib/player/store.svelte.js';
|
||||||
|
|
||||||
const album: AlbumRef = {
|
const album: AlbumRef = {
|
||||||
id: 'xyz',
|
id: 'xyz',
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
import { MoreVertical, Heart, HeartOff, ListMusic, Plus, Disc3 } from 'lucide-svelte';
|
import { MoreVertical, Heart, HeartOff, ListMusic, Plus, Disc3 } from 'lucide-svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
|
||||||
import { listAlbumTracks } from '$lib/api/albums';
|
import { listAlbumTracks } from '#lib/api/albums.js';
|
||||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
import { enqueueTracks } from '#lib/player/store.svelte.js';
|
||||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
albumId,
|
albumId,
|
||||||
|
|||||||
@@ -2,28 +2,28 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
import { makeTracks } from '$test-utils/fixtures/track';
|
import { makeTracks } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
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>;
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
|
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()
|
enqueueTracks: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import AlbumMenu from './AlbumMenu.svelte';
|
import AlbumMenu from './AlbumMenu.svelte';
|
||||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
import { enqueueTracks } from '#lib/player/store.svelte.js';
|
||||||
import { listAlbumTracks } from '$lib/api/albums';
|
import { listAlbumTracks } from '#lib/api/albums.js';
|
||||||
import { likeEntity } from '$lib/api/likes';
|
import { likeEntity } from '#lib/api/likes.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
const TRACKS: TrackRef[] = makeTracks(2, {
|
const TRACKS: TrackRef[] = makeTracks(2, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ApiError } from '$lib/api/client';
|
import type { ApiError } from '#lib/api/client.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
import { shuffled } from '#lib/utils/shuffle.js';
|
||||||
import { api } from '$lib/api/client';
|
import type { ArtistRef, TrackRef } from '#lib/api/types.js';
|
||||||
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
|
import { api } from '#lib/api/client.js';
|
||||||
|
import { playQueue, enqueueTracks } from '#lib/player/store.svelte.js';
|
||||||
import { Play } from 'lucide-svelte';
|
import { Play } from 'lucide-svelte';
|
||||||
import ArtistMenu from './ArtistMenu.svelte';
|
import ArtistMenu from './ArtistMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import CardActionCluster from './CardActionCluster.svelte';
|
||||||
import Cover from './Cover.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 { artist }: { artist: ArtistRef } = $props();
|
||||||
|
|
||||||
let queueing = $state(false);
|
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) {
|
async function onPlayClick(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`);
|
const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`);
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
playQueue(shuffle(tracks), 0);
|
playQueue(shuffled(tracks), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAddToQueue(e: MouseEvent) {
|
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 { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { apiClientMock } from '../../test-utils/mocks/client';
|
import { apiClientMock } from '../../test-utils/mocks/client';
|
||||||
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
import type { ArtistRef, TrackRef } from '#lib/api/types.js';
|
||||||
import { makeTrack, makeTracks } from '$test-utils/fixtures/track';
|
import { makeTrack, makeTracks } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
vi.mock('$lib/api/client', () => apiClientMock());
|
vi.mock('#lib/api/client.js', () => apiClientMock());
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
playQueue: vi.fn(),
|
playQueue: vi.fn(),
|
||||||
enqueueTracks: vi.fn()
|
enqueueTracks: vi.fn()
|
||||||
}));
|
}));
|
||||||
vi.mock('$lib/api/artists', () => ({
|
vi.mock('#lib/api/artists.js', () => ({
|
||||||
listArtistTracks: vi.fn().mockResolvedValue([])
|
listArtistTracks: vi.fn().mockResolvedValue([])
|
||||||
}));
|
}));
|
||||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
|
||||||
import ArtistCard from './ArtistCard.svelte';
|
import ArtistCard from './ArtistCard.svelte';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '#lib/api/client.js';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '#lib/player/store.svelte.js';
|
||||||
|
|
||||||
const artist: ArtistRef = {
|
const artist: ArtistRef = {
|
||||||
id: 'art-1',
|
id: 'art-1',
|
||||||
@@ -82,7 +82,7 @@ describe('ArtistCard', () => {
|
|||||||
const fakeTracks = [
|
const fakeTracks = [
|
||||||
makeTrack({ artist_id: 'art-1', artist_name: 'BoC' })
|
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);
|
(listArtistTracks as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fakeTracks);
|
||||||
render(ArtistCard, { props: { artist } });
|
render(ArtistCard, { props: { artist } });
|
||||||
const btn = screen.getByRole('button', { name: /Add Boards of Canada to queue/i });
|
const btn = screen.getByRole('button', { name: /Add Boards of Canada to queue/i });
|
||||||
@@ -90,7 +90,7 @@ describe('ArtistCard', () => {
|
|||||||
// Allow promise microtask to flush.
|
// Allow promise microtask to flush.
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
expect(listArtistTracks).toHaveBeenCalledWith('art-1');
|
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);
|
expect(enqueueTracks).toHaveBeenCalledWith(fakeTracks);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { MoreVertical, Heart, HeartOff, ListMusic, Plus } from 'lucide-svelte';
|
import { MoreVertical, Heart, HeartOff, ListMusic, Plus } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
|
||||||
import { listArtistTracks } from '$lib/api/artists';
|
import { listArtistTracks } from '#lib/api/artists.js';
|
||||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
import { enqueueTracks } from '#lib/player/store.svelte.js';
|
||||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
artistId,
|
artistId,
|
||||||
|
|||||||
@@ -2,27 +2,27 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
import { makeTracks } from '$test-utils/fixtures/track';
|
import { makeTracks } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
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([])
|
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()
|
enqueueTracks: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import ArtistMenu from './ArtistMenu.svelte';
|
import ArtistMenu from './ArtistMenu.svelte';
|
||||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
import { enqueueTracks } from '#lib/player/store.svelte.js';
|
||||||
import { listArtistTracks } from '$lib/api/artists';
|
import { listArtistTracks } from '#lib/api/artists.js';
|
||||||
import { likeEntity, unlikeEntity } from '$lib/api/likes';
|
import { likeEntity, unlikeEntity } from '#lib/api/likes.js';
|
||||||
|
|
||||||
const TRACKS: TrackRef[] = makeTracks(2, {
|
const TRACKS: TrackRef[] = makeTracks(2, {
|
||||||
album_id: 'al1', artist_id: 'a1', artist_name: 'Test'
|
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">
|
<script lang="ts">
|
||||||
import { Plus } from 'lucide-svelte';
|
import { Plus } from 'lucide-svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
import { playQueue, enqueueTrack } from '#lib/player/store.svelte.js';
|
||||||
import { coverUrl } from '$lib/media/covers';
|
import { coverUrl } from '#lib/media/covers.js';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
import Cover from './Cover.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
|
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"
|
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} />
|
<Cover src={cover} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
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 { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
||||||
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
|
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 }; } }
|
user: { get value() { return { id: 'u1', username: 'u', is_admin: false }; } }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
playQueue: vi.fn(),
|
playQueue: vi.fn(),
|
||||||
enqueueTrack: vi.fn(),
|
enqueueTrack: vi.fn(),
|
||||||
playNext: vi.fn()
|
playNext: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import CompactTrackCard from './CompactTrackCard.svelte';
|
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 titles = ['First', 'Second', 'Third'];
|
||||||
const tracks: TrackRef[] = makeTracks(3, { artist_name: 'Artist' }).map((t, i) => ({
|
const tracks: TrackRef[] = makeTracks(3, { artist_name: 'Artist' }).map((t, i) => ({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
import { FALLBACK_COVER } from '#lib/media/covers.js';
|
||||||
import { Disc3 } from 'lucide-svelte';
|
import { Disc3 } from 'lucide-svelte';
|
||||||
|
|
||||||
// Shared cover-artwork renderer. The web counterpart of Android's
|
// Shared cover-artwork renderer. The web counterpart of Android's
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Bring ${title} back`}
|
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?.()}
|
onclick={() => onUnsnooze?.()}
|
||||||
>
|
>
|
||||||
Undo
|
Undo
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`Not right now — hide ${title} for a while`}
|
aria-label={`Not right now — hide ${title} for a while`}
|
||||||
title="Not right now"
|
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?.()}
|
onclick={() => onSnooze?.()}
|
||||||
>
|
>
|
||||||
<Clock size={16} strokeWidth={1} />
|
<Clock size={16} strokeWidth={1} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
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(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
pageUrl: new URL('http://localhost/discover')
|
pageUrl: new URL('http://localhost/discover')
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
getFingerprintSettings,
|
getFingerprintSettings,
|
||||||
updateFingerprintSettings,
|
updateFingerprintSettings,
|
||||||
type FingerprintSettings
|
type FingerprintSettings
|
||||||
} from '$lib/api/admin';
|
} from '#lib/api/admin.js';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// How Minstrel fingerprints tracks and when it looks for duplicates (#3913).
|
// How Minstrel fingerprints tracks and when it looks for duplicates (#3913).
|
||||||
// Lives on the Duplicates page, beside the results these settings shape.
|
// Lives on the Duplicates page, beside the results these settings shape.
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
|
'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>
|
</script>
|
||||||
|
|
||||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
<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"
|
class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary"
|
||||||
data-testid="length-warning"
|
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>
|
<span>
|
||||||
Saving re-fingerprints {libraryTotal > 0
|
Saving re-fingerprints {libraryTotal > 0
|
||||||
? `all ${libraryTotal.toLocaleString()} tracks`
|
? `all ${libraryTotal.toLocaleString()} tracks`
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
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:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={!dirty || saving || problems.length > 0}
|
disabled={!dirty || saving || problems.length > 0}
|
||||||
onclick={save}
|
onclick={save}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
import type { FingerprintSettings } from '$lib/api/admin';
|
import type { FingerprintSettings } from '#lib/api/admin.js';
|
||||||
import { ERROR_COPY } from '$lib/api/error-copy';
|
import { ERROR_COPY } from '#lib/api/error-copy.js';
|
||||||
|
|
||||||
vi.mock('$lib/api/admin', () => ({
|
vi.mock('#lib/api/admin.js', () => ({
|
||||||
getFingerprintSettings: vi.fn(),
|
getFingerprintSettings: vi.fn(),
|
||||||
updateFingerprintSettings: 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 FingerprintSettingsCard from './FingerprintSettingsCard.svelte';
|
||||||
import { getFingerprintSettings, updateFingerprintSettings } from '$lib/api/admin';
|
import { getFingerprintSettings, updateFingerprintSettings } from '#lib/api/admin.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
const base: FingerprintSettings = {
|
const base: FingerprintSettings = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { Flag } from 'lucide-svelte';
|
import { Flag } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { flagTrack } from '$lib/api/quarantine';
|
import { flagTrack } from '#lib/api/quarantine.js';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '#lib/api/errors.js';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '#lib/api/queries.js';
|
||||||
import type { TrackRef, LidarrQuarantineReason } from '$lib/api/types';
|
import type { TrackRef, LidarrQuarantineReason } from '#lib/api/types.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
track,
|
track,
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<span class="block text-xs text-text-secondary">Reason</span>
|
<span class="block text-xs text-text-secondary">Reason</span>
|
||||||
<select
|
<select
|
||||||
bind:value={reason}
|
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="bad_rip">Bad rip</option>
|
||||||
<option value="wrong_file">Wrong file</option>
|
<option value="wrong_file">Wrong file</option>
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
bind:value={notes}
|
bind:value={notes}
|
||||||
maxlength="200"
|
maxlength="200"
|
||||||
placeholder="What's wrong with it?"
|
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"
|
rows="2"
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import FlagPopover from './FlagPopover.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();
|
const invalidateMock = vi.fn();
|
||||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
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' })
|
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();
|
const track = makeTrack();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HistoryEvent } from '$lib/api/history';
|
import type { HistoryEvent } from '#lib/api/history.js';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '#lib/player/store.svelte.js';
|
||||||
import { coverUrl } from '$lib/media/covers';
|
import { coverUrl } from '#lib/media/covers.js';
|
||||||
|
import TrackListRow from './TrackListRow.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
|
|
||||||
let { event } = $props<{ event: HistoryEvent }>();
|
let { event } = $props<{ event: HistoryEvent }>();
|
||||||
|
|
||||||
@@ -40,25 +42,23 @@
|
|||||||
const tooltip = $derived(new Date(event.played_at).toISOString());
|
const tooltip = $derived(new Date(event.played_at).toISOString());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<TrackListRow
|
||||||
type="button"
|
title={event.track.title}
|
||||||
onclick={handleClick}
|
subtitle={`${event.track.artist_name} · ${event.track.album_title}`}
|
||||||
aria-label={`Play ${event.track.title}`}
|
durationSec={event.track.duration_sec}
|
||||||
class="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-surface-hover"
|
track={event.track}
|
||||||
|
ariaLabel={`Play ${event.track.title}`}
|
||||||
|
onActivate={handleClick}
|
||||||
>
|
>
|
||||||
<img
|
{#snippet lead()}
|
||||||
src={coverUrl(event.track.album_id)}
|
<span class="block h-8 w-8 overflow-hidden rounded">
|
||||||
alt=""
|
<Cover src={coverUrl(event.track.album_id)} />
|
||||||
class="h-11 w-11 flex-shrink-0 rounded object-cover"
|
</span>
|
||||||
loading="lazy"
|
{/snippet}
|
||||||
/>
|
|
||||||
<div class="min-w-0 flex-1">
|
{#snippet trailing()}
|
||||||
<div class="truncate text-sm font-medium text-text-primary">{event.track.title}</div>
|
<span title={tooltip} class="w-16 shrink-0 text-right text-xs text-text-secondary">
|
||||||
<div class="truncate text-xs text-text-secondary">
|
{timestamp}
|
||||||
{event.track.artist_name} · {event.track.album_title}
|
</span>
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
</TrackListRow>
|
||||||
<span title={tooltip} class="flex-shrink-0 text-xs text-text-secondary">
|
|
||||||
{timestamp}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import HistoryRow from './HistoryRow.svelte';
|
import type { HistoryEvent } from '#lib/api/history.js';
|
||||||
import type { HistoryEvent } from '$lib/api/history';
|
import { makeTrack } from '#test-utils/fixtures/track.js';
|
||||||
import { makeTrack } from '$test-utils/fixtures/track';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
|
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
|
||||||
|
|
||||||
const playQueue = vi.fn();
|
const playQueue = vi.fn();
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
// The row is a TrackListRow, so LikeButton and TrackMenu render too.
|
||||||
playQueue: (...args: unknown[]) => playQueue(...args)
|
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({
|
const sampleTrack = makeTrack({
|
||||||
title: 'Song Title',
|
title: 'Song Title',
|
||||||
album_title: 'Album',
|
album_title: 'Album',
|
||||||
@@ -41,6 +61,13 @@ describe('HistoryRow', () => {
|
|||||||
expect(playQueue).toHaveBeenCalledWith([sampleTrack], 0);
|
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', () => {
|
it('renders a relative timestamp for events under 1 hour ago', () => {
|
||||||
render(HistoryRow, { props: { event: sampleEvent } });
|
render(HistoryRow, { props: { event: sampleEvent } });
|
||||||
expect(screen.getByText(/\d+m ago/)).toBeInTheDocument();
|
expect(screen.getByText(/\d+m ago/)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -62,14 +62,8 @@
|
|||||||
<div class="section" aria-label={ariaLabel}>
|
<div class="section" aria-label={ariaLabel}>
|
||||||
<header class="section-header">
|
<header class="section-header">
|
||||||
{#if title}
|
{#if title}
|
||||||
<!-- Section header: Fraunces (font-display) at 24px with an
|
<!-- Section header: Fraunces (font-display) at 24px. -->
|
||||||
inline accent rule that bleeds out from the title — gives
|
<h2 class="font-display text-2xl font-medium text-text-primary">{title}</h2>
|
||||||
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>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span></span>
|
<span></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
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 {
|
let {
|
||||||
entityType,
|
entityType,
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ const cacheState = vi.hoisted(() => ({
|
|||||||
data: { track_ids: ['t1'] as string[], album_ids: [] as string[], artist_ids: [] as string[] }
|
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 }),
|
createLikedIdsQuery: () => readable({ data: cacheState.data, isPending: false, isError: false }),
|
||||||
likeEntity: vi.fn().mockResolvedValue(undefined),
|
likeEntity: vi.fn().mockResolvedValue(undefined),
|
||||||
unlikeEntity: vi.fn().mockResolvedValue(undefined)
|
unlikeEntity: vi.fn().mockResolvedValue(undefined)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
import { likeEntity, unlikeEntity } from '$lib/api/likes';
|
import { likeEntity, unlikeEntity } from '#lib/api/likes.js';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
updateLoudnessSettings,
|
updateLoudnessSettings,
|
||||||
type LoudnessCoverage,
|
type LoudnessCoverage,
|
||||||
type LoudnessSettings
|
type LoudnessSettings
|
||||||
} from '$lib/api/admin';
|
} from '#lib/api/admin.js';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// Loudness analysis (#4995): the background measurement that loudness
|
// Loudness analysis (#4995): the background measurement that loudness
|
||||||
// normalization levels playback from, its progress, and its knobs. The
|
// normalization levels playback from, its progress, and its knobs. The
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
|
'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>
|
</script>
|
||||||
|
|
||||||
<section class="space-y-4 rounded border border-border bg-surface p-4">
|
<section class="space-y-4 rounded border border-border bg-surface p-4">
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
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:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={!dirty || saving || !valid}
|
disabled={!dirty || saving || !valid}
|
||||||
onclick={save}
|
onclick={save}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
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(),
|
getLoudnessSettings: vi.fn(),
|
||||||
updateLoudnessSettings: vi.fn(),
|
updateLoudnessSettings: vi.fn(),
|
||||||
getLoudnessCoverage: 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 LoudnessSettingsCard from './LoudnessSettingsCard.svelte';
|
||||||
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin';
|
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '#lib/api/admin.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 };
|
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 };
|
||||||
const coverage: LoudnessCoverage = {
|
const coverage: LoudnessCoverage = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Smartphone } from 'lucide-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
|
// Polls /api/client/version once on mount. Renders nothing on 404
|
||||||
// (no bundled APK — graceful degradation in dev / pre-CI images)
|
// (no bundled APK — graceful degradation in dev / pre-CI images)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
getNetworkSettings,
|
getNetworkSettings,
|
||||||
updateNetworkSettings,
|
updateNetworkSettings,
|
||||||
type NetworkSettings
|
type NetworkSettings
|
||||||
} from '$lib/api/admin';
|
} from '#lib/api/admin.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
let settings = $state<NetworkSettings | null>(null);
|
let settings = $state<NetworkSettings | null>(null);
|
||||||
let hops = $state(1);
|
let hops = $state(1);
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
max={settings.max_hops}
|
max={settings.max_hops}
|
||||||
bind:value={hops}
|
bind:value={hops}
|
||||||
class="w-24 rounded border border-border bg-background px-2 py-1
|
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>
|
</label>
|
||||||
<button
|
<button
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
|
|
||||||
<div class="space-y-2 rounded border border-border bg-background p-3 text-sm">
|
<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">
|
<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>
|
<span>
|
||||||
Count your proxies — don't guess high. This number tells Minstrel how much of the
|
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
|
<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 getNetworkSettings = vi.fn();
|
||||||
const updateNetworkSettings = vi.fn();
|
const updateNetworkSettings = vi.fn();
|
||||||
|
|
||||||
vi.mock('$lib/api/admin', () => ({
|
vi.mock('#lib/api/admin.js', () => ({
|
||||||
getNetworkSettings: () => getNetworkSettings(),
|
getNetworkSettings: () => getNetworkSettings(),
|
||||||
updateNetworkSettings: (hops: number) => updateNetworkSettings(hops)
|
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> = {}) {
|
function settings(over: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
NORMALIZATION_MODES,
|
NORMALIZATION_MODES,
|
||||||
NORMALIZATION_TARGETS,
|
NORMALIZATION_TARGETS,
|
||||||
type NormalizationPrefs
|
type NormalizationPrefs
|
||||||
} from '$lib/api/normalization';
|
} from '#lib/api/normalization.js';
|
||||||
import {
|
import {
|
||||||
normalization,
|
normalization,
|
||||||
loadNormalization,
|
loadNormalization,
|
||||||
setNormalization
|
setNormalization
|
||||||
} from '$lib/stores/normalization.svelte';
|
} from '#lib/stores/normalization.svelte.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// Volume leveling (M464 #4998). The choice is the user's, stored on the
|
// 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.
|
// 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 { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
|
|
||||||
vi.mock('$lib/api/normalization', async (importOriginal) => ({
|
vi.mock('#lib/api/normalization.js', async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import('$lib/api/normalization')>()),
|
...(await importOriginal<typeof import('#lib/api/normalization.js')>()),
|
||||||
getNormalization: vi.fn(),
|
getNormalization: vi.fn(),
|
||||||
putNormalization: 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 NormalizationSetting from './NormalizationSetting.svelte';
|
||||||
import { getNormalization, putNormalization } from '$lib/api/normalization';
|
import { getNormalization, putNormalization } from '#lib/api/normalization.js';
|
||||||
import { normalization } from '$lib/stores/normalization.svelte';
|
import { normalization } from '#lib/stores/normalization.svelte.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
markAllNotificationsRead,
|
markAllNotificationsRead,
|
||||||
markNotificationRead,
|
markNotificationRead,
|
||||||
type NotificationItem
|
type NotificationItem
|
||||||
} from '$lib/api/notifications';
|
} from '#lib/api/notifications.js';
|
||||||
import { relativeTime } from '$lib/utils/relativeTime';
|
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// The notifications inbox (M489): a bell in the header with an unread badge,
|
// The notifications inbox (M489): a bell in the header with an unread badge,
|
||||||
// opening a panel of what happened while you were away.
|
// 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)} />
|
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (open = false)} />
|
||||||
|
|
||||||
<div class="relative flex-shrink-0">
|
<div class="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
bind:this={buttonRef}
|
bind:this={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<div
|
||||||
bind:this={panelRef}
|
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"
|
role="dialog"
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
>
|
>
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
onclick={() => openItem(item)}
|
onclick={() => openItem(item)}
|
||||||
>
|
>
|
||||||
<span
|
<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"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
|
|||||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
|
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>;
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
badgeLabel: actual.badgeLabel,
|
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 NotificationBell from './NotificationBell.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
markAllNotificationsRead,
|
markAllNotificationsRead,
|
||||||
markNotificationRead,
|
markNotificationRead,
|
||||||
type NotificationItem
|
type NotificationItem
|
||||||
} from '$lib/api/notifications';
|
} from '#lib/api/notifications.js';
|
||||||
|
|
||||||
function item(over: Partial<NotificationItem> = {}): NotificationItem {
|
function item(over: Partial<NotificationItem> = {}): NotificationItem {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
getNotificationEmailSettings,
|
getNotificationEmailSettings,
|
||||||
updateNotificationEmailSettings,
|
updateNotificationEmailSettings,
|
||||||
type NotificationEmailSettings
|
type NotificationEmailSettings
|
||||||
} from '$lib/api/admin';
|
} from '#lib/api/admin.js';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// When notification emails go out (M489 #5346). Nothing is emailed per
|
// When notification emails go out (M489 #5346). Nothing is emailed per
|
||||||
// event: new music is a daily summary, everything else a batch.
|
// event: new music is a daily summary, everything else a batch.
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<select
|
<select
|
||||||
bind:value={form.summary_hour}
|
bind:value={form.summary_hour}
|
||||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
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"
|
focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
{#each hours as h (h)}
|
{#each hours as h (h)}
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
max="1440"
|
max="1440"
|
||||||
bind:value={form.batch_window_minutes}
|
bind:value={form.batch_window_minutes}
|
||||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
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"
|
focus-visible:outline-accent"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
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:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={!dirty || saving}
|
disabled={!dirty || saving}
|
||||||
onclick={save}
|
onclick={save}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
|
|
||||||
vi.mock('$lib/api/admin', () => ({
|
vi.mock('#lib/api/admin.js', () => ({
|
||||||
getNotificationEmailSettings: vi.fn(),
|
getNotificationEmailSettings: vi.fn(),
|
||||||
updateNotificationEmailSettings: 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 NotificationEmailCard from './NotificationEmailCard.svelte';
|
||||||
import { getNotificationEmailSettings, updateNotificationEmailSettings } from '$lib/api/admin';
|
import { getNotificationEmailSettings, updateNotificationEmailSettings } from '#lib/api/admin.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
afterEach(() => vi.clearAllMocks());
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
type NotificationKindSetting,
|
type NotificationKindSetting,
|
||||||
type NotificationSettingChange,
|
type NotificationSettingChange,
|
||||||
type NotificationSettings
|
type NotificationSettings
|
||||||
} from '$lib/api/notifications';
|
} from '#lib/api/notifications.js';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '#lib/api/queries.js';
|
||||||
import { user } from '$lib/auth/store.svelte';
|
import { user } from '#lib/auth/store.svelte.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// Per-user notification settings (M489): one row per kind, a toggle per
|
// Per-user notification settings (M489): one row per kind, a toggle per
|
||||||
// channel. Toggles are optimistic; a failed save puts the switch back.
|
// 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() }) };
|
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>;
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
@@ -18,12 +18,12 @@ vi.mock('$lib/api/notifications', async (orig) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userState = vi.hoisted(() => ({ current: { id: '1', username: 'alice', is_admin: false } }));
|
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; } }
|
user: { get value() { return userState.current; } }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const pushToast = vi.fn();
|
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 NotificationSettings from './NotificationSettings.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
putNotificationSettings,
|
putNotificationSettings,
|
||||||
type NotificationKindSetting,
|
type NotificationKindSetting,
|
||||||
type NotificationSettings as Settings
|
type NotificationSettings as Settings
|
||||||
} from '$lib/api/notifications';
|
} from '#lib/api/notifications.js';
|
||||||
|
|
||||||
const requesterKinds: NotificationKindSetting[] = [
|
const requesterKinds: NotificationKindSetting[] = [
|
||||||
{ kind: 'request_approved', admin_only: false, inbox: true, phone: true, email: true },
|
{ 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,
|
togglePlay, skipNext, skipPrev, seekTo, setVolume,
|
||||||
toggleShuffle, cycleRepeat, playQueue,
|
toggleShuffle, cycleRepeat, playQueue,
|
||||||
toggleQueueDrawer
|
toggleQueueDrawer
|
||||||
} from '$lib/player/store.svelte';
|
} from '#lib/player/store.svelte.js';
|
||||||
import { formatDuration } from '$lib/media/duration';
|
import { formatDuration } from '#lib/media/duration.js';
|
||||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
import { FALLBACK_COVER, coverUrl } from '#lib/media/covers.js';
|
||||||
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
|
import { dominantColorFromUrl, rgbToCssString } from '#lib/media/dominantColor.js';
|
||||||
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
import { useSmoothPosition } from '#lib/player/smoothPosition.svelte.js';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
<a
|
<a
|
||||||
href="/now-playing"
|
href="/now-playing"
|
||||||
aria-label="Open 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%;"
|
style="flex: 1 1 35%;"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={player.isPlaying ? 'Pause' : 'Play'}
|
aria-label={player.isPlaying ? 'Pause' : 'Play'}
|
||||||
onclick={togglePlay}
|
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'}
|
{#if player.state === 'loading' || player.state === 'idle'}
|
||||||
<Loader2 data-testid="play-spinner" size={22} strokeWidth={1.5} class="animate-spin" />
|
<Loader2 data-testid="play-spinner" size={22} strokeWidth={1.5} class="animate-spin" />
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
<a
|
<a
|
||||||
href="/now-playing"
|
href="/now-playing"
|
||||||
aria-label="Open 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
|
<img
|
||||||
src={coverUrl(current.album_id)}
|
src={coverUrl(current.album_id)}
|
||||||
@@ -341,7 +341,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={player.isPlaying ? 'Pause' : 'Play'}
|
aria-label={player.isPlaying ? 'Pause' : 'Play'}
|
||||||
onclick={togglePlay}
|
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'}
|
{#if player.state === 'loading' || player.state === 'idle'}
|
||||||
<Loader2 size={24} strokeWidth={1.5} class="animate-spin" />
|
<Loader2 size={24} strokeWidth={1.5} class="animate-spin" />
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, within } from '@testing-library/svelte';
|
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 { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
|
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.
|
// Mutable state the mocked store reads from.
|
||||||
const state = vi.hoisted(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
@@ -20,7 +20,7 @@ const state = vi.hoisted(() => ({
|
|||||||
queueDrawerOpen: false
|
queueDrawerOpen: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
player: {
|
player: {
|
||||||
get queue() { return state.queue; },
|
get queue() { return state.queue; },
|
||||||
get index() { return state.index; },
|
get index() { return state.index; },
|
||||||
@@ -46,16 +46,16 @@ vi.mock('$lib/player/store.svelte', () => ({
|
|||||||
toggleQueueDrawer: vi.fn()
|
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 PlayerBar from './PlayerBar.svelte';
|
||||||
import {
|
import {
|
||||||
togglePlay, skipNext, skipPrev, seekTo, setVolume,
|
togglePlay, skipNext, skipPrev, seekTo, setVolume,
|
||||||
toggleShuffle, cycleRepeat, playQueue,
|
toggleShuffle, cycleRepeat, playQueue,
|
||||||
toggleQueueDrawer
|
toggleQueueDrawer
|
||||||
} from '$lib/player/store.svelte';
|
} from '#lib/player/store.svelte.js';
|
||||||
|
|
||||||
function track(): TrackRef {
|
function track(): TrackRef {
|
||||||
return makeTrack({
|
return makeTrack({
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Play, MoreVertical, RefreshCcw } from 'lucide-svelte';
|
import { Play, MoreVertical, RefreshCcw } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import type { Playlist, PlaylistTrack, TrackRef } from '$lib/api/types';
|
import type { Playlist, PlaylistTrack, TrackRef } from '#lib/api/types.js';
|
||||||
import { user } from '$lib/auth/store.svelte';
|
import { user } from '#lib/auth/store.svelte.js';
|
||||||
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
|
import { getPlaylist, systemShuffle, refreshSystem } from '#lib/api/playlists.js';
|
||||||
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
|
import { playlistTrackToRef } from '#lib/playlists/playlistTrackToRef.js';
|
||||||
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
|
import { systemPlaylistRefetch } from '#lib/playlists/systemRefetch.js';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '#lib/api/errors.js';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '#lib/api/queries.js';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '#lib/player/store.svelte.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
let { playlist }: { playlist: Playlist } = $props();
|
let { playlist }: { playlist: Playlist } = $props();
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="art-wrap relative aspect-square w-full overflow-hidden rounded-md
|
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"
|
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
||||||
>
|
>
|
||||||
{#if playlist.cover_url}
|
{#if playlist.cover_url}
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
is often a 2x2 collage; the gradient mutes the collage so
|
is often a 2x2 collage; the gradient mutes the collage so
|
||||||
the playlist NAME becomes the dominant element. -->
|
the playlist NAME becomes the dominant element. -->
|
||||||
<div
|
<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"
|
from-black/85 via-black/40 to-transparent"
|
||||||
></div>
|
></div>
|
||||||
<!-- Title burned in at the bottom of the cover. Two lines max;
|
<!-- 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 { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
import PlaylistCard from './PlaylistCard.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 } }
|
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({
|
getPlaylist: vi.fn().mockResolvedValue({
|
||||||
id: 'p-1',
|
id: 'p-1',
|
||||||
user_id: 'u-self',
|
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'] })
|
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()
|
playQueue: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/api/queries', () => ({
|
vi.mock('#lib/api/queries.js', () => ({
|
||||||
qk: {
|
qk: {
|
||||||
playlist: (id: string) => ['playlist', id],
|
playlist: (id: string) => ['playlist', id],
|
||||||
playlists: (kind?: string) => ['playlists', { kind: kind ?? 'user' }]
|
playlists: (kind?: string) => ['playlists', { kind: kind ?? 'user' }]
|
||||||
@@ -162,8 +162,8 @@ describe('PlaylistCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('play button calls playQueue with tracks when clicked', async () => {
|
test('play button calls playQueue with tracks when clicked', async () => {
|
||||||
const { getPlaylist } = await import('$lib/api/playlists');
|
const { getPlaylist } = await import('#lib/api/playlists.js');
|
||||||
const { playQueue } = await import('$lib/player/store.svelte');
|
const { playQueue } = await import('#lib/player/store.svelte.js');
|
||||||
|
|
||||||
render(PlaylistCard, { props: { playlist: base } });
|
render(PlaylistCard, { props: { playlist: base } });
|
||||||
const playButton = screen.getByLabelText(/Play Saturday morning/i);
|
const playButton = screen.getByLabelText(/Play Saturday morning/i);
|
||||||
@@ -222,7 +222,7 @@ describe('PlaylistCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('clicking Refresh calls refreshSystem with the raw variant', async () => {
|
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 = {
|
const discoverPlaylist: Playlist = {
|
||||||
...base,
|
...base,
|
||||||
kind: 'system',
|
kind: 'system',
|
||||||
@@ -237,8 +237,8 @@ describe('PlaylistCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('For-You play calls systemShuffle, not getPlaylist', async () => {
|
test('For-You play calls systemShuffle, not getPlaylist', async () => {
|
||||||
const { getPlaylist, systemShuffle } = await import('$lib/api/playlists');
|
const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js');
|
||||||
const { playQueue } = await import('$lib/player/store.svelte');
|
const { playQueue } = await import('#lib/player/store.svelte.js');
|
||||||
const forYouPlaylist: Playlist = {
|
const forYouPlaylist: Playlist = {
|
||||||
...base,
|
...base,
|
||||||
kind: 'system',
|
kind: 'system',
|
||||||
@@ -261,7 +261,7 @@ describe('PlaylistCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('For-You play passes source:for_you to playQueue (no client shuffle)', async () => {
|
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 = {
|
const forYouPlaylist: Playlist = {
|
||||||
...base,
|
...base,
|
||||||
kind: 'system',
|
kind: 'system',
|
||||||
@@ -281,8 +281,8 @@ describe('PlaylistCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('user-playlist play uses getPlaylist + plain playQueue (no source)', async () => {
|
test('user-playlist play uses getPlaylist + plain playQueue (no source)', async () => {
|
||||||
const { getPlaylist, systemShuffle } = await import('$lib/api/playlists');
|
const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js');
|
||||||
const { playQueue } = await import('$lib/player/store.svelte');
|
const { playQueue } = await import('#lib/player/store.svelte.js');
|
||||||
render(PlaylistCard, { props: { playlist: base } });
|
render(PlaylistCard, { props: { playlist: base } });
|
||||||
const playButton = screen.getByLabelText(/Play Saturday morning/i);
|
const playButton = screen.getByLabelText(/Play Saturday morning/i);
|
||||||
await fireEvent.click(playButton);
|
await fireEvent.click(playButton);
|
||||||
@@ -294,8 +294,8 @@ describe('PlaylistCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('user-playlist play does not refresh anything', async () => {
|
test('user-playlist play does not refresh anything', async () => {
|
||||||
const { refreshSystem, getPlaylist } = await import('$lib/api/playlists');
|
const { refreshSystem, getPlaylist } = await import('#lib/api/playlists.js');
|
||||||
const { playQueue } = await import('$lib/player/store.svelte');
|
const { playQueue } = await import('#lib/player/store.svelte.js');
|
||||||
|
|
||||||
render(PlaylistCard, { props: { playlist: base } });
|
render(PlaylistCard, { props: { playlist: base } });
|
||||||
const playButton = screen.getByLabelText(/Play Saturday morning/i);
|
const playButton = screen.getByLabelText(/Play Saturday morning/i);
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<script lang="ts">
|
<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 { GripVertical, X } from 'lucide-svelte';
|
||||||
import { draggable, type DragEventData } from '@neodrag/svelte';
|
import type { PlaylistTrack } from '#lib/api/types.js';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import { playlistTrackToRef } from '#lib/playlists/playlistTrackToRef.js';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackListRow from './TrackListRow.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';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
row,
|
row,
|
||||||
@@ -32,22 +30,21 @@
|
|||||||
const isRemoved = $derived(row.track_id === null);
|
const isRemoved = $derived(row.track_id === null);
|
||||||
const isUnavailable = $derived(isRemoved || isMissingFile);
|
const isUnavailable = $derived(isRemoved || isMissingFile);
|
||||||
|
|
||||||
// Reconstruct a minimal TrackRef for the kebab menu when the
|
// null when the entry cannot be played, which hides like, radio, add
|
||||||
// upstream track still exists. When unavailable, the menu is hidden.
|
// and the menu.
|
||||||
const liveTrack = $derived(playlistTrackToRef(row));
|
const liveTrack = $derived(playlistTrackToRef(row));
|
||||||
|
|
||||||
let measuredRowHeight = 56; // sensible default; replaced on dragStart
|
const canDrag = $derived(isOwner && !isUnavailable);
|
||||||
|
|
||||||
function handleDragStart(data: DragEventData) {
|
const drag = $derived(
|
||||||
const rect = data.rootNode.getBoundingClientRect();
|
isOwner
|
||||||
if (rect.height > 0) measuredRowHeight = rect.height;
|
? {
|
||||||
}
|
disabled: !canDrag,
|
||||||
|
handle: '[data-reorder-handle]',
|
||||||
function handleDragEnd(data: DragEventData) {
|
onMove: (delta: number) => onMove?.(row.position, row.position + delta)
|
||||||
const delta = offsetToDelta(data.offsetY, measuredRowHeight);
|
}
|
||||||
if (delta === 0) return;
|
: null
|
||||||
onMove?.(row.position, row.position + delta);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function handleHandleKeydown(e: KeyboardEvent) {
|
function handleHandleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'ArrowUp') {
|
if (e.key === 'ArrowUp') {
|
||||||
@@ -63,87 +60,57 @@
|
|||||||
e.preventDefault();
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<TrackListRow
|
||||||
role="listitem"
|
title={row.title}
|
||||||
class="flex items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-surface-hover
|
subtitle={`${row.artist_name} · ${row.album_title}`}
|
||||||
{isUnavailable ? 'text-text-muted' : 'text-text-primary'}
|
durationSec={row.duration_sec}
|
||||||
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
|
track={liveTrack}
|
||||||
use:draggable={{
|
ariaLabel={`Play ${row.title}`}
|
||||||
axis: 'y',
|
rowTitle={isMissingFile
|
||||||
bounds: 'parent',
|
? '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.'
|
||||||
disabled: !canDrag,
|
: undefined}
|
||||||
onDragStart: handleDragStart,
|
disabled={isUnavailable}
|
||||||
onDragEnd: handleDragEnd
|
struck={isRemoved}
|
||||||
}}
|
{drag}
|
||||||
|
onActivate={() => onPlay(row.position)}
|
||||||
>
|
>
|
||||||
{#if isOwner}
|
{#snippet lead()}
|
||||||
<button
|
{#if isOwner}
|
||||||
type="button"
|
<button
|
||||||
disabled={!canDrag}
|
type="button"
|
||||||
aria-label="Reorder track (drag or use arrow keys)"
|
data-reorder-handle
|
||||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
disabled={!canDrag}
|
||||||
onkeydown={handleHandleKeydown}
|
aria-label="Reorder track (drag or use arrow keys)"
|
||||||
class="flex-shrink-0 cursor-grab text-text-muted hover:text-text-primary
|
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||||
active:cursor-grabbing focus-visible:outline focus-visible:outline-2
|
onclick={(e) => e.stopPropagation()}
|
||||||
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
|
onkeydown={handleHandleKeydown}
|
||||||
>
|
class="cursor-grab p-1 text-text-muted hover:text-text-primary
|
||||||
<GripVertical size={14} strokeWidth={1} />
|
active:cursor-grabbing focus-visible:outline-solid focus-visible:outline-2
|
||||||
</button>
|
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
{/if}
|
>
|
||||||
|
<GripVertical size={16} strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<button
|
{#snippet note()}
|
||||||
type="button"
|
{#if isMissingFile}
|
||||||
class="min-w-0 flex-1 text-left"
|
<span class="text-error-fg"> · File missing</span>
|
||||||
onclick={() => !isUnavailable && onPlay(row.position)}
|
{/if}
|
||||||
disabled={isUnavailable}
|
{/snippet}
|
||||||
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>
|
|
||||||
|
|
||||||
<span class="flex-shrink-0 text-xs text-text-muted">{format(row.duration_sec)}</span>
|
{#snippet trailing()}
|
||||||
|
{#if isOwner}
|
||||||
{#if !isUnavailable && liveTrack}
|
<button
|
||||||
<LikeButton entityType="track" entityId={liveTrack.id} />
|
type="button"
|
||||||
<TrackMenu track={liveTrack} />
|
onclick={(e) => { e.stopPropagation(); onRemove(row.position); }}
|
||||||
{/if}
|
aria-label={`Remove ${row.title} from playlist`}
|
||||||
|
class="shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
|
||||||
{#if isOwner}
|
>
|
||||||
<button
|
<X size={16} strokeWidth={1} />
|
||||||
type="button"
|
</button>
|
||||||
onclick={() => onRemove(row.position)}
|
{/if}
|
||||||
aria-label={`Remove ${row.title} from playlist`}
|
{/snippet}
|
||||||
class="flex-shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
|
</TrackListRow>
|
||||||
>
|
|
||||||
<X size={14} strokeWidth={1} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
|
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
|
// LikeButton + TrackMenu transitively pull in TanStack Query, the auth
|
||||||
// store, likes/quarantine APIs, the player store, and SvelteKit
|
// store, likes/quarantine APIs, the player store, and SvelteKit
|
||||||
// navigation. Mock all of them at module level so the component can be
|
// navigation. Mock all of them at module level so the component can be
|
||||||
// rendered in isolation.
|
// 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 } }
|
user: { value: { id: 'u1', username: 'me', is_admin: false } }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -17,11 +17,11 @@ vi.mock('$app/navigation', () => ({
|
|||||||
goto: vi.fn()
|
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(),
|
playNext: vi.fn(),
|
||||||
enqueueTrack: vi.fn(),
|
enqueueTrack: vi.fn(),
|
||||||
playQueue: vi.fn(),
|
playQueue: vi.fn(),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Save, TriangleAlert } from 'lucide-svelte';
|
import { Save, TriangleAlert } from 'lucide-svelte';
|
||||||
import { getNetworkSettings, updatePublicUrl } from '$lib/api/admin';
|
import { getNetworkSettings, updatePublicUrl } from '#lib/api/admin.js';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
let saved = $state<string | null>(null);
|
let saved = $state<string | null>(null);
|
||||||
let value = $state('');
|
let value = $state('');
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
placeholder="https://music.example.com"
|
placeholder="https://music.example.com"
|
||||||
bind:value
|
bind:value
|
||||||
class="w-full rounded border border-border bg-background px-2 py-1 font-mono
|
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>
|
</label>
|
||||||
{#if here && value.trim() !== here}
|
{#if here && value.trim() !== here}
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
|
|
||||||
{#if !saved}
|
{#if !saved}
|
||||||
<p class="flex items-start gap-2 text-sm text-text-secondary" role="status">
|
<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>
|
<span>
|
||||||
Not set, so password-reset emails are not being sent. Minstrel won't build the link from
|
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.
|
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 getNetworkSettings = vi.fn();
|
||||||
const updatePublicUrl = vi.fn();
|
const updatePublicUrl = vi.fn();
|
||||||
|
|
||||||
vi.mock('$lib/api/admin', () => ({
|
vi.mock('#lib/api/admin.js', () => ({
|
||||||
getNetworkSettings: () => getNetworkSettings(),
|
getNetworkSettings: () => getNetworkSettings(),
|
||||||
updatePublicUrl: (url: string) => updatePublicUrl(url)
|
updatePublicUrl: (url: string) => updatePublicUrl(url)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const pushToast = vi.fn();
|
const pushToast = vi.fn();
|
||||||
vi.mock('$lib/stores/toast.svelte', () => ({
|
vi.mock('#lib/stores/toast.svelte.js', () => ({
|
||||||
pushToast: (...args: unknown[]) => pushToast(...args)
|
pushToast: (...args: unknown[]) => pushToast(...args)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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';
|
import QueueList from './QueueList.svelte';
|
||||||
|
|
||||||
let previouslyFocused: HTMLElement | null = null;
|
let previouslyFocused: HTMLElement | null = null;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
import { makeTrack } from '$test-utils/fixtures/track';
|
import { makeTrack } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
// QueueTrackRow (rendered inside QueueDrawer → QueueList) now renders a
|
// QueueTrackRow (rendered inside QueueDrawer → QueueList) now renders a
|
||||||
// LikeButton, which reads createLikedIdsQuery. Stub the likes API so the
|
// 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
|
// emptyLikesMock import — must precede the QueueDrawer import below, since
|
||||||
// loading the component evaluates the mocked modules and the hoisted
|
// loading the component evaluates the mocked modules and the hoisted
|
||||||
// factories need their referenced bindings already initialized.
|
// factories need their referenced bindings already initialized.
|
||||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
|
||||||
|
|
||||||
const closeQueueDrawer = vi.fn();
|
const closeQueueDrawer = vi.fn();
|
||||||
let queueValue: TrackRef[] = [];
|
let queueValue: TrackRef[] = [];
|
||||||
let indexValue = 0;
|
let indexValue = 0;
|
||||||
let openValue = true;
|
let openValue = true;
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
closeQueueDrawer: (...args: unknown[]) => closeQueueDrawer(...args),
|
closeQueueDrawer: (...args: unknown[]) => closeQueueDrawer(...args),
|
||||||
player: {
|
player: {
|
||||||
get queue() { return queueValue; },
|
get queue() { return queueValue; },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { X, Trash2, ArrowDown } from 'lucide-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';
|
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||||
|
|
||||||
// onClose: when provided, renders an X button in the header so the
|
// onClose: when provided, renders an X button in the header so the
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { GripVertical, X } from 'lucide-svelte';
|
import { GripVertical, X } from 'lucide-svelte';
|
||||||
import { draggable, type DragEventData } from '@neodrag/svelte';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '#lib/player/store.svelte.js';
|
||||||
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
|
import { coverUrl, FALLBACK_COVER } from '#lib/media/covers.js';
|
||||||
import { coverUrl, FALLBACK_COVER } from '$lib/media/covers';
|
import { reorderDrag } from './reorder-drag';
|
||||||
import { offsetToDelta } from './queue-row-math';
|
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
|
|
||||||
let { track, index, isCurrent } = $props<{
|
let { track, index, isCurrent } = $props<{
|
||||||
@@ -13,14 +12,6 @@
|
|||||||
isCurrent: boolean;
|
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() {
|
function handleBodyClick() {
|
||||||
if (isCurrent) return;
|
if (isCurrent) return;
|
||||||
playFromQueueIndex(index);
|
playFromQueueIndex(index);
|
||||||
@@ -30,12 +21,6 @@
|
|||||||
removeFromQueue(index);
|
removeFromQueue(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragEnd(data: DragEventData) {
|
|
||||||
const delta = offsetToDelta(data.offsetY, measuredRowHeight);
|
|
||||||
if (delta === 0) return;
|
|
||||||
moveQueueItem(index, index + delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHandleKeydown(e: KeyboardEvent) {
|
function handleHandleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'ArrowUp') {
|
if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -53,14 +38,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<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
|
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
|
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
|
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
|
worked from anywhere — the grip's real jobs are being the visual cue and
|
||||||
the keyboard target, and both survive here.
|
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
|
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.
|
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
|
<img
|
||||||
src={coverUrl(track.album_id)}
|
src={coverUrl(track.album_id)}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -86,7 +72,7 @@
|
|||||||
class="group absolute inset-0 flex cursor-grab items-center justify-center rounded
|
class="group absolute inset-0 flex cursor-grab items-center justify-center rounded
|
||||||
text-white/70 transition hover:bg-black/45 hover:text-white
|
text-white/70 transition hover:bg-black/45 hover:text-white
|
||||||
focus-visible:bg-black/45 focus-visible: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))"
|
style="filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.9))"
|
||||||
>
|
>
|
||||||
<GripVertical size={16} />
|
<GripVertical size={16} />
|
||||||
@@ -101,7 +87,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
{#if isCurrent}
|
{#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}
|
{/if}
|
||||||
<span class="font-medium truncate text-text-primary">{track.title}</span>
|
<span class="font-medium truncate text-text-primary">{track.title}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +100,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={handleRemove}
|
onclick={handleRemove}
|
||||||
aria-label="Remove from queue"
|
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} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
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.
|
// QueueTrackRow now renders a LikeButton, which reads createLikedIdsQuery.
|
||||||
// Stub the likes API so the row doesn't need a real QueryClient in context.
|
// 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
|
// The mock (and its emptyLikesMock import) must precede the component import
|
||||||
// below: importing QueueTrackRow transitively loads LikeButton → the mocked
|
// below: importing QueueTrackRow transitively loads LikeButton → the mocked
|
||||||
// module, and the hoisted factory needs emptyLikesMock already initialized.
|
// 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';
|
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ const playFromQueueIndex = vi.fn();
|
|||||||
const removeFromQueue = vi.fn();
|
const removeFromQueue = vi.fn();
|
||||||
const moveQueueItem = vi.fn();
|
const moveQueueItem = vi.fn();
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
playFromQueueIndex: (...args: unknown[]) => playFromQueueIndex(...args),
|
playFromQueueIndex: (...args: unknown[]) => playFromQueueIndex(...args),
|
||||||
removeFromQueue: (...args: unknown[]) => removeFromQueue(...args),
|
removeFromQueue: (...args: unknown[]) => removeFromQueue(...args),
|
||||||
moveQueueItem: (...args: unknown[]) => moveQueueItem(...args)
|
moveQueueItem: (...args: unknown[]) => moveQueueItem(...args)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
oninput={onInput}
|
oninput={onInput}
|
||||||
onkeydown={onKey}
|
onkeydown={onKey}
|
||||||
class="w-full rounded border border-border bg-surface pl-7 pr-7 py-1 text-sm
|
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}
|
{#if raw}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
getReacquisitionSettings,
|
getReacquisitionSettings,
|
||||||
updateReacquisitionSettings,
|
updateReacquisitionSettings,
|
||||||
type ReacquisitionSettings
|
type ReacquisitionSettings
|
||||||
} from '$lib/api/admin';
|
} from '#lib/api/admin.js';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// Policy for turning a missing file back into a Lidarr request
|
// Policy for turning a missing file back into a Lidarr request
|
||||||
// (milestone #290). Lives on the missing-files page rather than under
|
// (milestone #290). Lives on the missing-files page rather than under
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
max="720"
|
max="720"
|
||||||
bind:value={form.grace_hours}
|
bind:value={form.grace_hours}
|
||||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
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"
|
focus-visible:outline-accent"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
max="10"
|
max="10"
|
||||||
bind:value={form.max_attempts}
|
bind:value={form.max_attempts}
|
||||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
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"
|
focus-visible:outline-accent"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
max="168"
|
max="168"
|
||||||
bind:value={form.backoff_base_hours}
|
bind:value={form.backoff_base_hours}
|
||||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
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"
|
focus-visible:outline-accent"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
max="720"
|
max="720"
|
||||||
bind:value={form.backoff_max_hours}
|
bind:value={form.backoff_max_hours}
|
||||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
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"
|
focus-visible:outline-accent"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
max="200"
|
max="200"
|
||||||
bind:value={form.max_per_pass}
|
bind:value={form.max_per_pass}
|
||||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
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"
|
focus-visible:outline-accent"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
watching those rows never get a request would reasonably conclude
|
watching those rows never get a request would reasonably conclude
|
||||||
the feature is broken. -->
|
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">
|
<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>
|
<span>
|
||||||
{saved.unnameable_albums}
|
{saved.unnameable_albums}
|
||||||
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no
|
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
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:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={!dirty || saving}
|
disabled={!dirty || saving}
|
||||||
onclick={save}
|
onclick={save}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
import type { ReacquisitionSettings } from '$lib/api/admin';
|
import type { ReacquisitionSettings } from '#lib/api/admin.js';
|
||||||
import { ERROR_COPY } from '$lib/api/error-copy';
|
import { ERROR_COPY } from '#lib/api/error-copy.js';
|
||||||
|
|
||||||
vi.mock('$lib/api/admin', () => ({
|
vi.mock('#lib/api/admin.js', () => ({
|
||||||
getReacquisitionSettings: vi.fn(),
|
getReacquisitionSettings: vi.fn(),
|
||||||
updateReacquisitionSettings: 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 ReacquisitionSettingsCard from './ReacquisitionSettingsCard.svelte';
|
||||||
import { getReacquisitionSettings, updateReacquisitionSettings } from '$lib/api/admin';
|
import { getReacquisitionSettings, updateReacquisitionSettings } from '#lib/api/admin.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
const base: ReacquisitionSettings = {
|
const base: ReacquisitionSettings = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -18,16 +18,16 @@
|
|||||||
function navigate(next: string) {
|
function navigate(next: string) {
|
||||||
const onSearchPage = page.url.pathname.startsWith('/search');
|
const onSearchPage = page.url.pathname.startsWith('/search');
|
||||||
const trimmed = next.trim();
|
const trimmed = next.trim();
|
||||||
// keepFocus: SvelteKit blurs the active element on navigation by default.
|
// reset: false: SvelteKit blurs the active element and scrolls to the top
|
||||||
// For a typeahead-style URL update we want the user to keep typing
|
// on navigation by default. For a typeahead-style URL update we want the
|
||||||
// without re-clicking the input every 250 ms.
|
// user to keep typing without re-clicking the input every 250 ms.
|
||||||
if (trimmed === '') {
|
if (trimmed === '') {
|
||||||
if (onSearchPage) goto('/search', { replaceState: true, keepFocus: true });
|
if (onSearchPage) goto('/search', { replace: true, reset: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
goto(`/search?q=${encodeURIComponent(trimmed)}`, {
|
goto(`/search?q=${encodeURIComponent(trimmed)}`, {
|
||||||
replaceState: onSearchPage,
|
replace: onSearchPage,
|
||||||
keepFocus: true,
|
reset: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +66,6 @@
|
|||||||
bind:value
|
bind:value
|
||||||
oninput={onInput}
|
oninput={onInput}
|
||||||
onkeydown={onKey}
|
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>
|
</label>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
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(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
pageUrl: new URL('http://localhost/')
|
pageUrl: new URL('http://localhost/')
|
||||||
@@ -40,7 +40,7 @@ describe('SearchInput', () => {
|
|||||||
expect(goto).not.toHaveBeenCalled();
|
expect(goto).not.toHaveBeenCalled();
|
||||||
vi.advanceTimersByTime(250);
|
vi.advanceTimersByTime(250);
|
||||||
expect(goto).toHaveBeenCalledTimes(1);
|
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 () => {
|
test('rapid keystrokes debounce to a single goto', async () => {
|
||||||
@@ -53,16 +53,16 @@ describe('SearchInput', () => {
|
|||||||
await fireEvent.input(input, { target: { value: 'hel' } });
|
await fireEvent.input(input, { target: { value: 'hel' } });
|
||||||
vi.advanceTimersByTime(250);
|
vi.advanceTimersByTime(250);
|
||||||
expect(goto).toHaveBeenCalledTimes(1);
|
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');
|
state.pageUrl = new URL('http://localhost/search?q=h');
|
||||||
render(SearchInput);
|
render(SearchInput);
|
||||||
const input = screen.getByRole('searchbox') as HTMLInputElement;
|
const input = screen.getByRole('searchbox') as HTMLInputElement;
|
||||||
await fireEvent.input(input, { target: { value: 'he' } });
|
await fireEvent.input(input, { target: { value: 'he' } });
|
||||||
vi.advanceTimersByTime(250);
|
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 () => {
|
test('clearing input on /search calls goto("/search") (drops ?q=)', async () => {
|
||||||
@@ -71,7 +71,7 @@ describe('SearchInput', () => {
|
|||||||
const input = screen.getByRole('searchbox') as HTMLInputElement;
|
const input = screen.getByRole('searchbox') as HTMLInputElement;
|
||||||
await fireEvent.input(input, { target: { value: '' } });
|
await fireEvent.input(input, { target: { value: '' } });
|
||||||
vi.advanceTimersByTime(250);
|
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 () => {
|
test('Escape clears the input value', async () => {
|
||||||
@@ -88,6 +88,6 @@ describe('SearchInput', () => {
|
|||||||
const input = screen.getByRole('searchbox') as HTMLInputElement;
|
const input = screen.getByRole('searchbox') as HTMLInputElement;
|
||||||
await fireEvent.input(input, { target: { value: 'a b&c' } });
|
await fireEvent.input(input, { target: { value: 'a b&c' } });
|
||||||
vi.advanceTimersByTime(250);
|
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">
|
<script lang="ts">
|
||||||
import { X, ListPlus, Plus, ListMusic, Heart } from 'lucide-svelte';
|
import { X, ListPlus, Plus, ListMusic, Heart } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { selection, clearSelection } from '$lib/selection/store.svelte';
|
import { selection, clearSelection } from '#lib/selection/store.svelte.js';
|
||||||
import { playNextMany, enqueueTracks } from '$lib/player/store.svelte';
|
import { playNextMany, enqueueTracks } from '#lib/player/store.svelte.js';
|
||||||
import { likeEntity, createLikedIdsQuery } from '$lib/api/likes';
|
import { likeEntity, createLikedIdsQuery } from '#lib/api/likes.js';
|
||||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { ChevronDown, Compass, House, LibraryBig } from 'lucide-svelte';
|
import { ChevronDown, Compass, House, LibraryBig } from 'lucide-svelte';
|
||||||
import { user, logout } from '$lib/auth/store.svelte';
|
import { user, logout } from '#lib/auth/store.svelte.js';
|
||||||
import { player } from '$lib/player/store.svelte';
|
import { player } from '#lib/player/store.svelte.js';
|
||||||
import { appName } from '$lib/branding';
|
import { appName } from '#lib/branding.js';
|
||||||
import MinstrelMark from './MinstrelMark.svelte';
|
import MinstrelMark from './MinstrelMark.svelte';
|
||||||
import NotificationBell from './NotificationBell.svelte';
|
import NotificationBell from './NotificationBell.svelte';
|
||||||
import PlayerBar from './PlayerBar.svelte';
|
import PlayerBar from './PlayerBar.svelte';
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
await logout();
|
await logout();
|
||||||
goto('/login', { replaceState: true });
|
goto('/login', { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActive(prefix: string): boolean {
|
function isActive(prefix: string): boolean {
|
||||||
@@ -94,13 +94,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 md:gap-3 justify-self-end min-w-0">
|
<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 />
|
<SearchInput />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
|
|
||||||
<div class="relative flex-shrink-0">
|
<div class="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
bind:this={menuButtonRef}
|
bind:this={menuButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
{#if menuOpen}
|
{#if menuOpen}
|
||||||
<div
|
<div
|
||||||
bind:this={menuRef}
|
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"
|
role="menu"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ const userState = vi.hoisted(() => ({
|
|||||||
} | null
|
} | null
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/auth/store.svelte', () => ({
|
vi.mock('#lib/auth/store.svelte.js', () => ({
|
||||||
user: { get value() { return userState.current; } },
|
user: { get value() { return userState.current; } },
|
||||||
logout: vi.fn().mockResolvedValue(undefined)
|
logout: vi.fn().mockResolvedValue(undefined)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// The bell's queries need a QueryClient the Shell tests do not provide; its own
|
// The bell's queries need a QueryClient the Shell tests do not provide; its own
|
||||||
// behaviour is covered in NotificationBell.test.ts.
|
// 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');
|
const { mockQuery } = await import('../../test-utils/query');
|
||||||
return {
|
return {
|
||||||
createUnreadCountQuery: () => mockQuery({ data: 0 }),
|
createUnreadCountQuery: () => mockQuery({ data: 0 }),
|
||||||
@@ -38,7 +38,7 @@ vi.mock('$lib/api/notifications', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import Shell from './Shell.svelte';
|
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';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -110,6 +110,6 @@ describe('Shell', () => {
|
|||||||
await fireEvent.click(screen.getByRole('button', { name: /alice/i }));
|
await fireEvent.click(screen.getByRole('button', { name: /alice/i }));
|
||||||
await fireEvent.click(screen.getByRole('menuitem', { name: /log out/i }));
|
await fireEvent.click(screen.getByRole('menuitem', { name: /log out/i }));
|
||||||
expect(logout).toHaveBeenCalledTimes(1);
|
expect(logout).toHaveBeenCalledTimes(1);
|
||||||
expect(goto).toHaveBeenCalledWith('/login', { replaceState: true });
|
expect(goto).toHaveBeenCalledWith('/login', { replace: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" module>
|
<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';
|
type Tone = 'warning' | 'info' | 'success' | 'error';
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
createSnoozesQuery,
|
createSnoozesQuery,
|
||||||
snoozeSuggestion,
|
snoozeSuggestion,
|
||||||
unsnoozeSuggestion
|
unsnoozeSuggestion
|
||||||
} from '$lib/api/suggestions';
|
} from '#lib/api/suggestions.js';
|
||||||
import { createRequest } from '$lib/api/requests';
|
import { createRequest } from '#lib/api/requests.js';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '#lib/api/queries.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
import DiscoverResultCard from './DiscoverResultCard.svelte';
|
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 client = useQueryClient();
|
||||||
const queryStore = createSuggestionsQuery();
|
const queryStore = createSuggestionsQuery();
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Bring ${snoozed.name} back now`}
|
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)}
|
onclick={() => onUnsnooze(snoozed.mbid, snoozed.name)}
|
||||||
>
|
>
|
||||||
Bring back
|
Bring back
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
|
|||||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
|
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('$lib/api/suggestions', () => ({
|
vi.mock('#lib/api/suggestions.js', () => ({
|
||||||
createSuggestionsQuery: vi.fn(),
|
createSuggestionsQuery: vi.fn(),
|
||||||
createSnoozesQuery: vi.fn(),
|
createSnoozesQuery: vi.fn(),
|
||||||
snoozeSuggestion: vi.fn().mockResolvedValue(undefined),
|
snoozeSuggestion: vi.fn().mockResolvedValue(undefined),
|
||||||
unsnoozeSuggestion: vi.fn().mockResolvedValue(undefined)
|
unsnoozeSuggestion: vi.fn().mockResolvedValue(undefined)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/api/requests', () => ({
|
vi.mock('#lib/api/requests.js', () => ({
|
||||||
createRequest: vi.fn().mockResolvedValue({})
|
createRequest: vi.fn().mockResolvedValue({})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const pushToastMock = vi.fn();
|
const pushToastMock = vi.fn();
|
||||||
vi.mock('$lib/stores/toast.svelte', () => ({
|
vi.mock('#lib/stores/toast.svelte.js', () => ({
|
||||||
pushToast: (...args: unknown[]) => pushToastMock(...args)
|
pushToast: (...args: unknown[]) => pushToastMock(...args)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -30,9 +30,9 @@ import {
|
|||||||
createSnoozesQuery,
|
createSnoozesQuery,
|
||||||
snoozeSuggestion,
|
snoozeSuggestion,
|
||||||
unsnoozeSuggestion
|
unsnoozeSuggestion
|
||||||
} from '$lib/api/suggestions';
|
} from '#lib/api/suggestions.js';
|
||||||
import { createRequest } from '$lib/api/requests';
|
import { createRequest } from '#lib/api/requests.js';
|
||||||
import type { ArtistSuggestion, SuggestionSnooze } from '$lib/api/types';
|
import type { ArtistSuggestion, SuggestionSnooze } from '#lib/api/types.js';
|
||||||
|
|
||||||
const oneSeed: ArtistSuggestion = {
|
const oneSeed: ArtistSuggestion = {
|
||||||
mbid: 'mb1',
|
mbid: 'mb1',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Single global toast renderer. Mounted once in +layout.svelte; pages call
|
// 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.
|
// 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>
|
</script>
|
||||||
|
|
||||||
{#if toast.value}
|
{#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 AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||||
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
|
||||||
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
|
import { createMyQuarantineQuery, unflagTrack } from '#lib/api/quarantine.js';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '#lib/api/queries.js';
|
||||||
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
import { playNext, enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
track,
|
track,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
||||||
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
|
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
|
||||||
import { makeTrack } from '$test-utils/fixtures/track';
|
import { makeTrack } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
// Mutable mock-user handle so individual tests can flip is_admin / null
|
// Mutable mock-user handle so individual tests can flip is_admin / null
|
||||||
// without re-importing the module under test.
|
// without re-importing the module under test.
|
||||||
@@ -13,7 +13,7 @@ const userState = vi.hoisted(() => ({
|
|||||||
| null
|
| null
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/auth/store.svelte', () => ({
|
vi.mock('#lib/auth/store.svelte.js', () => ({
|
||||||
user: { get value() { return userState.current; } }
|
user: { get value() { return userState.current; } }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -21,20 +21,20 @@ vi.mock('$app/navigation', () => ({
|
|||||||
goto: vi.fn()
|
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/api/playlists', () => emptyPlaylistsMock());
|
vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
playNext: vi.fn(),
|
playNext: vi.fn(),
|
||||||
enqueueTrack: vi.fn(),
|
enqueueTrack: vi.fn(),
|
||||||
playRadio: vi.fn()
|
playRadio: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
import { playNext, enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
|
||||||
|
|
||||||
const track = makeTrack({ title: 'Roygbiv' });
|
const track = makeTrack({ title: 'Roygbiv' });
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TrackRef } from '$lib/api/types';
|
// A track in a library list (album, Liked, Search). The look is
|
||||||
import { formatDuration } from '$lib/media/duration';
|
// TrackListRow's; what this adds is the track number and multi-select.
|
||||||
import { player, playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
import { Plus, Check } from 'lucide-svelte';
|
import { playQueue } from '#lib/player/store.svelte.js';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import { Check } from 'lucide-svelte';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackListRow from './TrackListRow.svelte';
|
||||||
import {
|
import {
|
||||||
selection, toggleOne, selectRange
|
selection, toggleOne, selectRange
|
||||||
} from '$lib/selection/store.svelte';
|
} from '#lib/selection/store.svelte.js';
|
||||||
|
|
||||||
type PlayHandler = (tracks: TrackRef[], index: number) => void;
|
type PlayHandler = (tracks: TrackRef[], index: number) => void;
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tracks,
|
tracks,
|
||||||
index,
|
index,
|
||||||
onPlay
|
onPlay,
|
||||||
}: { tracks: TrackRef[]; index: number; onPlay?: PlayHandler } = $props();
|
showAlbum = true
|
||||||
|
}: {
|
||||||
|
tracks: TrackRef[];
|
||||||
|
index: number;
|
||||||
|
onPlay?: PlayHandler;
|
||||||
|
/** Off on the album page, where the album is the page itself. */
|
||||||
|
showAlbum?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const track = $derived(tracks[index]);
|
const track = $derived(tracks[index]);
|
||||||
|
|
||||||
const isCurrent = $derived(player.current?.id === track.id);
|
|
||||||
const isSelected = $derived(selection.isSelected(track.id));
|
const isSelected = $derived(selection.isSelected(track.id));
|
||||||
const selectMode = $derived(selection.active);
|
const selectMode = $derived(selection.active);
|
||||||
|
|
||||||
function activate() {
|
const subtitle = $derived(
|
||||||
if (onPlay) onPlay(tracks, index);
|
showAlbum ? `${track.artist_name} · ${track.album_title}` : track.artist_name
|
||||||
else playQueue(tracks, index);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function onRowClick(e: MouseEvent) {
|
function activate(e: MouseEvent | KeyboardEvent) {
|
||||||
// In selection mode any click toggles; outside selection mode click
|
// In selection mode any activation toggles; outside it, it plays.
|
||||||
// plays as before. Shift+click extends the range from the anchor
|
// Shift+click extends the range from the anchor regardless of mode
|
||||||
// regardless of mode (enters selection mode if nothing was picked).
|
// (entering selection mode if nothing was picked).
|
||||||
if (e.shiftKey) {
|
if (e instanceof MouseEvent && e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectRange(index, tracks);
|
selectRange(index, tracks);
|
||||||
return;
|
return;
|
||||||
@@ -41,15 +46,8 @@
|
|||||||
toggleOne(track, index);
|
toggleOne(track, index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activate();
|
if (onPlay) onPlay(tracks, index);
|
||||||
}
|
else playQueue(tracks, index);
|
||||||
|
|
||||||
function onRowKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (selectMode) toggleOne(track, index);
|
|
||||||
else activate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCheckboxClick(e: MouseEvent) {
|
function onCheckboxClick(e: MouseEvent) {
|
||||||
@@ -57,43 +55,31 @@
|
|||||||
if (e.shiftKey) selectRange(index, tracks);
|
if (e.shiftKey) selectRange(index, tracks);
|
||||||
else toggleOne(track, index);
|
else toggleOne(track, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddClick(e: MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
enqueueTrack(track);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRadioClick(e: MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
playRadio(track.id);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<TrackListRow
|
||||||
role="button"
|
title={track.title}
|
||||||
tabindex="0"
|
{subtitle}
|
||||||
aria-label={track.title}
|
durationSec={track.duration_sec}
|
||||||
data-selected={isSelected}
|
{track}
|
||||||
onclick={onRowClick}
|
ariaLabel={track.title}
|
||||||
onkeydown={onRowKey}
|
{isSelected}
|
||||||
class="group grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''} {isSelected ? 'bg-surface-hover' : ''}"
|
onActivate={activate}
|
||||||
>
|
>
|
||||||
<span class="relative flex h-6 w-8 items-center justify-end">
|
{#snippet lead()}
|
||||||
<!-- Track number: visible by default; hidden when the row is hovered
|
<!-- Track number by default; the checkbox replaces it on hover, in
|
||||||
(so the checkbox replaces it) or when this row is selected. -->
|
selection mode, or when this row is selected. -->
|
||||||
<span
|
<span
|
||||||
class="tabular-nums text-text-secondary
|
class="tabular-nums text-text-secondary
|
||||||
{selectMode || isSelected ? 'hidden' : 'group-hover:hidden'}"
|
{selectMode || isSelected ? 'hidden' : 'group-hover:hidden'}"
|
||||||
>{track.track_number ?? '—'}</span>
|
>{track.track_number ?? '—'}</span>
|
||||||
<!-- Checkbox: hidden by default; revealed on row hover OR whenever
|
|
||||||
the selection bar is active OR this row is the selected one. -->
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
aria-checked={isSelected}
|
aria-checked={isSelected}
|
||||||
aria-label={isSelected ? `Deselect ${track.title}` : `Select ${track.title}`}
|
aria-label={isSelected ? `Deselect ${track.title}` : `Select ${track.title}`}
|
||||||
onclick={onCheckboxClick}
|
onclick={onCheckboxClick}
|
||||||
class="flex h-5 w-5 items-center justify-center rounded border
|
class="h-5 w-5 items-center justify-center rounded border
|
||||||
{isSelected
|
{isSelected
|
||||||
? 'border-accent bg-accent text-text-primary'
|
? 'border-accent bg-accent text-text-primary'
|
||||||
: 'border-border bg-surface text-transparent'}
|
: 'border-border bg-surface text-transparent'}
|
||||||
@@ -101,25 +87,5 @@
|
|||||||
>
|
>
|
||||||
<Check size={14} strokeWidth={2} />
|
<Check size={14} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
{/snippet}
|
||||||
<span class="truncate">{track.title}</span>
|
</TrackListRow>
|
||||||
<LikeButton entityType="track" entityId={track.id} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Play radio from this track"
|
|
||||||
title="Play radio"
|
|
||||||
onclick={onRadioClick}
|
|
||||||
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
|
||||||
>📻</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Add ${track.title} to queue`}
|
|
||||||
title="Add to queue"
|
|
||||||
onclick={onAddClick}
|
|
||||||
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
|
||||||
>
|
|
||||||
<Plus size={16} strokeWidth={1} />
|
|
||||||
</button>
|
|
||||||
<TrackMenu {track} />
|
|
||||||
<span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
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 { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
|
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
|
||||||
import { makeTrack } from '$test-utils/fixtures/track';
|
import { makeTrack } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
playQueue: vi.fn(),
|
playQueue: vi.fn(),
|
||||||
enqueueTrack: vi.fn(),
|
enqueueTrack: vi.fn(),
|
||||||
playRadio: vi.fn(),
|
playRadio: vi.fn(),
|
||||||
player: { current: undefined }
|
player: { current: undefined }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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 TrackRow from './TrackRow.svelte';
|
import TrackRow from './TrackRow.svelte';
|
||||||
import { playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
import { playQueue, enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
|
||||||
import { selection, clearSelection } from '$lib/selection/store.svelte';
|
import { selection, clearSelection } from '#lib/selection/store.svelte.js';
|
||||||
|
|
||||||
const tracks: TrackRef[] = [
|
const tracks: TrackRef[] = [
|
||||||
makeTrack({ title: 'So What', duration_sec: 545 }),
|
makeTrack({ title: 'So What', duration_sec: 545 }),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Translate a vertical drag offset (px) into an integer row-delta,
|
// Translate a vertical drag offset (px) into an integer row-delta,
|
||||||
// given the measured row height (px). Used by QueueTrackRow when a
|
// given the measured row height (px). Used by reorderDrag both to snap
|
||||||
// neodrag drop completes — we round to the nearest row boundary so
|
// the row while it is dragged and to report where it was dropped — it
|
||||||
// the user's drop intent maps to a clean queue index.
|
// rounds to the nearest row boundary, so the slot the row shows is the
|
||||||
|
// slot it lands in.
|
||||||
export function offsetToDelta(offsetY: number, rowHeight: number): number {
|
export function offsetToDelta(offsetY: number, rowHeight: number): number {
|
||||||
if (rowHeight <= 0) return 0;
|
if (rowHeight <= 0) return 0;
|
||||||
// `|| 0` normalizes -0 to +0. Math.round(-0.5) === -0; downstream
|
// `|| 0` normalizes -0 to +0. Math.round(-0.5) === -0; downstream
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user