Merge pull request 'feat: player + search + M2 events foundation' (#19) from dev into main

This commit was merged in pull request #19.
This commit is contained in:
2026-04-26 15:51:52 +00:00
69 changed files with 11424 additions and 145 deletions
+1 -1
View File
@@ -75,7 +75,7 @@ func run() error {
srv := server.New(logger, pool, scanner, subsonic.Config{
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
})
}, cfg.Events)
httpServer := &http.Server{
Addr: cfg.Server.Address,
Handler: srv.Router(),
+9
View File
@@ -36,3 +36,12 @@ subsonic:
# apiKey (OpenSubsonic) and falls back to u+t+s token auth.
# Env: SMARTMUSIC_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD
allow_plaintext_password: false
events:
# Idle gap (minutes) between play_events that closes a play_session and
# starts a new one. Spec §6.
session_timeout_minutes: 30
# Skip rule (spec §6): a play counts as a SKIP if completion ratio is below
# this threshold AND duration played (ms) is below the next threshold.
skip_max_completion_ratio: 0.5
skip_max_duration_played_ms: 30000
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,423 @@
# Web UI Player — Design Spec
**Date:** 2026-04-24
**Status:** Design approved
**Follows:** [Web UI Library Views](2026-04-23-web-ui-library-views-design.md) — tracks surfaced by the library plan finally become playable.
## Goal
Let an authenticated user click any track on an album page and hear it — plus skip/seek/shuffle/repeat/volume, a persistent bottom-bar UI, and OS-level media controls via the MediaSession API. After this lands, the web client is a functional music player for sequential playback through the library.
## Non-goals
Explicit YAGNI — kept out of this plan so scope stays tight:
- **No server-side listen-history recording.** The spec's `play_events` / `sessions` tables exist in the server design but no `/api/*` endpoints are implemented yet. Once those land, a follow-up "session reporting" sub-plan wires the player to POST events.
- **No scrobbling to ListenBrainz.** Dependent on server event endpoints + scrobble queue — later plan.
- **No contextual likes or "like this vibe".** Out of scope until session reporting exists.
- **No cross-refresh persistence.** Reload drops playback state (queue, position, current track). Volume is the only thing that persists.
- **No queue UI / "up next" panel.** The user sees the current track in the bar; they don't get a separate list view of what's coming. Queue visibility is a natural follow-up.
- **No keyboard shortcuts.** Space/arrow keys don't control playback in this plan — a separate "keyboard shortcuts" sub-plan will add them.
- **No crossfade, gapless playback, replay-gain, equalizer.** Baseline HTMLAudio only.
- **No cast / AirPlay / Chromecast.** Browser-local playback only.
- **No keyboard-driven seeking beyond the native `<input type="range">` behavior.**
## Architecture
**Single global player store** as a Svelte 5 rune module — imported by `+layout.svelte`, `Shell`, `PlayerBar`, and `TrackRow`. The store is pure state + action functions; it never touches the `<audio>` element directly.
The `<audio>` element lives once in `+layout.svelte` (survives navigation because the root layout never unmounts). The layout drives the element FROM the store via `$effect`:
- `audioEl.src` follows `player.current?.stream_url`
- `audioEl.volume` follows `player.volume`
- `audioEl.play()` / `pause()` follows `player.isPlaying`
And reports back INTO the store via DOM event handlers: `timeupdate`, `loadedmetadata`, `playing`, `pause`, `waiting`, `ended`, `error`. This inversion makes the store pure-logic testable — unit tests don't need jsdom to implement `HTMLMediaElement`.
**Seek writes go directly** via a small `registerAudioEl(el)` helper the layout calls once at mount. `seekTo(sec)` then writes `audioEl.currentTime = sec` without going through a `$effect` on `position` (avoids a write-loop where audio `timeupdate` → store `position``$effect` → audio `currentTime``timeupdate` again).
**MediaSession integration** (`useMediaSession()`) is a thin `$effect`-based module imported once from the root layout. It reads the store and writes `navigator.mediaSession.metadata`, registers action handlers (play/pause/previous/next/seekto), and keeps `playbackState` + `setPositionState` in sync.
**Click-to-play lifecycle:**
```
User clicks <TrackRow> for album tracks[5]
→ store.playQueue(album.tracks, 5)
→ _queue=tracks, _index=5, _state='loading', _position=0
→ Shell mounts <PlayerBar> (current !== undefined)
→ layout's src $effect → audio.src = tracks[5].stream_url
→ layout's isPlaying $effect → audio.play()
→ 'loadedmetadata' → reportDuration
→ 'playing' → reportStateFromAudio('playing') → _state='playing'
→ PlayerBar updates
```
## File structure
**New files under `web/src/`:**
```
lib/
├── player/
│ ├── store.svelte.ts # Rune state + action functions
│ ├── store.test.ts # Pure state-machine tests
│ ├── mediaSession.svelte.ts # navigator.mediaSession glue
│ └── mediaSession.test.ts # MediaSession spy-based tests
└── components/
├── PlayerBar.svelte # Bottom bar UI
└── PlayerBar.test.ts # Controls wired correctly; disabled states
```
**Modified files:**
| File | Change |
|---|---|
| `lib/components/Shell.svelte` | Grid grows a 3rd row. `<PlayerBar>` slot spans both columns. Mounted only when `player.current !== undefined`. |
| `lib/components/TrackRow.svelte` | Becomes a `<button>` that calls `playQueue(tracks, index)`. New props `tracks: TrackRef[]` and `index: number`. |
| `lib/components/TrackRow.test.ts` | Updated — clicks fire `playQueue` (mocked). |
| `routes/albums/[id]/+page.svelte` | Passes `tracks={album.tracks}` and `index={i}` to each `<TrackRow>`. |
| `routes/+layout.svelte` | Adds `<audio>` element, `$effect` bindings that drive it from the store, and a `useMediaSession()` call. |
## State shape
```ts
// lib/player/store.svelte.ts
import type { TrackRef } from '$lib/api/types';
export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
export type RepeatMode = 'off' | 'all' | 'one';
let _queue = $state<TrackRef[]>([]);
let _index = $state(0);
let _state = $state<PlayerState>('idle');
let _position = $state(0);
let _duration = $state(0);
let _volume = $state(readStoredVolume()); // initial from localStorage
let _shuffle = $state(false);
let _repeat = $state<RepeatMode>('off');
let _error = $state<string | null>(null);
export const player = {
get queue() { return _queue; },
get index() { return _index; },
get current() { return _queue[_index] as TrackRef | undefined; },
get state() { return _state; },
get isPlaying() { return _state === 'playing'; },
get position() { return _position; },
get duration() { return _duration; },
get volume() { return _volume; },
get shuffle() { return _shuffle; },
get repeat() { return _repeat; },
get error() { return _error; },
};
```
## Action contract
```ts
// From outside the module (UI components and the layout call these).
export function playQueue(tracks: TrackRef[], startIndex = 0): void;
export function togglePlay(): void;
export function skipNext(): void;
export function skipPrev(): void;
export function seekTo(sec: number): void;
export function setVolume(v: number): void;
export function toggleShuffle(): void;
export function cycleRepeat(): void;
// Audio-event reports FROM the layout's <audio> element into the store.
export function reportTimeUpdate(sec: number): void;
export function reportDuration(sec: number): void;
export function reportStateFromAudio(
event: 'playing' | 'paused' | 'waiting' | 'ended' | 'error',
detail?: string
): void;
// Wiring for seek writes (avoids $effect loop on position).
export function registerAudioEl(el: HTMLAudioElement | null): void;
```
## Per-control semantics
**Skip-previous (3-second rule).** Skip-prev never wraps to the end of the queue — repeat modes only affect auto-advance and skip-next.
- If `_position < 3` AND `_index > 0`: `_index--`, position=0, play.
- Else (position ≥ 3, OR at index 0): seek to 0 of current track.
- Disabled visually when `_index === 0` AND `_position < 3` — nothing useful left to do.
**Skip-next.**
- `_index + 1 < _queue.length``_index++`, position=0.
- At end: consult `_repeat`:
- `off``_state='paused'`, `_index = queue.length - 1`, `_position = _duration`.
- `all``_index = 0`, keep playing.
- `one` → treated as `off` here (explicit user skip overrides one-track repeat).
- Disabled when `_index === _queue.length - 1` AND `_repeat === 'off'`.
**Auto-advance on `ended` event** (`reportStateFromAudio('ended')`):
- `repeat === 'one'``_position = 0`, `_state='loading'`; layout sees state change, `currentTime` resets (via `seekTo(0)` internally), play continues.
- `repeat === 'all'``_index = (_index + 1) % _queue.length`, `_position = 0`.
- `repeat === 'off'` → same as skip-next past end: `_state='paused'`.
**Seek.**
- UI: `<input type="range" min=0 max={duration}>` bound to `player.position` with `oninput → seekTo(newValue)`.
- `seekTo` writes `_position = sec` AND `audioEl.currentTime = sec` via `registerAudioEl`-stored reference.
**Volume.**
- UI: `<input type="range" min=0 max=1 step=0.01>`.
- `setVolume(v)` clamps to `[0, 1]`, writes `_volume = v`, persists via `localStorage.setItem('minstrel.volume', String(v))`.
- Layout's `$effect` on `player.volume` pushes to `audioEl.volume`.
- Module-load-time helper `readStoredVolume(): number` reads `localStorage.getItem('minstrel.volume')` with `1.0` fallback and numeric validation.
**Shuffle.**
- `toggleShuffle()`:
- Enabling: Fisher-Yates shuffle `_queue[_index+1..end]` in place. The played-and-current tracks stay in position; only what's ahead is randomized.
- Disabling: flag flips. Queue is not unshuffled — shuffle is a one-shot randomization; toggling back off only matters for new `skipNext`/`ended` behavior, which at the moment IS "walk through queue in order" so no difference. (Simpler than tracking an original order.)
- Test-determinism: the shuffle function takes an injectable RNG; default is `Math.random`. Tests override via a module-level setter.
**Repeat.**
- `cycleRepeat()`: `off → all → one → off`.
- UI renders three distinct glyphs.
**Click-to-play.**
- `TrackRow` `onclick``playQueue(tracks, index)`.
- Clicking a track currently playing restarts it.
- Clicking a track on a DIFFERENT album replaces queue with that album's tracks, `index=N`.
## Audio wiring in `+layout.svelte`
```svelte
<script lang="ts">
/* existing imports unchanged */
import {
player,
registerAudioEl,
reportTimeUpdate,
reportDuration,
reportStateFromAudio,
seekTo
} from '$lib/player/store.svelte';
import { useMediaSession } from '$lib/player/mediaSession.svelte';
let audioEl: HTMLAudioElement | undefined = $state();
$effect(() => {
if (audioEl) registerAudioEl(audioEl);
return () => registerAudioEl(null);
});
$effect(() => {
if (!audioEl) return;
const src = player.current?.stream_url ?? '';
if (audioEl.src !== new URL(src, window.location.origin).href && src) {
audioEl.src = src;
} else if (!src) {
audioEl.removeAttribute('src');
audioEl.load();
}
});
$effect(() => {
if (audioEl) audioEl.volume = player.volume;
});
$effect(() => {
if (!audioEl) return;
if (player.isPlaying) audioEl.play().catch(() => {/* play() rejected (autoplay / interrupted) — store will receive a 'pause' event */});
else audioEl.pause();
});
useMediaSession();
</script>
<!-- existing layout markup -->
<audio
bind:this={audioEl}
preload="metadata"
ontimeupdate={() => audioEl && reportTimeUpdate(audioEl.currentTime)}
onloadedmetadata={() => audioEl && reportDuration(audioEl.duration)}
onplaying={() => reportStateFromAudio('playing')}
onpause={() => reportStateFromAudio('paused')}
onwaiting={() => reportStateFromAudio('waiting')}
onended={() => reportStateFromAudio('ended')}
onerror={() => reportStateFromAudio('error', audioEl?.error?.message)}
></audio>
```
## MediaSession glue
```ts
// lib/player/mediaSession.svelte.ts
import { player, togglePlay, skipNext, skipPrev, seekTo } from './store.svelte';
export function useMediaSession(): void {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
$effect(() => {
const t = player.current;
if (!t) {
navigator.mediaSession.metadata = null;
return;
}
navigator.mediaSession.metadata = new MediaMetadata({
title: t.title,
artist: t.artist_name,
album: t.album_title,
artwork: [{
src: `/api/albums/${t.album_id}/cover`,
sizes: '512x512',
type: 'image/jpeg'
}]
});
});
navigator.mediaSession.setActionHandler('play', () => togglePlay());
navigator.mediaSession.setActionHandler('pause', () => togglePlay());
navigator.mediaSession.setActionHandler('previoustrack', () => skipPrev());
navigator.mediaSession.setActionHandler('nexttrack', () => skipNext());
navigator.mediaSession.setActionHandler('seekto', (e) => {
if (typeof e.seekTime === 'number') seekTo(e.seekTime);
});
$effect(() => {
if (player.duration > 0 && 'setPositionState' in navigator.mediaSession) {
try {
navigator.mediaSession.setPositionState({
duration: player.duration,
position: Math.min(player.position, player.duration),
playbackRate: 1
});
} catch { /* setPositionState throws if numbers are NaN/Infinity briefly between tracks */ }
}
});
$effect(() => {
navigator.mediaSession.playbackState = player.isPlaying ? 'playing' : 'paused';
});
}
```
## Bottom-bar layout
```
┌────────────────────────────────────────────────────────────────┐
│ [cover] Title 2:14 [════════▓═══════] 4:02 🔀 🔁 🔊 │
│ Artist ⏮ ⏯ ⏭ │
└────────────────────────────────────────────────────────────────┘
```
- **Left** (fixed 240px, collapses below `sm:`): 48px cover thumbnail (click → `/albums/:id`), title, artist name linked to `/artists/:id`. Broken cover → `FALLBACK_COVER` from library plan.
- **Center** (flex-1, stacked):
- Top row: `{elapsedTime} {seek slider (flex-1)} {totalDuration}` — single row, elapsed/total use `tabular-nums` and a fixed `ch` width so the slider doesn't jitter as `position` ticks.
- Bottom row: `⏮ ⏯ ⏭` centered under the slider. Play/pause glyph swaps based on `isPlaying`. During `loading`/`waiting`, swap play icon for a small spinner.
- **Right** (~220px): shuffle button, repeat button (badge for 'one' state), volume slider (150px wide).
- Total bar height ~80px. Grid spans both columns (sidebar + main).
Error variant: the center column's content is replaced with an inline error card — "Playback failed." + "Try again" button that calls `playQueue(_queue, _index)`. Transport controls disabled.
## Shell modifications
`Shell.svelte` grid grows a 3rd row:
```
grid grid-cols-[auto_1fr] grid-rows-[auto_1fr_auto]
```
The last row spans both columns. PlayerBar renders there only when `player.current` is defined. When nothing has played yet, the row collapses (zero height).
## Loading / error / empty states
- `state === 'loading' | 'waiting'` — play/pause button shows spinner glyph. Other controls unchanged.
- `state === 'error'` — error card replaces transport controls; retry button calls `playQueue(_queue, _index)`.
- `current === undefined` — PlayerBar doesn't render. Shell's 3rd grid row collapses.
401 mid-playback: already handled globally by `apiFetch`'s interceptor → `auth.logout({silent: true})` → auth store clears → root layout's guard redirects to `/login`. The audio element errors out; the PlayerBar unmounts because Shell is gone. No player-specific handling needed.
## Testing
**Unit tests — `store.test.ts`:**
State-machine semantics, no audio element. All tests seed a fresh store via `vi.resetModules()` + dynamic `import('./store.svelte')` in `beforeEach` so `_*` state isn't shared across tests. Explicit cases:
- `playQueue([...], 2)` sets queue, index=2, state='loading', position=0.
- `togglePlay`: idle → no-op. paused → playing. playing → paused.
- `skipNext` mid-queue: index++, position=0.
- `skipNext` at end with `repeat='off'`: state='paused', index=queue.length-1, position=duration.
- `skipNext` at end with `repeat='all'`: index=0.
- `skipNext` at end with `repeat='one'`: same as 'off' (explicit skip overrides one-track repeat).
- `skipPrev` with position < 3 AND index > 0: index--.
- `skipPrev` with position >= 3: index unchanged; position=0.
- `skipPrev` at index 0: position=0 regardless.
- `reportStateFromAudio('ended')` with `repeat='one'`: position=0, index unchanged, state='loading'.
- `reportStateFromAudio('ended')` with `repeat='all'`: wraps `_index = (_index + 1) % _queue.length`.
- `reportStateFromAudio('ended')` with `repeat='off'`: state='paused'.
- `toggleShuffle` (with injected seeded RNG): queue[index+1..end] is reordered; queue[0..index] unchanged.
- `cycleRepeat`: off → all → one → off (3 consecutive calls).
- `setVolume(0.5)`: volume=0.5; localStorage key 'minstrel.volume' equals '0.5'.
- `setVolume(-1)` clamps to 0; `setVolume(2)` clamps to 1.
- Module load with `localStorage['minstrel.volume']='0.3'`: initial `_volume === 0.3`.
- Module load with absent / invalid localStorage value: initial `_volume === 1.0`.
- `reportStateFromAudio('error', 'foo')`: state='error', error='foo'.
- `reportStateFromAudio('playing')`: state='playing'; clears `_error`.
- `seekTo(30)`: position=30; if `registerAudioEl` has been called with a mock element, that element's `currentTime === 30`.
**Unit tests — `mediaSession.test.ts`:**
Fakes `navigator.mediaSession` with a spy object containing `setActionHandler`, `setPositionState`, `metadata`, `playbackState`. Calls `useMediaSession()` inside `$effect.root(() => {...})`.
- Metadata: when `player.current` is set, `navigator.mediaSession.metadata` has title/artist/album/artwork matching the current track.
- Metadata cleared to `null` when `player.current` becomes undefined.
- All four action handlers registered with callables that dispatch to the corresponding store action.
- `playbackState` updates when `isPlaying` flips.
- `setPositionState` called with duration/position whenever duration>0 changes.
**Unit tests — `PlayerBar.test.ts`:**
Mocks `$lib/player/store.svelte` to control state and spy on actions.
- Renders nothing when `player.current === undefined` (or Shell doesn't mount it — tests render PlayerBar directly with a current track).
- Renders cover, title, artist link (`href='/artists/:id'`), cover link (`href='/albums/:id'`).
- Play button click calls `togglePlay`.
- Skip-next/prev click call `skipNext`/`skipPrev`.
- Skip-prev disabled when index=0 AND position < 3.
- Skip-next disabled when index=length-1, repeat='off'.
- Seek slider input calls `seekTo(value)`.
- Volume slider input calls `setVolume(value)`.
- Shuffle button click calls `toggleShuffle`; active state reflects `player.shuffle`.
- Repeat button click calls `cycleRepeat`; three visual states render distinctly (assert on `aria-label` variants).
- State='loading' or 'waiting': play icon replaced by spinner (assert on a `data-testid` or SVG class).
- State='error': transport region replaced by retry card; clicking retry calls `playQueue` with `player.queue` + `player.index`.
- Elapsed + total use `formatDuration` (reuse from library plan).
**Unit tests — `TrackRow.test.ts` (updated):**
Keep existing tests; add:
- Clicking the row calls `playQueue(tracksProp, indexProp)` — mock the store module.
- Row renders as a `<button>` (or `<div role="button" tabindex="0">` — spec requires a keyboard-activatable element; `<button>` is simpler).
**Manual integration (Plan Task N verification):**
1. Sign in, navigate to an album, click track 3 → bar appears, track plays.
2. OS-level check: lock-screen / media notification shows the track metadata and allows play/pause.
3. Press media keys on keyboard → play/pause toggles.
4. Drag seek slider mid-track → audio jumps to new position.
5. Click skip-next through end of album → stops.
6. Turn on repeat=all → skip-next past end wraps to track 1.
7. Turn on repeat=one → track loops at end.
8. Turn on shuffle → future skips land on random remaining tracks.
9. Adjust volume; refresh the page; navigate to a new track — volume restored from localStorage.
10. Disconnect network mid-track → error card appears; click "Try again" → recovers when network returns.
11. Navigate between artists / albums / the library root while a track is playing — playback continues, bar stays put.
## Dependencies added
None. Everything uses the existing stack (SvelteKit 2, Svelte 5 runes, Vitest, Testing Library, jsdom). The MediaSession API is native to browsers; no polyfill.
## Success criteria
- Clicking any track starts playback with the rest of the album queued.
- All controls (play/pause, skip ±1, seek, volume, shuffle, repeat) behave per Section 3 of this spec.
- MediaSession metadata and action handlers are populated; OS media controls work.
- Playback survives navigation within the SPA.
- Volume persists across page loads.
- Broken streams show the error card; retry recovers.
- All new unit + page tests pass (`npm test` in `web/`).
- No backend changes — the plan is pure frontend consumption of the existing `/api/tracks/:id/stream`.
@@ -0,0 +1,221 @@
# M2 Events Sub-Plan — Design Spec
**Status:** approved 2026-04-25
**Slice:** M2 (Events, sessions, general likes), spec §13 step 5. General likes (step 6) deferred to a follow-up sub-plan.
**Fable tasks:** #322 (schema), #323 (session service), #324 (events API), #325 (web UI wiring) — bundled into a single PR for this slice.
## Goal
Ship the play-event ingestion pipeline end-to-end: schema, session service, `POST /api/events` handler, Subsonic `/rest/scrobble` integration, and the SvelteKit player wiring that emits the events. Output is a populated `play_events` / `skip_events` / `sessions` set that M3's recommendation engine can read from. No recommendation logic in this slice; this is the data foundation.
## Non-goals
- General likes table, `/api/likes` endpoint, Subsonic `star/unstar` wiring — own M2 sub-plan.
- `contextual_likes`, `artist_preferences` — M3 (algorithm consumers).
- `session_vector_at_play` JSONB population — M3 (the algorithm computes it). The column ships in this slice as nullable.
- Background janitor for abandoned `ended_at IS NULL` rows. Auto-close-on-next-play handles the common case; user-never-comes-back leaves at most one open row per user, which doesn't impact recommendation queries (they filter `ended_at IS NOT NULL`).
- Multi-device session merging. Each `play_started` opens a new row regardless of whether one is already open elsewhere; M3 decides what to do when interleaved.
- Listen-history UI. The Web UI surfaces nothing about events in this slice — the wiring is invisible to the user.
## Architecture
A new database migration adds `play_events`, `skip_events`, and `sessions` per spec §5. A small Go package `internal/sessions` exposes a `FindOrCreate` helper implementing the 30-minute rolling-window rule from spec §6. A second package `internal/playevents` owns the write paths (start, end, skip, synthetic-completed) so both the JSON API handler and the existing Subsonic scrobble handler share one source of truth. A new HTTP handler `POST /api/events` accepts a discriminated-union JSON payload covering the three client-side event types. The web SPA gains a sibling module to the player store that watches state transitions and dispatches events.
**Event lifecycle:** start + end (option 1 from brainstorm). Each track produces one `play_events` row inserted at start (`ended_at` NULL) and updated at end. The skip-classification rule (spec §6) runs at end-time: a play is a SKIP if `completion_ratio < 0.5 AND duration_played_ms < 30000`. Both conditions must fail (i.e., either ≥50% OR ≥30s) for it to count as a play.
**Subsonic scrobble integration:** Third-party Subsonic clients call `/rest/scrobble` to record plays. Today the handler is a no-op stub. After this slice, `submission=false` writes a `play_started` (replacing the in-memory `nowPlayingMap`); `submission=true` writes a synthetic completed play (`play_started + play_ended` in one transaction). This puts mobile/desktop Subsonic clients on equal footing with the web SPA in M3's recommendation pipeline.
**Abandoned plays:** The web SPA uses `navigator.sendBeacon('/api/events', play_skipped)` on `pagehide` so graceful tab-close still completes the row. For ungraceful cases (network drop, browser crash, OS kill), the events handler's `play_started` path begins by auto-closing any prior `ended_at IS NULL` row for the same user. The close uses `ended_at = now()`, `duration_played_ms = min(now() - started_at, track.duration_ms)` (in ms), and `was_skipped = true`. The completion ratio is recomputed and the skip rule from spec §6 is *not* applied (auto-closed rows are flagged as skipped regardless, since we don't know how much the user actually heard). At most one open row per user at any time.
## Schema (migration `0005_events.up.sql`)
```sql
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL,
ended_at timestamptz,
last_event_at timestamptz NOT NULL,
track_count integer NOT NULL DEFAULT 0,
client_id text
);
CREATE INDEX sessions_user_last_event_idx ON sessions (user_id, last_event_at DESC);
CREATE TABLE play_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL,
ended_at timestamptz,
duration_played_ms integer,
completion_ratio double precision,
was_skipped boolean NOT NULL DEFAULT false,
client_id text,
session_vector_at_play jsonb,
scrobbled_at timestamptz
);
CREATE INDEX play_events_user_started_idx ON play_events (user_id, started_at DESC);
CREATE INDEX play_events_user_track_idx ON play_events (user_id, track_id);
CREATE TABLE skip_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
skipped_at timestamptz NOT NULL,
position_ms integer NOT NULL
);
CREATE INDEX skip_events_user_skipped_idx ON skip_events (user_id, skipped_at DESC);
```
`session_vector_at_play` lands now as nullable so M3 doesn't need a follow-up migration; M2 writes NULL.
## API contracts
### `POST /api/events`
```ts
type EventRequest =
| { type: 'play_started'; track_id: string; at?: string; client_id?: string }
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number; at?: string }
| { type: 'play_skipped'; play_event_id: string; position_ms: number; at?: string };
type EventResponse =
| { play_event_id: string; session_id: string } // for play_started
| { ok: true }; // for play_ended / play_skipped
```
`at` defaults to server `now()` if omitted. `client_id` defaults to null.
**Errors:**
- `400 bad_request` — missing/invalid `type`; required fields missing or malformed; UUIDs not parseable; `duration_played_ms` or `position_ms` negative.
- `404 not_found``track_id` doesn't exist, or `play_event_id` doesn't exist.
- `403 forbidden``play_event_id` belongs to a different user (event UUIDs aren't secrets, but writes must respect ownership).
- `500 server_error` — DB issue.
### `/rest/scrobble` (Subsonic — existing endpoint, behavior change only)
Request shape unchanged. Behavior:
- `submission=false&id=X&time=T` → calls `playevents.RecordPlayStarted(userID, trackID, clientID=c, at=T)` then returns the standard Subsonic `ok` envelope. Replaces the in-memory `nowPlayingMap`.
- `submission=true&id=X&time=T` → calls `playevents.RecordSyntheticCompletedPlay(userID, trackID, clientID=c, at=T)` which writes `play_started` and `play_ended` together in a single transaction. `started_at = T`, `ended_at = T + track.duration_ms`, `duration_played_ms = track.duration_ms`, `was_skipped = false` (Subsonic clients don't tell us if a play was skipped — assume completion).
- Other `submission` values: ignored, ack with `ok` (matches current behavior).
Response stays the standard Subsonic `ok` envelope; no new fields.
## Components & files
### New server files
| Path | Responsibility |
|---|---|
| `internal/db/migrations/0005_events.up.sql` + `.down.sql` | Schema for `play_events`, `skip_events`, `sessions`. |
| `internal/db/dbq/events.sql` (sqlc input) + generated `events.sql.go` | `InsertSession`, `UpdateSessionLastEvent`, `GetMostRecentSessionForUser`, `InsertPlayEvent`, `UpdatePlayEventEnded`, `InsertSkipEvent`, `CloseAbandonedPlayEventsForUser`, `GetOpenPlayEventByUser`. |
| `internal/sessions/service.go` | `FindOrCreate(ctx, tx, userID, eventTime, clientID, timeout) (sessionID, error)`. Composable into a larger transaction. |
| `internal/sessions/service_test.go` | Live-DB tests covering the four scenarios above. |
| `internal/playevents/writer.go` | `RecordPlayStarted`, `RecordPlayEnded`, `RecordPlaySkipped`, `RecordSyntheticCompletedPlay`. Owns the auto-close-prior step, skip classification rule, skip_events writes. |
| `internal/playevents/writer_test.go` | Direct tests of rule edges (49/50% × 29/30s), auto-close logic, synthetic write. |
| `internal/api/events.go` | `handleEvents` HTTP handler — JSON decode, dispatch by `type`, call into `playevents.writer`. |
| `internal/api/events_test.go` | HTTP-level coverage for happy path, validation errors, ownership-mismatch. |
### Modified server files
| Path | Change |
|---|---|
| `internal/api/api.go` | Register `authed.Post("/events", h.handleEvents)`. |
| `internal/subsonic/stream.go` | `handleScrobble` calls `playevents.writer` instead of `nowPlayingMap`. The `nowPlayingMap` struct + `newNowPlayingMap` + the `nowPlaying` field are deleted. The `mediaHandlers` constructor signature simplifies. |
| `internal/subsonic/stream_test.go` | Update existing scrobble test for the new behavior; add a `submission=true` case. |
| `internal/config/config.go` + `config.example.yaml` | New `events:` section: `session_timeout_minutes` (default 30), `skip_max_completion_ratio` (default 0.5), `skip_max_duration_played_ms` (default 30000). |
### New web files
| Path | Responsibility |
|---|---|
| `web/src/lib/player/events.svelte.ts` | `useEventsDispatcher()``$effect`-based watcher on `player.current` / `player.state`; owns `_playEventId` state; calls `api.post`; installs `pagehide` listener that uses `navigator.sendBeacon`. |
| `web/src/lib/player/events.svelte.test.ts` | State-machine tests with mocked `api.post` and spied `navigator.sendBeacon`. |
| `web/src/lib/player/clientId.ts` | `getOrCreateClientId(): string` — sessionStorage-backed UUID per tab. |
### Modified web files
| Path | Change |
|---|---|
| `web/src/lib/api/types.ts` | Add `EventRequest`, `EventResponse` types matching the server contracts. |
| `web/src/lib/api/client.ts` | Add `api.post<T>(path, body)` helper if not already present. |
| `web/src/routes/+layout.svelte` | Call `useEventsDispatcher()` once at mount, alongside the existing `useMediaSession()`. |
## Data flow
**Web SPA path:**
1. User clicks track → store mutates: `_state = 'loading'`, `_queue = tracks`, `_index = 0`, `_position = 0`.
2. `<audio>` loads → fires `playing` → store transitions to `_state = 'playing'`.
3. Events module's `$effect` sees `(player.current, player.state === 'playing')` for the first time on this track → `POST /api/events { type: 'play_started', track_id, client_id, at }` → server returns `{ play_event_id }` → cached in module state.
4. Track plays. No emissions during playback.
5. Track ends naturally → audio fires `ended` → store advances to next track or pauses → events module sees the transition out of `playing` for THIS track → `POST { type: 'play_ended', play_event_id, duration_played_ms, at }` → server updates the row, applies skip classification, optionally writes a `skip_events` row.
6. Cycle repeats.
**Skip mid-track:** `skipNext()` advances queue → audio src changes → events module sees `player.current` change while `_playEventId` is still set → `POST { type: 'play_skipped', play_event_id, position_ms = _position, at }` → then on the new track's `playing` event, fires `play_started` for the new track.
**Tab close:** `pagehide` listener fires → `navigator.sendBeacon('/api/events', { type: 'play_skipped', play_event_id, position_ms = _position, at })`.
**Crash / network drop:** No `play_ended` ever sent. Row stays `ended_at IS NULL`. Next time the user starts ANY track on any client, the events handler's auto-close step closes the abandoned row before inserting the new one. Per the formula above: `ended_at = now()`, `duration_played_ms = min(now() - started_at, track.duration_ms)`, `was_skipped = true`.
**Subsonic path:**
- `scrobble?submission=false` → handler calls `playevents.RecordPlayStarted`. Server has live "now playing" via `play_events WHERE ended_at IS NULL` (not used yet but available for M3+).
- `scrobble?submission=true` → handler calls `playevents.RecordSyntheticCompletedPlay` which writes both rows in one transaction.
**Idempotency:** `play_ended` / `play_skipped` are naturally idempotent (each carries the row id). `play_started` is not — re-sending creates a new row. The auto-close-prior step protects the table from leaking open rows.
## Testing
### Server (`go test`)
- **Migration smoke** (`internal/db/db_test.go` extension): applying `0005_events` on a fresh test DB succeeds; round-trip insert+select for each new table.
- **Session service** (`internal/sessions/service_test.go`):
- No prior session → creates one with `started_at = eventTime`, `track_count = 0`.
- Within window → returns existing session id, updates `last_event_at`.
- Beyond window → creates a new session.
- Concurrent inserts (`t.Parallel` with shared user, transactional fixture) don't double-create.
- Uses a `Clock` interface so tests fast-forward without `time.Sleep`.
- **playevents writer** (`internal/playevents/writer_test.go`):
- Skip rule boundaries: `completion=0.49, duration=29s` → skip; `completion=0.5, duration=29s` → not skip; `completion=0.49, duration=30s` → not skip.
- Auto-close-prior: prior open row gets closed with computed `ended_at` and `was_skipped=true` when a new `play_started` arrives.
- Synthetic completed play writes both rows + the `was_skipped=false` flag in a single transaction.
- **Events handler** (`internal/api/events_test.go`):
- Happy path for all three event types.
- 400 on missing `type`; 400 on `play_ended` referencing a malformed UUID; 400 on negative `duration_played_ms`.
- 404 on `track_id` that doesn't exist; 404 on `play_event_id` that doesn't exist.
- 403 on `play_event_id` belonging to a different user.
- **Subsonic scrobble update** (`internal/subsonic/stream_test.go` extension):
- Existing `submission=false` test now asserts a `play_events` row was inserted.
- New `submission=true` test asserts both `play_started + play_ended` rows are written and the response is the standard Subsonic envelope.
- Test-only code that referenced the deleted `nowPlayingMap` is removed.
### Web (`vitest`)
- **`events.svelte.test.ts`** with `vi.mock('$lib/api/client', ...)` and `vi.spyOn(navigator, 'sendBeacon')`:
- Track 1 starts → exactly one `POST` with `type: 'play_started'`.
- Server returns `play_event_id: 'pe1'`, then track ends → exactly one POST with `play_ended` carrying `pe1`.
- User skips mid-track 2 → POST with `play_skipped` carrying that row's id.
- Loading a new track via `playRadio()` while track 2 is still playing → POSTs `play_skipped` for track 2 BEFORE `play_started` for the new track.
- `pagehide` event with an active play → `navigator.sendBeacon` called once with `play_skipped` payload.
### End-to-end manual
Final task in the implementation plan, against `docker compose up --build -d`:
1. Sign in to web SPA. Play a track. `psql -c "SELECT * FROM play_events"` shows one row, `ended_at IS NULL`, correct user/track/session.
2. Let track finish. Same row now has `ended_at`, `was_skipped=false`.
3. Skip mid-track (within 30s of start). New row created on next track; previous row's `was_skipped=true` and `skip_events` has the matching row.
4. Wait > 30 minutes. Play. New `sessions` row with different id from the first session.
5. Open Feishin/Symfonium pointed at the same instance. Play a track to completion. `play_events` shows a synthetic row.
6. Close the web tab mid-track. `play_events` last row was closed via sendBeacon (timestamp ≈ tab close).
7. Disconnect network mid-play, force-quit browser. Reconnect, sign back in, start a new track. Previous abandoned row gets auto-closed.
## Risks & mitigations
- **Event spam from buggy clients.** A misbehaving client could fire `play_started` in a loop, leaking rows. Mitigation: the auto-close-prior step caps each user at one open row at a time. Worst-case is one closed-row insert per `play_started` call, which is bounded by client-side sanity. Future M2.5 could add per-user rate limiting.
- **Clock skew between client and server.** The client sends `at` timestamps; if the client's clock is wildly off, sessions could be assigned incorrectly (same user might see a "new session" for events that should extend the current one). Mitigation: server uses its own `now()` if `at` is omitted, and the events module always omits `at` (lets the server timestamp). The `at` field exists for the Subsonic compatibility path (Subsonic clients send `time=` and we honor it).
- **Subsonic synthetic completed plays are too coarse.** A client that scrobbles after a 90% play is treated identically to a 100% play. Mitigation: Subsonic protocol doesn't expose finer info; this is a known limitation. M3's recommendation engine should weight Subsonic-sourced plays slightly lower if it ever matters in practice.
- **`nowPlayingMap` deletion breaks an unknown caller.** Verified there are no consumers of the existing in-memory map outside `handleScrobble` itself; the constructor change is local. Migration is safe.
- **JSONB `session_vector_at_play` ships nullable but is referenced in M3 plans.** No M3 task starts in this slice; the column is purely forward-compatibility. Documented in the schema comment.
@@ -0,0 +1,189 @@
# Web UI Search — Design Spec
**Status:** approved 2026-04-25
**Slice:** M6 sub-plan #5 (after Player). Backend `GET /api/search` already exists; this slice ships the SPA UI plus a small `/api/radio` stub.
## Goal
Replace the `/search` placeholder with a working search experience: a persistent header search box, live debounced results across artists/albums/tracks, per-facet overflow pages, click-to-play-as-radio for tracks, and an "add to queue" affordance on tracks and albums.
## Non-goals
- Real radio recommendations. The `/api/radio` endpoint ships as a stub returning `{ tracks: [seed] }`. The full M4 implementation (similarity-driven candidate pool + scoring + 80% queue refresh) lands later; only the request/response contract is locked here.
- Search-result pre-fetch / typeahead suggestions / autocomplete. Plain results page only.
- Mobile-specific layout polish. Header bar fits on desktop; mobile responsive pass is its own future slice.
- Keyboard shortcuts (e.g. Cmd-K to focus search). Out of scope.
- Cross-page search history / recent-searches list.
## Architecture
A persistent `SearchInput` component lives in `Shell.svelte`'s header. It binds a local `value` state, debounces 250 ms, and uses `goto()` to keep `/search?q=…` synced. First nav from elsewhere is a normal push; subsequent typing while on `/search` uses `replaceState` so back-navigation skips the typing log. Empty input on `/search` clears the param.
`/search/+page.svelte` reads `?q=` reactively and runs a TanStack Query against `GET /api/search?q=…&limit=10`, which returns three small paged facets. The page renders up to three sections (Artists / Albums / Tracks), each reusing the existing library components. Each section also renders a "See all N →" link to its overflow page when `total > items.length`. Empty facets are not rendered.
Three overflow pages — `/search/artists/`, `/search/albums/`, `/search/tracks/` — each read `?q=` and run their own `createInfiniteQuery` against the same `/api/search` endpoint with `limit=50`. They render only their own facet, ignoring the other two facets in the response (minor over-fetch, simpler than three new endpoints). "Load more" pulls the next page.
The Track click handler on the search results page calls a new player-store action `playRadio(seedTrackId)` which fetches `/api/radio?seed_track=…` and pipes the response into `playQueue(tracks, 0)`. On the album-detail page, Track clicks keep their current behavior (queue the album).
A new `+ queue` button on each track row and album card calls `enqueueTrack` / `enqueueTracks`, which append to the existing `_queue` rune state without disturbing the current playback or `_index`.
## API contracts
### `GET /api/search?q=&limit=&offset=` (existing — no change)
```ts
type SearchResponse = {
artists: Page<ArtistRef>;
albums: Page<AlbumRef>;
tracks: Page<TrackRef>;
};
type Page<T> = { items: T[]; total: number; limit: number; offset: number };
```
Server applies the shared `limit/offset` to each facet's `LIMIT/OFFSET` query independently. `q` is required; empty/whitespace is `400`. Default `limit` is bounded server-side; this slice sends `10` from the main `/search` page and `50` from each overflow page.
### `GET /api/radio?seed_track=<uuid>` (new — stub)
```ts
type RadioResponse = { tracks: TrackRef[] };
```
Stub behavior: `200` with `{ tracks: [<seed_track>] }` (one entry, the seed itself). `404 { code: 'not_found' }` if the track id doesn't exist. `400 { code: 'bad_request' }` if `seed_track` is missing or empty. Same `TrackRef` shape used everywhere else; `resolveTrackRefs` builds the response.
When M4 fills the endpoint, the response shape is final: extra entries appended after the seed, same `TrackRef` shape.
## Components & files
### New
| Path | Responsibility |
|---|---|
| `web/src/lib/components/SearchInput.svelte` | Header search box; debounced URL sync via `goto`; pre-fills from `page.url.searchParams` |
| `web/src/lib/components/SearchSkeleton.svelte` | Three-section placeholder (artist list / album grid / track list shapes) for `/search` loading state |
| `web/src/routes/search/+page.svelte` | Main results page — reads `?q=`, fires search query, renders three sections |
| `web/src/routes/search/artists/+page.svelte` | Artists overflow with `createInfiniteQuery` + Load more |
| `web/src/routes/search/albums/+page.svelte` | Albums overflow with `createInfiniteQuery` + Load more |
| `web/src/routes/search/tracks/+page.svelte` | Tracks overflow with `createInfiniteQuery` + Load more |
| `web/src/lib/components/SearchInput.test.ts` | Debounce / pre-fill / clear behavior |
| `web/src/routes/search/search.test.ts` | Page integration: 3 sections, empty hide, "See all" link, no-results, empty `?q=` prompt |
| `web/src/routes/search/artists/artists.test.ts` | Overflow tests (analogous for `albums.test.ts`, `tracks.test.ts`) |
| `internal/api/radio.go` | Stub handler |
| `internal/api/radio_test.go` | Stub tests (200/404/400) |
### Modified
| Path | Change |
|---|---|
| `web/src/lib/components/Shell.svelte` | Insert `<SearchInput />` in the header between brand and user-menu |
| `web/src/lib/components/TrackRow.svelte` | Refactor outer element from `<button>` to `<div role="button" tabindex="0">` with keyboard handlers (Enter/Space) so a real `<button>` for `+ queue` can nest inside without invalid HTML. Add optional `onPlay?: (track: TrackRef, index: number) => void` prop; default behavior remains `playQueue(tracks, index)` |
| `web/src/lib/components/TrackRow.test.ts` | Update for `role=button` keyboard activation; add `+ queue` click test; add `onPlay` override test |
| `web/src/lib/components/AlbumCard.svelte` | Wrap `<a href="/albums/:id">` around cover+title; add an absolutely-positioned `+ queue` button as a sibling. Click on the link still navigates; click on `+` calls `event.preventDefault()` + `event.stopPropagation()` then `api.get<AlbumDetail>('/api/albums/' + album_id)` (one-shot fetch, not the live `createAlbumQuery` store) and calls `enqueueTracks(detail.tracks)` |
| `web/src/lib/components/AlbumCard.test.ts` | Add `+ queue` click test |
| `web/src/lib/api/types.ts` | Add `SearchResponse`, `RadioResponse` |
| `web/src/lib/api/queries.ts` | Add `createSearchQuery(q)`, `createSearchArtistsInfiniteQuery(q)`, `createSearchAlbumsInfiniteQuery(q)`, `createSearchTracksInfiniteQuery(q)` |
| `web/src/lib/player/store.svelte.ts` | Add `enqueueTrack(t)`, `enqueueTracks(ts)`, `playRadio(seedTrackId)` actions |
| `web/src/lib/player/store.test.ts` | Cover `enqueueTrack` / `enqueueTracks` / `playRadio` |
| `internal/api/api.go` | Register `r.Get("/api/radio", h.handleRadio)` |
## URL & state behavior
- `SearchInput` debounces 250 ms before navigating. Each settled value either pushes (first nav from elsewhere) or replaces (already on `/search`) the URL.
- Empty input on `/search``goto('/search')` (drops `?q=`).
- `Enter` blurs the input; no extra nav.
- `Escape` clears `value` (which then navigates per the rules above).
- `/search/+page.svelte` reads `q` via `$derived((page.url.searchParams.get('q') ?? '').trim())`. When `q === ''`, no query is created (empty-state copy renders).
- Overflow pages read `q` the same way; they show their own empty state copy when `q === ''`.
- TanStack Query caches per-`q` results, so backspacing through previous values returns instantly.
## States
| Condition | Render |
|---|---|
| `q === ''` (main page) | "Start typing to search artists, albums, and tracks." |
| `q` non-empty AND `useDelayed(() => query.isPending) === true` AND no cached data | `SearchSkeleton` |
| `query.isError` | `ApiErrorBanner` with `onRetry={query.refetch}` |
| All three facets `items.length === 0` | "No matches for '<q>'." |
| Otherwise | Up to three sections; empty facets are skipped |
Overflow pages: same skeleton/error/empty pattern (using `LibrarySkeleton` for the per-facet variant). "Load more" button hides when `hasNextPage === false`.
## Player-store additions
```ts
export function enqueueTrack(t: TrackRef): void {
_queue = [..._queue, t];
if (_state === 'idle') _index = 0;
}
export function enqueueTracks(ts: TrackRef[]): void {
if (ts.length === 0) return;
_queue = [..._queue, ...ts];
if (_state === 'idle') _index = 0;
}
export async function playRadio(seedTrackId: string): Promise<void> {
const resp = await api.get<RadioResponse>(
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
);
if (resp.tracks.length > 0) playQueue(resp.tracks, 0);
}
```
`enqueueTrack`/`enqueueTracks` deliberately do NOT change `_state` — appending while playing should not pause; appending while paused should not auto-play. The `idle → 0` reset only matters when the queue was previously empty so that `_index` points at a valid position; the rest of the playback machinery (`+layout.svelte` audio effects, MediaSession glue) reacts to `_state` and `player.current` and continues to work unchanged.
## Testing
**Server (`internal/api/radio_test.go`):**
- 200 success path with valid `seed_track` (response shape, single-item array, full `TrackRef`).
- 404 on nonexistent track id.
- 400 on missing/empty `seed_track`.
**Player store (`store.test.ts`):**
- `enqueueTrack` appends; preserves `_index` when queue was non-empty; sets `_index=0` when state was `'idle'`.
- `enqueueTracks([])` no-ops; non-empty concatenates.
- `playRadio` (mocked `api.get`) hits `/api/radio?seed_track=<id>` and forwards response to `playQueue`.
**`SearchInput.test.ts`:**
- Single keystroke fires `goto` once after 250 ms (`vi.useFakeTimers` + `flushSync`).
- Burst of keystrokes in <250 ms fires `goto` once after settle.
- Empty input on `/search` calls `goto('/search')`.
- Mount pre-fills `value` from `page.url.searchParams.get('q')`.
**`search/search.test.ts`:**
- All three sections render when all three facets have items.
- Empty facets are hidden.
- "See all N →" links render when `total > items.length`.
- "No matches" copy renders when all three facets are empty.
- Empty `?q=` shows "Start typing" prompt and fires NO query.
**`search/artists/artists.test.ts`** (and analogous `albums.test.ts`, `tracks.test.ts`):
- Items render.
- "Load more" calls `fetchNextPage`.
- Button hides when `hasNextPage === false`.
**`TrackRow.test.ts` (updates):**
- `role=button` Enter/Space activation calls the play handler.
- Click on the nested `+` button calls `enqueueTrack` and does NOT trigger the row's play handler.
- `onPlay` prop overrides the default `playQueue` behavior.
**`AlbumCard.test.ts` (additions):**
- Click on `+` calls `enqueueTracks(album.tracks)` after fetching album detail; does NOT navigate.
- Click on the card area still navigates to `/albums/:id`.
**Manual end-to-end** (verified during the plan's final task):
1. From any page, type "miles" in the header bar → URL becomes `/search?q=miles`, three sections appear.
2. Click a track → radio plays (one-track queue today via the stub); the bottom bar shows the seed.
3. Click `+` on another track → appended to queue (verify queue length increments via `player.queue.length` in dev tools).
4. Click `+` on an album card → all the album's tracks append; current playback uninterrupted.
5. Click "See all 47 artists →" → overflow page loads; "Load more" pulls additional pages.
6. Click an artist → navigates to `/artists/:id`; the header search bar still shows "miles".
7. Browser back → returns to `/search?q=miles` with cached results.
8. Backspace through the input → URL clears; main page returns to "Start typing" prompt.
## Risks & mitigations
- **Radio stub looks broken.** A click on a track currently produces a one-track queue, which feels like "click did nothing meaningful." Mitigation: skeleton MVP framing; the contract is forward-compatible and the M4 task will deliver the real candidate-pool logic. The fallback playback works; the limitation is honesty about scope.
- **TrackRow refactor regresses album page.** The `<button>``<div role="button">` change touches an already-shipped surface. Mitigation: existing TrackRow tests run as part of the slice; the album-page tests (`src/routes/albums/[id]/album.test.ts`) re-run; manual click + keyboard activation verified before the slice ships.
- **Header search bar squeezes mobile layout.** Mitigation: the input gets `flex-1 min-w-0` so it shrinks gracefully; deeper mobile polish is a separate slice.
- **Per-keystroke `goto` history pollution.** Already mitigated via `replaceState` once the user is on `/search`.
- **Over-fetch on overflow pages.** Each overflow page receives all three facets in the response. Acceptable: the request is one round-trip and the unused fields are bounded by `limit=50` × 2 facets = ≤ 100 unused records. Migrating to per-facet endpoints later is a backward-compatible move if it ever becomes load-bearing.
+8 -3
View File
@@ -11,12 +11,14 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) {
h := &handlers{pool: pool, logger: logger}
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer) {
h := &handlers{pool: pool, logger: logger, events: events}
r.Route("/api", func(api chi.Router) {
api.Post("/auth/login", h.handleLogin)
@@ -32,6 +34,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) {
authed.Get("/tracks/{id}", h.handleGetTrack)
authed.Get("/tracks/{id}/stream", h.handleGetStream)
authed.Get("/search", h.handleSearch)
authed.Get("/radio", h.handleRadio)
authed.Post("/events", h.handleEvents)
})
})
}
@@ -39,4 +43,5 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) {
type handlers struct {
pool *pgxpool.Pool
logger *slog.Logger
events *playevents.Writer
}
+6 -2
View File
@@ -12,12 +12,15 @@ import (
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
@@ -42,10 +45,11 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(context.Background(),
"TRUNCATE sessions, users RESTART IDENTITY CASCADE"); err != nil {
"TRUNCATE play_events, skip_events, play_sessions, sessions, users RESTART IDENTITY CASCADE"); err != nil {
t.Fatalf("truncate: %v", err)
}
return &handlers{pool: pool, logger: logger}, pool
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
return &handlers{pool: pool, logger: logger, events: w}, pool
}
func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User {
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
type eventRequest struct {
Type string `json:"type"`
TrackID string `json:"track_id"`
PlayEventID string `json:"play_event_id"`
DurationPlayedMs *int32 `json:"duration_played_ms"`
PositionMs *int32 `json:"position_ms"`
At *string `json:"at"`
ClientID *string `json:"client_id"`
}
type playStartedResponse struct {
PlayEventID string `json:"play_event_id"`
SessionID string `json:"session_id"`
}
type okResponse struct {
OK bool `json:"ok"`
}
func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
var req eventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
return
}
at := time.Now().UTC()
if req.At != nil && *req.At != "" {
parsed, err := time.Parse(time.RFC3339, *req.At)
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid `at` timestamp")
return
}
at = parsed
}
clientID := ""
if req.ClientID != nil {
clientID = *req.ClientID
}
switch req.Type {
case "play_started":
h.handleEventPlayStarted(w, r, user, req, at, clientID)
case "play_ended":
h.handleEventPlayEnded(w, r, user, req, at)
case "play_skipped":
h.handleEventPlaySkipped(w, r, user, req, at)
default:
writeErr(w, http.StatusBadRequest, "bad_request", "unknown event type")
}
}
func (h *handlers) handleEventPlayStarted(
w http.ResponseWriter, r *http.Request,
user dbq.User, req eventRequest, at time.Time, clientID string,
) {
trackID, ok := parseUUID(req.TrackID)
if !ok {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track_id")
return
}
if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "track not found")
return
}
h.logger.Error("api: events: lookup track", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
res, err := h.events.RecordPlayStarted(r.Context(), user.ID, trackID, clientID, at)
if err != nil {
h.logger.Error("api: events: play_started", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "record failed")
return
}
writeJSON(w, http.StatusOK, playStartedResponse{
PlayEventID: uuidToString(res.PlayEventID),
SessionID: uuidToString(res.SessionID),
})
}
func (h *handlers) handleEventPlayEnded(
w http.ResponseWriter, r *http.Request,
user dbq.User, req eventRequest, at time.Time,
) {
playEventID, ok := parseUUID(req.PlayEventID)
if !ok {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid play_event_id")
return
}
if req.DurationPlayedMs == nil || *req.DurationPlayedMs < 0 {
writeErr(w, http.StatusBadRequest, "bad_request", "duration_played_ms required and must be >= 0")
return
}
ev, err := dbq.New(h.pool).GetPlayEventByID(r.Context(), playEventID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "play_event not found")
return
}
h.logger.Error("api: events: lookup play_event", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
if ev.UserID != user.ID {
writeErr(w, http.StatusForbidden, "forbidden", "play_event belongs to a different user")
return
}
if err := h.events.RecordPlayEnded(r.Context(), playEventID, *req.DurationPlayedMs, at); err != nil {
h.logger.Error("api: events: play_ended", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "record failed")
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
func (h *handlers) handleEventPlaySkipped(
w http.ResponseWriter, r *http.Request,
user dbq.User, req eventRequest, at time.Time,
) {
playEventID, ok := parseUUID(req.PlayEventID)
if !ok {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid play_event_id")
return
}
if req.PositionMs == nil || *req.PositionMs < 0 {
writeErr(w, http.StatusBadRequest, "bad_request", "position_ms required and must be >= 0")
return
}
ev, err := dbq.New(h.pool).GetPlayEventByID(r.Context(), playEventID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "play_event not found")
return
}
h.logger.Error("api: events: lookup play_event", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
if ev.UserID != user.ID {
writeErr(w, http.StatusForbidden, "forbidden", "play_event belongs to a different user")
return
}
if err := h.events.RecordPlaySkipped(r.Context(), playEventID, *req.PositionMs, at); err != nil {
h.logger.Error("api: events: play_skipped", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "record failed")
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
+137
View File
@@ -0,0 +1,137 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// callEvents invokes h.handleEvents directly, injecting `user` via the same
// context key RequireUser populates in production. Mirrors the pattern in
// me_test.go (userCtxKeyForTest comes from auth_test.go).
func callEvents(h *handlers, user dbq.User, body []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/events", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
w := httptest.NewRecorder()
h.handleEvents(w, req)
return w
}
func TestHandleEvents_PlayStartedReturnsIDs(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "Beatles")
album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
body, _ := json.Marshal(map[string]any{
"type": "play_started",
"track_id": uuidToString(track.ID),
})
w := callEvents(h, user, body)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var resp struct {
PlayEventID string `json:"play_event_id"`
SessionID string `json:"session_id"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.PlayEventID == "" || resp.SessionID == "" {
t.Errorf("ids empty: %+v", resp)
}
}
func TestHandleEvents_PlayEndedClosesRow(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "Beatles")
album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
startedID := postPlayStartedHelper(t, h, user, track.ID)
body, _ := json.Marshal(map[string]any{
"type": "play_ended",
"play_event_id": startedID,
"duration_played_ms": 180_000,
})
w := callEvents(h, user, body)
if w.Code != http.StatusOK {
t.Fatalf("ended status = %d body=%s", w.Code, w.Body.String())
}
}
func TestHandleEvents_MissingTypeIs400(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "x", false)
body, _ := json.Marshal(map[string]any{"track_id": "00000000-0000-0000-0000-000000000000"})
w := callEvents(h, user, body)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d", w.Code)
}
}
func TestHandleEvents_PlayStartedWithUnknownTrackIs404(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "x", false)
body, _ := json.Marshal(map[string]any{
"type": "play_started",
"track_id": "00000000-0000-0000-0000-000000000000",
})
w := callEvents(h, user, body)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d", w.Code)
}
}
func TestHandleEvents_PlayEndedOnOtherUsersRowIs403(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "x", false)
bob := seedUser(t, pool, "bob", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
track := seedTrack(t, pool, album.ID, artist.ID, "X", 1, 100_000)
startedID := postPlayStartedHelper(t, h, alice, track.ID)
body, _ := json.Marshal(map[string]any{
"type": "play_ended",
"play_event_id": startedID,
"duration_played_ms": 50_000,
})
w := callEvents(h, bob, body)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", w.Code)
}
}
func postPlayStartedHelper(t *testing.T, h *handlers, user dbq.User, trackID pgtype.UUID) string {
t.Helper()
body, _ := json.Marshal(map[string]any{
"type": "play_started",
"track_id": uuidToString(trackID),
})
w := callEvents(h, user, body)
if w.Code != http.StatusOK {
t.Fatalf("play_started status = %d body=%s", w.Code, w.Body.String())
}
var resp struct {
PlayEventID string `json:"play_event_id"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
return resp.PlayEventID
}
+8 -1
View File
@@ -2,11 +2,16 @@ package api
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
// newLibraryRouter builds a test-only chi router with the library handlers
@@ -433,7 +438,9 @@ func TestHandleListArtists_EmptyDB(t *testing.T) {
func TestRoutesRegisteredInMount(t *testing.T) {
h, _ := testHandlers(t)
r := chi.NewRouter()
Mount(r, h.pool, h.logger)
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w)
paths := []string{
"/api/artists",
+60
View File
@@ -0,0 +1,60 @@
package api
import (
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// RadioResponse is the body of GET /api/radio.
type RadioResponse struct {
Tracks []TrackRef `json:"tracks"`
}
// handleRadio implements GET /api/radio?seed_track=<uuid>.
//
// M6 stub: returns a queue containing only the seed track. The full M4
// implementation (similarity-based candidate pool + scoring) replaces the
// body without changing the request/response shape.
func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimSpace(r.URL.Query().Get("seed_track"))
if raw == "" {
writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required")
return
}
id, ok := parseUUID(raw)
if !ok {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id")
return
}
q := dbq.New(h.pool)
track, err := q.GetTrackByID(r.Context(), id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "seed_track not found")
return
}
h.logger.Error("api: get radio seed track failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
album, err := q.GetAlbumByID(r.Context(), track.AlbumID)
if err != nil {
h.logger.Error("api: get radio seed album failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
artist, err := q.GetArtistByID(r.Context(), track.ArtistID)
if err != nil {
h.logger.Error("api: get radio seed artist failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
writeJSON(w, http.StatusOK, RadioResponse{
Tracks: []TrackRef{trackRefFrom(track, album.Title, artist.Name)},
})
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func newRadioRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/radio", h.handleRadio)
return r
}
func TestHandleRadio_Stub_ReturnsSeedOnly(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
artist := seedArtist(t, pool, "Beatles")
album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track="+uuidToString(track.ID), nil)
w := httptest.NewRecorder()
newRadioRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var got struct {
Tracks []TrackRef `json:"tracks"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.Tracks) != 1 {
t.Fatalf("len=%d, want 1", len(got.Tracks))
}
if got.Tracks[0].Title != "Something" {
t.Errorf("title=%q", got.Tracks[0].Title)
}
if got.Tracks[0].AlbumTitle != "Abbey Road" || got.Tracks[0].ArtistName != "Beatles" {
t.Errorf("ref = %+v", got.Tracks[0])
}
}
func TestHandleRadio_NotFound(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=00000000-0000-0000-0000-000000000000", nil)
w := httptest.NewRecorder()
newRadioRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d", w.Code)
}
}
func TestHandleRadio_MissingSeed(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/radio", nil)
w := httptest.NewRecorder()
newRadioRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status=%d, want 400", w.Code)
}
}
func TestHandleRadio_BlankSeed(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=%20%20", nil)
w := httptest.NewRecorder()
newRadioRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status=%d, want 400", w.Code)
}
}
func TestHandleRadio_BadUUID(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=not-a-uuid", nil)
w := httptest.NewRecorder()
newRadioRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status=%d, want 400", w.Code)
}
}
+13
View File
@@ -16,6 +16,7 @@ type Config struct {
Auth AuthConfig `yaml:"auth"`
Library LibraryConfig `yaml:"library"`
Subsonic SubsonicConfig `yaml:"subsonic"`
Events EventsConfig `yaml:"events"`
}
type ServerConfig struct {
@@ -55,11 +56,23 @@ type SubsonicConfig struct {
AllowPlaintextPassword bool `yaml:"allow_plaintext_password"`
}
// EventsConfig governs play-event ingestion: session window, skip rule.
type EventsConfig struct {
SessionTimeoutMinutes int `yaml:"session_timeout_minutes"`
SkipMaxCompletionRatio float64 `yaml:"skip_max_completion_ratio"`
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
}
func Default() Config {
return Config{
Server: ServerConfig{Address: ":4533"},
Database: DatabaseConfig{URL: ""},
Log: LogConfig{Level: "info", Format: "text"},
Events: EventsConfig{
SessionTimeoutMinutes: 30,
SkipMaxCompletionRatio: 0.5,
SkipMaxDurationPlayedMs: 30000,
},
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// sqlc v1.31.1
// source: albums.sql
package dbq
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// sqlc v1.31.1
// source: artists.sql
package dbq
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// sqlc v1.31.1
package dbq
+253
View File
@@ -0,0 +1,253 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: events.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getMostRecentPlaySessionForUser = `-- name: GetMostRecentPlaySessionForUser :one
SELECT id, user_id, started_at, ended_at, last_event_at, track_count, client_id FROM play_sessions
WHERE user_id = $1
ORDER BY last_event_at DESC
LIMIT 1
`
func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pgtype.UUID) (PlaySession, error) {
row := q.db.QueryRow(ctx, getMostRecentPlaySessionForUser, userID)
var i PlaySession
err := row.Scan(
&i.ID,
&i.UserID,
&i.StartedAt,
&i.EndedAt,
&i.LastEventAt,
&i.TrackCount,
&i.ClientID,
)
return i, err
}
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at FROM play_events
WHERE user_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC
LIMIT 1
`
// Returns the most recent play_event for a user where ended_at IS NULL.
// Used by the auto-close-prior step in playevents.RecordPlayStarted.
func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUID) (PlayEvent, error) {
row := q.db.QueryRow(ctx, getOpenPlayEventForUser, userID)
var i PlayEvent
err := row.Scan(
&i.ID,
&i.UserID,
&i.TrackID,
&i.SessionID,
&i.StartedAt,
&i.EndedAt,
&i.DurationPlayedMs,
&i.CompletionRatio,
&i.WasSkipped,
&i.ClientID,
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
)
return i, err
}
const getPlayEventByID = `-- name: GetPlayEventByID :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at FROM play_events WHERE id = $1
`
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
row := q.db.QueryRow(ctx, getPlayEventByID, id)
var i PlayEvent
err := row.Scan(
&i.ID,
&i.UserID,
&i.TrackID,
&i.SessionID,
&i.StartedAt,
&i.EndedAt,
&i.DurationPlayedMs,
&i.CompletionRatio,
&i.WasSkipped,
&i.ClientID,
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
)
return i, err
}
const insertPlayEvent = `-- name: InsertPlayEvent :one
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at
`
type InsertPlayEventParams struct {
UserID pgtype.UUID
TrackID pgtype.UUID
SessionID pgtype.UUID
StartedAt pgtype.Timestamptz
ClientID *string
}
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
row := q.db.QueryRow(ctx, insertPlayEvent,
arg.UserID,
arg.TrackID,
arg.SessionID,
arg.StartedAt,
arg.ClientID,
)
var i PlayEvent
err := row.Scan(
&i.ID,
&i.UserID,
&i.TrackID,
&i.SessionID,
&i.StartedAt,
&i.EndedAt,
&i.DurationPlayedMs,
&i.CompletionRatio,
&i.WasSkipped,
&i.ClientID,
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
)
return i, err
}
const insertPlaySession = `-- name: InsertPlaySession :one
INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, $2, $2, $3)
RETURNING id, user_id, started_at, ended_at, last_event_at, track_count, client_id
`
type InsertPlaySessionParams struct {
UserID pgtype.UUID
StartedAt pgtype.Timestamptz
ClientID *string
}
func (q *Queries) InsertPlaySession(ctx context.Context, arg InsertPlaySessionParams) (PlaySession, error) {
row := q.db.QueryRow(ctx, insertPlaySession, arg.UserID, arg.StartedAt, arg.ClientID)
var i PlaySession
err := row.Scan(
&i.ID,
&i.UserID,
&i.StartedAt,
&i.EndedAt,
&i.LastEventAt,
&i.TrackCount,
&i.ClientID,
)
return i, err
}
const insertSkipEvent = `-- name: InsertSkipEvent :one
INSERT INTO skip_events (user_id, track_id, session_id, skipped_at, position_ms)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, track_id, session_id, skipped_at, position_ms
`
type InsertSkipEventParams struct {
UserID pgtype.UUID
TrackID pgtype.UUID
SessionID pgtype.UUID
SkippedAt pgtype.Timestamptz
PositionMs int32
}
func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams) (SkipEvent, error) {
row := q.db.QueryRow(ctx, insertSkipEvent,
arg.UserID,
arg.TrackID,
arg.SessionID,
arg.SkippedAt,
arg.PositionMs,
)
var i SkipEvent
err := row.Scan(
&i.ID,
&i.UserID,
&i.TrackID,
&i.SessionID,
&i.SkippedAt,
&i.PositionMs,
)
return i, err
}
const touchPlaySessionLastEvent = `-- name: TouchPlaySessionLastEvent :exec
UPDATE play_sessions
SET last_event_at = $2,
track_count = track_count + 1
WHERE id = $1
`
type TouchPlaySessionLastEventParams struct {
ID pgtype.UUID
LastEventAt pgtype.Timestamptz
}
func (q *Queries) TouchPlaySessionLastEvent(ctx context.Context, arg TouchPlaySessionLastEventParams) error {
_, err := q.db.Exec(ctx, touchPlaySessionLastEvent, arg.ID, arg.LastEventAt)
return err
}
const updatePlayEventEnded = `-- name: UpdatePlayEventEnded :one
UPDATE play_events
SET ended_at = $2,
duration_played_ms = $3,
completion_ratio = $4,
was_skipped = $5
WHERE id = $1
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at
`
type UpdatePlayEventEndedParams struct {
ID pgtype.UUID
EndedAt pgtype.Timestamptz
DurationPlayedMs *int32
CompletionRatio *float64
WasSkipped bool
}
// Closes a play_event by id with the given ended_at, duration, and skip flag.
// completion_ratio is computed from duration_played_ms and the track duration
// (looked up by the caller — sqlc doesn't do joins on UPDATE).
func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventEndedParams) (PlayEvent, error) {
row := q.db.QueryRow(ctx, updatePlayEventEnded,
arg.ID,
arg.EndedAt,
arg.DurationPlayedMs,
arg.CompletionRatio,
arg.WasSkipped,
)
var i PlayEvent
err := row.Scan(
&i.ID,
&i.UserID,
&i.TrackID,
&i.SessionID,
&i.StartedAt,
&i.EndedAt,
&i.DurationPlayedMs,
&i.CompletionRatio,
&i.WasSkipped,
&i.ClientID,
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
)
return i, err
}
+35 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// sqlc v1.31.1
package dbq
@@ -29,6 +29,31 @@ type Artist struct {
UpdatedAt pgtype.Timestamptz
}
type PlayEvent struct {
ID pgtype.UUID
UserID pgtype.UUID
TrackID pgtype.UUID
SessionID pgtype.UUID
StartedAt pgtype.Timestamptz
EndedAt pgtype.Timestamptz
DurationPlayedMs *int32
CompletionRatio *float64
WasSkipped bool
ClientID *string
SessionVectorAtPlay []byte
ScrobbledAt pgtype.Timestamptz
}
type PlaySession struct {
ID pgtype.UUID
UserID pgtype.UUID
StartedAt pgtype.Timestamptz
EndedAt pgtype.Timestamptz
LastEventAt pgtype.Timestamptz
TrackCount int32
ClientID *string
}
type Session struct {
ID pgtype.UUID
UserID pgtype.UUID
@@ -38,6 +63,15 @@ type Session struct {
LastSeenAt pgtype.Timestamptz
}
type SkipEvent struct {
ID pgtype.UUID
UserID pgtype.UUID
TrackID pgtype.UUID
SessionID pgtype.UUID
SkippedAt pgtype.Timestamptz
PositionMs int32
}
type Track struct {
ID pgtype.UUID
Title string
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// sqlc v1.31.1
// source: sessions.sql
package dbq
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// sqlc v1.31.1
// source: tracks.sql
package dbq
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// sqlc v1.31.1
// source: users.sql
package dbq
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS skip_events;
DROP TABLE IF EXISTS play_events;
DROP TABLE IF EXISTS play_sessions;
+46
View File
@@ -0,0 +1,46 @@
-- M2 listening telemetry: play_sessions, play_events, skip_events.
-- Note the table is named play_sessions (not sessions — that table already
-- exists from migration 0004 and holds HTTP auth sessions). Listening
-- sessions are an entirely separate concept driven by spec §6.
--
-- session_vector_at_play and contextual_likes ship as nullable now even
-- though M2 writes NULL; M3 will populate them without a follow-up
-- migration.
CREATE TABLE play_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL,
ended_at timestamptz,
last_event_at timestamptz NOT NULL,
track_count integer NOT NULL DEFAULT 0,
client_id text
);
CREATE INDEX play_sessions_user_last_event_idx ON play_sessions (user_id, last_event_at DESC);
CREATE TABLE play_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
session_id uuid NOT NULL REFERENCES play_sessions(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL,
ended_at timestamptz,
duration_played_ms integer,
completion_ratio double precision,
was_skipped boolean NOT NULL DEFAULT false,
client_id text,
session_vector_at_play jsonb,
scrobbled_at timestamptz
);
CREATE INDEX play_events_user_started_idx ON play_events (user_id, started_at DESC);
CREATE INDEX play_events_user_track_idx ON play_events (user_id, track_id);
CREATE TABLE skip_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
session_id uuid NOT NULL REFERENCES play_sessions(id) ON DELETE CASCADE,
skipped_at timestamptz NOT NULL,
position_ms integer NOT NULL
);
CREATE INDEX skip_events_user_skipped_idx ON skip_events (user_id, skipped_at DESC);
+50
View File
@@ -0,0 +1,50 @@
-- name: GetMostRecentPlaySessionForUser :one
SELECT * FROM play_sessions
WHERE user_id = $1
ORDER BY last_event_at DESC
LIMIT 1;
-- name: InsertPlaySession :one
INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, $2, $2, $3)
RETURNING *;
-- name: TouchPlaySessionLastEvent :exec
UPDATE play_sessions
SET last_event_at = $2,
track_count = track_count + 1
WHERE id = $1;
-- name: GetOpenPlayEventForUser :one
-- Returns the most recent play_event for a user where ended_at IS NULL.
-- Used by the auto-close-prior step in playevents.RecordPlayStarted.
SELECT * FROM play_events
WHERE user_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC
LIMIT 1;
-- name: InsertPlayEvent :one
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: UpdatePlayEventEnded :one
-- Closes a play_event by id with the given ended_at, duration, and skip flag.
-- completion_ratio is computed from duration_played_ms and the track duration
-- (looked up by the caller — sqlc doesn't do joins on UPDATE).
UPDATE play_events
SET ended_at = $2,
duration_played_ms = $3,
completion_ratio = $4,
was_skipped = $5
WHERE id = $1
RETURNING *;
-- name: GetPlayEventByID :one
SELECT * FROM play_events WHERE id = $1;
-- name: InsertSkipEvent :one
INSERT INTO skip_events (user_id, track_id, session_id, skipped_at, position_ms)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
+286
View File
@@ -0,0 +1,286 @@
// Package playevents owns the four write paths into play_events / skip_events:
// RecordPlayStarted, RecordPlayEnded, RecordPlaySkipped, and
// RecordSyntheticCompletedPlay (used by the Subsonic /rest/scrobble shim).
//
// All paths run inside a single transaction. The auto-close-prior step on
// RecordPlayStarted bounds each user to at most one open play_event at any
// time (per spec data-flow section).
package playevents
import (
"context"
"errors"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playsessions"
)
type Writer struct {
pool *pgxpool.Pool
logger *slog.Logger
sessionTimeout time.Duration
skipMaxCompletionRatio float64
skipMaxDurationPlayedMs int32
}
// NewWriter constructs a writer. timeout is the play-session window; the two
// remaining args are the skip rule thresholds from spec §6.
func NewWriter(
pool *pgxpool.Pool,
logger *slog.Logger,
sessionTimeout time.Duration,
skipMaxCompletionRatio float64,
skipMaxDurationPlayedMs int,
) *Writer {
return &Writer{
pool: pool,
logger: logger,
sessionTimeout: sessionTimeout,
skipMaxCompletionRatio: skipMaxCompletionRatio,
skipMaxDurationPlayedMs: int32(skipMaxDurationPlayedMs),
}
}
type StartedResult struct {
PlayEventID pgtype.UUID
SessionID pgtype.UUID
}
// RecordPlayStarted: in one transaction, auto-close any prior open row for
// the user, FindOrCreate the play_session, insert the new play_event,
// return its id and session id.
func (w *Writer) RecordPlayStarted(
ctx context.Context,
userID, trackID pgtype.UUID,
clientID string,
at time.Time,
) (StartedResult, error) {
var out StartedResult
err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
if err := w.autoClosePriorOpen(ctx, q, userID, at); err != nil {
return err
}
sessionID, err := playsessions.FindOrCreate(ctx, q, userID, at, clientID, w.sessionTimeout)
if err != nil {
return err
}
var clientIDPtr *string
if clientID != "" {
clientIDPtr = &clientID
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
})
if err != nil {
return err
}
out.PlayEventID = ev.ID
out.SessionID = sessionID
return nil
})
return out, err
}
// RecordPlayEnded closes a play_event with duration + computed completion
// ratio. Applies the spec §6 skip rule (AND of completion < threshold AND
// duration_played < threshold) — both conditions must hold for the row to
// be marked was_skipped=true. If skipped, also writes a skip_events row.
func (w *Writer) RecordPlayEnded(
ctx context.Context,
playEventID pgtype.UUID,
durationPlayedMs int32,
at time.Time,
) error {
return pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
ev, err := q.GetPlayEventByID(ctx, playEventID)
if err != nil {
return err
}
track, err := q.GetTrackByID(ctx, ev.TrackID)
if err != nil {
return err
}
ratio := 0.0
if track.DurationMs > 0 {
ratio = float64(durationPlayedMs) / float64(track.DurationMs)
}
isSkip := ratio < w.skipMaxCompletionRatio && durationPlayedMs < w.skipMaxDurationPlayedMs
ratioPtr := ratio
durPtr := durationPlayedMs
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
ID: playEventID,
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
DurationPlayedMs: &durPtr,
CompletionRatio: &ratioPtr,
WasSkipped: isSkip,
}); err != nil {
return err
}
if isSkip {
if _, err := q.InsertSkipEvent(ctx, dbq.InsertSkipEventParams{
UserID: ev.UserID,
TrackID: ev.TrackID,
SessionID: ev.SessionID,
SkippedAt: pgtype.Timestamptz{Time: at, Valid: true},
PositionMs: durationPlayedMs,
}); err != nil {
return err
}
}
return nil
})
}
// RecordPlaySkipped is the user-initiated-skip variant: was_skipped is forced
// true regardless of the rule, and a skip_events row is always written.
func (w *Writer) RecordPlaySkipped(
ctx context.Context,
playEventID pgtype.UUID,
positionMs int32,
at time.Time,
) error {
return pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
ev, err := q.GetPlayEventByID(ctx, playEventID)
if err != nil {
return err
}
track, err := q.GetTrackByID(ctx, ev.TrackID)
if err != nil {
return err
}
ratio := 0.0
if track.DurationMs > 0 {
ratio = float64(positionMs) / float64(track.DurationMs)
}
ratioPtr := ratio
durPtr := positionMs
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
ID: playEventID,
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
DurationPlayedMs: &durPtr,
CompletionRatio: &ratioPtr,
WasSkipped: true,
}); err != nil {
return err
}
_, err = q.InsertSkipEvent(ctx, dbq.InsertSkipEventParams{
UserID: ev.UserID,
TrackID: ev.TrackID,
SessionID: ev.SessionID,
SkippedAt: pgtype.Timestamptz{Time: at, Valid: true},
PositionMs: positionMs,
})
return err
})
}
// RecordSyntheticCompletedPlay writes a start+end pair for a Subsonic
// scrobble?submission=true call. ended_at = at + track.duration_ms,
// duration_played_ms = track.duration_ms, was_skipped = false.
func (w *Writer) RecordSyntheticCompletedPlay(
ctx context.Context,
userID, trackID pgtype.UUID,
clientID string,
at time.Time,
) error {
return pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
track, err := q.GetTrackByID(ctx, trackID)
if err != nil {
return err
}
if err := w.autoClosePriorOpen(ctx, q, userID, at); err != nil {
return err
}
sessionID, err := playsessions.FindOrCreate(ctx, q, userID, at, clientID, w.sessionTimeout)
if err != nil {
return err
}
var clientIDPtr *string
if clientID != "" {
clientIDPtr = &clientID
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
})
if err != nil {
return err
}
ratio := 1.0
dur := track.DurationMs
_, err = q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
ID: ev.ID,
EndedAt: pgtype.Timestamptz{Time: at.Add(time.Duration(track.DurationMs) * time.Millisecond), Valid: true},
DurationPlayedMs: &dur,
CompletionRatio: &ratio,
WasSkipped: false,
})
return err
})
}
// autoClosePriorOpen closes any open (ended_at IS NULL) play_event for the
// user. Sets ended_at = at, duration_played_ms = min(at - started_at, track
// duration), was_skipped = true. Skip rule is NOT applied — auto-closed
// rows are flagged skipped regardless because we don't know what the user
// actually heard.
func (w *Writer) autoClosePriorOpen(
ctx context.Context,
q *dbq.Queries,
userID pgtype.UUID,
at time.Time,
) error {
prior, err := q.GetOpenPlayEventForUser(ctx, userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return err
}
track, err := q.GetTrackByID(ctx, prior.TrackID)
if err != nil {
return err
}
elapsedMs := int32(at.Sub(prior.StartedAt.Time) / time.Millisecond)
if elapsedMs < 0 {
elapsedMs = 0
}
if elapsedMs > track.DurationMs && track.DurationMs > 0 {
elapsedMs = track.DurationMs
}
ratio := 0.0
if track.DurationMs > 0 {
ratio = float64(elapsedMs) / float64(track.DurationMs)
}
ratioPtr := ratio
durPtr := elapsedMs
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
ID: prior.ID,
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
DurationPlayedMs: &durPtr,
CompletionRatio: &ratioPtr,
WasSkipped: true,
}); err != nil {
return err
}
w.logger.Info("playevents: auto-closed prior open row",
"play_event_id", prior.ID, "elapsed_ms", elapsedMs)
return nil
}
+236
View File
@@ -0,0 +1,236 @@
package playevents
import (
"context"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(context.Background(),
"TRUNCATE play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
t.Fatalf("truncate: %v", err)
}
return pool
}
type fixture struct {
pool *pgxpool.Pool
q *dbq.Queries
w *Writer
user pgtype.UUID
track pgtype.UUID
}
func newFixture(t *testing.T, durationMs int32) fixture {
t.Helper()
pool := testPool(t)
q := dbq.New(pool)
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
})
if err != nil {
t.Fatalf("user: %v", err)
}
a, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "Artist", SortName: "Artist"})
if err != nil {
t.Fatalf("artist: %v", err)
}
al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Album", SortTitle: "Album", ArtistID: a.ID})
if err != nil {
t.Fatalf("album: %v", err)
}
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "Track", AlbumID: al.ID, ArtistID: a.ID,
FilePath: "/tmp/track-fixture.flac",
DurationMs: durationMs,
})
if err != nil {
t.Fatalf("track: %v", err)
}
return fixture{
pool: pool,
q: q,
w: NewWriter(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000),
user: u.ID,
track: tr.ID,
}
}
func TestRecordPlayStarted_InsertsRow(t *testing.T) {
f := newFixture(t, 200_000)
now := time.Now().UTC()
res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", now)
if err != nil {
t.Fatalf("RecordPlayStarted: %v", err)
}
if !res.PlayEventID.Valid {
t.Fatalf("play_event_id invalid")
}
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.EndedAt.Valid {
t.Errorf("ended_at should be NULL right after start")
}
if got.WasSkipped {
t.Errorf("was_skipped should default false")
}
}
func TestRecordPlayStarted_AutoClosesPriorOpenRow(t *testing.T) {
f := newFixture(t, 200_000)
t1 := time.Now().UTC()
first, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
if err != nil {
t.Fatalf("first: %v", err)
}
t2 := t1.Add(45 * time.Second)
_, err = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t2)
if err != nil {
t.Fatalf("second: %v", err)
}
prior, err := f.q.GetPlayEventByID(context.Background(), first.PlayEventID)
if err != nil {
t.Fatalf("get: %v", err)
}
if !prior.EndedAt.Valid {
t.Errorf("prior should be closed")
}
if !prior.WasSkipped {
t.Errorf("prior should be marked was_skipped=true (auto-close convention)")
}
if prior.DurationPlayedMs == nil || *prior.DurationPlayedMs != 45_000 {
t.Errorf("duration_played_ms = %v, want 45000", prior.DurationPlayedMs)
}
}
func TestRecordPlayStarted_AutoCloseCapsAtTrackDuration(t *testing.T) {
f := newFixture(t, 60_000) // 60s track
t1 := time.Now().UTC()
first, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
// Start a second play 5 minutes later — elapsed > track duration.
t2 := t1.Add(5 * time.Minute)
_, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t2)
prior, _ := f.q.GetPlayEventByID(context.Background(), first.PlayEventID)
if prior.DurationPlayedMs == nil || *prior.DurationPlayedMs != 60_000 {
t.Errorf("duration_played_ms = %v, want capped at 60000", prior.DurationPlayedMs)
}
}
func TestRecordPlayEnded_AppliesSkipRule_BothFail_NotSkip(t *testing.T) {
f := newFixture(t, 200_000)
t1 := time.Now().UTC()
res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
// 15% completion (30_000ms), 30s duration -> 30s satisfies "duration >= 30s"
// so the rule's AND fails -> NOT a skip.
t2 := t1.Add(30 * time.Second)
if err := f.w.RecordPlayEnded(context.Background(), res.PlayEventID, 30_000, t2); err != nil {
t.Fatalf("RecordPlayEnded: %v", err)
}
got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if got.WasSkipped {
t.Errorf("was_skipped = true, want false (duration_played_ms == 30000 fails the AND)")
}
}
func TestRecordPlayEnded_AppliesSkipRule_BothPass_IsSkip(t *testing.T) {
f := newFixture(t, 200_000)
t1 := time.Now().UTC()
res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
// 5% completion (10_000ms), 10s duration -> both conditions hold -> SKIP.
t2 := t1.Add(10 * time.Second)
if err := f.w.RecordPlayEnded(context.Background(), res.PlayEventID, 10_000, t2); err != nil {
t.Fatalf("RecordPlayEnded: %v", err)
}
got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if !got.WasSkipped {
t.Errorf("expected was_skipped=true")
}
rows, err := f.pool.Query(context.Background(),
"SELECT id FROM skip_events WHERE user_id=$1 AND track_id=$2", f.user, f.track)
if err != nil {
t.Fatalf("query skips: %v", err)
}
defer rows.Close()
count := 0
for rows.Next() {
count++
}
if count != 1 {
t.Errorf("skip_events count = %d, want 1", count)
}
}
func TestRecordPlayEnded_DurationOver50Percent_NotSkip(t *testing.T) {
f := newFixture(t, 200_000) // 200s track
t1 := time.Now().UTC()
res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
// 50% completion (100_000ms), 5s duration. Completion fails the "< 0.5"
// check — so AND fails, NOT a skip.
t2 := t1.Add(5 * time.Second)
_ = f.w.RecordPlayEnded(context.Background(), res.PlayEventID, 100_000, t2)
got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if got.WasSkipped {
t.Errorf("expected was_skipped=false at completion >= 0.5")
}
}
func TestRecordPlaySkipped_AlwaysSkipFlagAndSkipEventRow(t *testing.T) {
f := newFixture(t, 200_000)
t1 := time.Now().UTC()
res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
// User-initiated skip at 90% — overrides the rule, always treated as skip.
t2 := t1.Add(180 * time.Second)
if err := f.w.RecordPlaySkipped(context.Background(), res.PlayEventID, 180_000, t2); err != nil {
t.Fatalf("RecordPlaySkipped: %v", err)
}
got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if !got.WasSkipped {
t.Errorf("RecordPlaySkipped must always set was_skipped=true")
}
}
func TestRecordSyntheticCompletedPlay_WritesBothRows(t *testing.T) {
f := newFixture(t, 200_000)
now := time.Now().UTC()
if err := f.w.RecordSyntheticCompletedPlay(context.Background(), f.user, f.track, "feishin", now); err != nil {
t.Fatalf("synthetic: %v", err)
}
rows, _ := f.pool.Query(context.Background(),
"SELECT id FROM play_events WHERE user_id=$1 AND ended_at IS NOT NULL", f.user)
defer rows.Close()
count := 0
for rows.Next() {
count++
}
if count != 1 {
t.Errorf("closed play_events count = %d, want 1", count)
}
}
+72
View File
@@ -0,0 +1,72 @@
// Package playsessions implements the listening-session lifecycle from
// spec §6: a session is a contiguous sequence of plays by a user with no
// gap longer than the configured timeout (default 30 minutes) between
// events.
package playsessions
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// FindOrCreate returns the session id for the given user and event time.
// If the user's most recent session was last touched within `timeout`, that
// session is extended (last_event_at and track_count updated). Otherwise a
// new session is inserted.
//
// q is *dbq.Queries; pass either the pool-level Queries or one bound to a
// transaction so callers can compose this into a larger atomic operation.
func FindOrCreate(
ctx context.Context,
q *dbq.Queries,
userID pgtype.UUID,
eventTime time.Time,
clientID string,
timeout time.Duration,
) (pgtype.UUID, error) {
prev, err := q.GetMostRecentPlaySessionForUser(ctx, userID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return pgtype.UUID{}, err
}
if err == nil {
// "Within window" includes the exact-boundary case: gap <= timeout extends.
gap := eventTime.Sub(prev.LastEventAt.Time)
if gap <= timeout {
if err := q.TouchPlaySessionLastEvent(ctx, dbq.TouchPlaySessionLastEventParams{
ID: prev.ID,
LastEventAt: pgtype.Timestamptz{Time: eventTime, Valid: true},
}); err != nil {
return pgtype.UUID{}, err
}
return prev.ID, nil
}
}
// Either no prior session, or the gap exceeded the timeout.
var clientIDPtr *string
if clientID != "" {
clientIDPtr = &clientID
}
row, err := q.InsertPlaySession(ctx, dbq.InsertPlaySessionParams{
UserID: userID,
StartedAt: pgtype.Timestamptz{Time: eventTime, Valid: true},
ClientID: clientIDPtr,
})
if err != nil {
return pgtype.UUID{}, err
}
// Bump track_count to 1 on creation so the contract "every FindOrCreate is
// the start of one play" stays true (insert leaves track_count default 0).
if err := q.TouchPlaySessionLastEvent(ctx, dbq.TouchPlaySessionLastEventParams{
ID: row.ID,
LastEventAt: pgtype.Timestamptz{Time: eventTime, Valid: true},
}); err != nil {
return pgtype.UUID{}, err
}
return row.ID, nil
}
+143
View File
@@ -0,0 +1,143 @@
package playsessions
import (
"context"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(context.Background(),
"TRUNCATE play_events, skip_events, play_sessions, sessions, users RESTART IDENTITY CASCADE"); err != nil {
t.Fatalf("truncate: %v", err)
}
return pool
}
func seedTestUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: "tester",
PasswordHash: "x",
ApiToken: "x",
IsAdmin: false,
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
return u.ID
}
func TestFindOrCreate_NoPriorSessionCreatesOne(t *testing.T) {
pool := testPool(t)
user := seedTestUser(t, pool)
q := dbq.New(pool)
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
id, err := FindOrCreate(context.Background(), q, user, now, "client-a", 30*time.Minute)
if err != nil {
t.Fatalf("FindOrCreate: %v", err)
}
if !id.Valid {
t.Fatalf("session id not valid")
}
row, err := q.GetMostRecentPlaySessionForUser(context.Background(), user)
if err != nil {
t.Fatalf("get session: %v", err)
}
if row.ID != id {
t.Errorf("returned id %v, db has %v", id, row.ID)
}
if !row.StartedAt.Time.Equal(now) {
t.Errorf("started_at = %v, want %v", row.StartedAt.Time, now)
}
}
func TestFindOrCreate_WithinWindowExtendsExisting(t *testing.T) {
pool := testPool(t)
user := seedTestUser(t, pool)
q := dbq.New(pool)
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
first, err := FindOrCreate(context.Background(), q, user, now, "client-a", 30*time.Minute)
if err != nil {
t.Fatalf("first: %v", err)
}
second, err := FindOrCreate(context.Background(), q, user, now.Add(15*time.Minute), "client-a", 30*time.Minute)
if err != nil {
t.Fatalf("second: %v", err)
}
if first != second {
t.Errorf("session id changed: %v -> %v", first, second)
}
row, err := q.GetMostRecentPlaySessionForUser(context.Background(), user)
if err != nil {
t.Fatalf("get session: %v", err)
}
if !row.LastEventAt.Time.Equal(now.Add(15 * time.Minute)) {
t.Errorf("last_event_at = %v", row.LastEventAt.Time)
}
if row.TrackCount != 2 {
t.Errorf("track_count = %d, want 2", row.TrackCount)
}
}
func TestFindOrCreate_BeyondWindowStartsNew(t *testing.T) {
pool := testPool(t)
user := seedTestUser(t, pool)
q := dbq.New(pool)
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
first, err := FindOrCreate(context.Background(), q, user, now, "client-a", 30*time.Minute)
if err != nil {
t.Fatalf("first: %v", err)
}
second, err := FindOrCreate(context.Background(), q, user, now.Add(31*time.Minute), "client-a", 30*time.Minute)
if err != nil {
t.Fatalf("second: %v", err)
}
if first == second {
t.Errorf("expected new session, got same id %v", first)
}
}
func TestFindOrCreate_AtExactBoundaryExtendsExisting(t *testing.T) {
pool := testPool(t)
user := seedTestUser(t, pool)
q := dbq.New(pool)
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
first, _ := FindOrCreate(context.Background(), q, user, now, "client-a", 30*time.Minute)
second, err := FindOrCreate(context.Background(), q, user, now.Add(30*time.Minute), "client-a", 30*time.Minute)
if err != nil {
t.Fatalf("second: %v", err)
}
if first != second {
t.Errorf("expected same session at exact boundary, got new id %v", second)
}
}
+15 -4
View File
@@ -11,9 +11,13 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
"git.fabledsword.com/bvandeusen/minstrel/web"
)
@@ -29,10 +33,11 @@ type Server struct {
Pool *pgxpool.Pool
Scanner ScanTrigger
SubsonicCfg subsonic.Config
EventsCfg config.EventsConfig
}
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config) *Server {
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg}
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig) *Server {
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg}
}
func (s *Server) Router() http.Handler {
@@ -43,14 +48,20 @@ func (s *Server) Router() http.Handler {
r.Get("/healthz", s.handleHealthz)
if s.Pool != nil {
api.Mount(r, s.Pool, s.Logger)
writer := playevents.NewWriter(
s.Pool, s.Logger,
time.Duration(s.EventsCfg.SessionTimeoutMinutes)*time.Minute,
s.EventsCfg.SkipMaxCompletionRatio,
s.EventsCfg.SkipMaxDurationPlayedMs,
)
api.Mount(r, s.Pool, s.Logger, writer)
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin(s.Pool))
if s.Scanner != nil {
admin.Post("/scan", s.handleAdminScan)
}
})
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg)
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer)
}
spa := web.Handler()
+6 -5
View File
@@ -9,11 +9,12 @@ import (
"strings"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
)
func TestHealthz(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -36,7 +37,7 @@ func TestHealthz(t *testing.T) {
}
func TestRouter_ServesSPAAtRoot(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -55,7 +56,7 @@ func TestRouter_ServesSPAAtRoot(t *testing.T) {
}
func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -74,7 +75,7 @@ func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
}
func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -93,7 +94,7 @@ func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
}
func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
+119
View File
@@ -0,0 +1,119 @@
package subsonic
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
func testScrobblePool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track) {
t.Helper()
if testing.Short() {
t.Skip("skipping in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(context.Background(),
"TRUNCATE play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
t.Fatalf("truncate: %v", err)
}
q := dbq.New(pool)
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
})
if err != nil {
t.Fatalf("user: %v", err)
}
a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"})
al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID})
tr, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "Y", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/scrob.flac", DurationMs: 100_000,
})
return pool, u, tr
}
func newScrobbleHandlers(pool *pgxpool.Pool) *mediaHandlers {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
return newMediaHandlers(pool, w)
}
func TestHandleScrobble_SubmissionFalseInsertsOpenPlayEvent(t *testing.T) {
pool, user, track := testScrobblePool(t)
m := newScrobbleHandlers(pool)
q := url.Values{}
q.Set("id", uuidToID(track.ID))
q.Set("submission", "false")
req := httptest.NewRequest(http.MethodGet, "/rest/scrobble?"+q.Encode(), nil)
ctx := context.WithValue(req.Context(), userCtxKey, user)
w := httptest.NewRecorder()
m.handleScrobble(w, req.WithContext(ctx))
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
rows, _ := pool.Query(context.Background(),
"SELECT id FROM play_events WHERE user_id=$1 AND ended_at IS NULL", user.ID)
defer rows.Close()
count := 0
for rows.Next() {
count++
}
if count != 1 {
t.Errorf("open play_events count = %d, want 1", count)
}
}
func TestHandleScrobble_SubmissionTrueWritesCompletedPlay(t *testing.T) {
pool, user, track := testScrobblePool(t)
m := newScrobbleHandlers(pool)
q := url.Values{}
q.Set("id", uuidToID(track.ID))
q.Set("submission", "true")
req := httptest.NewRequest(http.MethodGet, "/rest/scrobble?"+q.Encode(), nil)
ctx := context.WithValue(req.Context(), userCtxKey, user)
w := httptest.NewRecorder()
m.handleScrobble(w, req.WithContext(ctx))
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
rows, _ := pool.Query(context.Background(),
"SELECT ended_at IS NOT NULL FROM play_events WHERE user_id=$1", user.ID)
defer rows.Close()
closed := 0
for rows.Next() {
var c bool
_ = rows.Scan(&c)
if c {
closed++
}
}
if closed != 1 {
t.Errorf("closed play_events = %d, want 1", closed)
}
}
+44 -48
View File
@@ -7,27 +7,27 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
// mediaHandlers serves the bytes-on-the-wire endpoints: stream, download,
// getCoverArt, scrobble. They share a pool and an in-memory now-playing map
// because M1 has no event ingestion yet (see M2).
// getCoverArt, scrobble. Scrobble routes through playevents.Writer so
// Subsonic clients feed the same play_events table as the native /api/events.
type mediaHandlers struct {
pool *pgxpool.Pool
nowPlaying *nowPlayingMap
pool *pgxpool.Pool
events *playevents.Writer
}
func newMediaHandlers(pool *pgxpool.Pool) *mediaHandlers {
return &mediaHandlers{pool: pool, nowPlaying: newNowPlayingMap()}
func newMediaHandlers(pool *pgxpool.Pool, events *playevents.Writer) *mediaHandlers {
return &mediaHandlers{pool: pool, events: events}
}
// handleStream serves the raw track bytes. http.ServeContent gives us Range,
@@ -196,9 +196,12 @@ func imageContentType(path string) string {
}
}
// handleScrobble records a now-playing hint when submission=false. M1 has no
// event ingestion, so submission=true is a no-op that still returns ok — the
// real play/skip/seek/complete events land in M2.
// handleScrobble translates Subsonic scrobble calls into native play_events.
//
// submission=false (now-playing): writes a play_started, leaves ended_at NULL.
// submission=true (completed): writes a synthetic completed play (start+end
// in one transaction). play_events WHERE ended_at IS NULL is the source of
// truth for "currently playing"; the prior in-memory nowPlayingMap is gone.
func (m *mediaHandlers) handleScrobble(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
idStr := params.Get("id")
@@ -211,45 +214,38 @@ func (m *mediaHandlers) handleScrobble(w http.ResponseWriter, r *http.Request) {
WriteFail(w, r, ErrDataNotFound, "Track not found")
return
}
// Per spec, submission defaults to true. Only false means "this is now
// playing, don't record a play event." Anything else is an M2 no-op.
submission := strings.ToLower(params.Get("submission"))
if submission == "false" {
if user, ok := UserFromContext(r.Context()); ok {
m.nowPlaying.Set(uuidToID(user.ID), trackID)
user, ok := UserFromContext(r.Context())
if !ok {
WriteFail(w, r, ErrGeneric, "Unauthenticated")
return
}
at := time.Now().UTC()
if t := params.Get("time"); t != "" {
// Subsonic spec uses ms-since-epoch; some clients send ISO 8601.
// Accept both for resilience.
if ms, err := strconv.ParseInt(t, 10, 64); err == nil {
at = time.UnixMilli(ms).UTC()
} else if parsed, err := time.Parse(time.RFC3339, t); err == nil {
at = parsed
}
}
clientID := params.Get("c")
// Subsonic default is submission=true (completed play).
submission := strings.ToLower(params.Get("submission"))
switch submission {
case "true", "":
if err := m.events.RecordSyntheticCompletedPlay(r.Context(), user.ID, trackID, clientID, at); err != nil {
WriteFail(w, r, ErrGeneric, "Could not record play")
return
}
case "false":
if _, err := m.events.RecordPlayStarted(r.Context(), user.ID, trackID, clientID, at); err != nil {
WriteFail(w, r, ErrGeneric, "Could not record now-playing")
return
}
default:
// Unknown submission values: ack and ignore.
}
Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")})
}
// nowPlayingMap is a tiny in-memory store keyed by user id. M1 doesn't expose
// a getNowPlaying endpoint yet; this structure exists so scrobble has a place
// to write and M2 has something to read.
type nowPlayingMap struct {
mu sync.RWMutex
m map[string]nowPlayingEntry
}
type nowPlayingEntry struct {
TrackID pgtype.UUID
At time.Time
}
func newNowPlayingMap() *nowPlayingMap {
return &nowPlayingMap{m: make(map[string]nowPlayingEntry)}
}
func (n *nowPlayingMap) Set(userID string, trackID pgtype.UUID) {
n.mu.Lock()
defer n.mu.Unlock()
n.m[userID] = nowPlayingEntry{TrackID: trackID, At: time.Now()}
}
func (n *nowPlayingMap) Get(userID string) (nowPlayingEntry, bool) {
n.mu.RLock()
defer n.mu.RUnlock()
e, ok := n.m[userID]
return e, ok
}
-25
View File
@@ -4,8 +4,6 @@ import (
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
func TestFindSidecarCover(t *testing.T) {
@@ -53,26 +51,3 @@ func TestImageContentType(t *testing.T) {
}
}
}
func TestNowPlayingMap(t *testing.T) {
m := newNowPlayingMap()
user := "user-1"
if _, ok := m.Get(user); ok {
t.Fatalf("unexpected entry on fresh map")
}
var trackID pgtype.UUID
_ = trackID.Scan("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
m.Set(user, trackID)
entry, ok := m.Get(user)
if !ok {
t.Fatalf("Set did not persist")
}
if uuidToID(entry.TrackID) != "6ba7b810-9dad-11d1-80b4-00c04fd430c8" {
t.Errorf("trackID round-trip broke: %q", uuidToID(entry.TrackID))
}
if entry.At.IsZero() {
t.Errorf("At timestamp not set")
}
}
+4 -2
View File
@@ -11,15 +11,17 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
// Mount attaches Subsonic handlers at /rest on r. Endpoints are exposed at
// both /rest/foo and /rest/foo.view because client conventions vary. Both
// GET and POST are accepted; Subsonic's auth params live in the query string
// either way.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, events *playevents.Writer) {
b := &browseHandlers{pool: pool}
m := newMediaHandlers(pool)
m := newMediaHandlers(pool, events)
r.Route("/rest", func(sub chi.Router) {
sub.Use(Middleware(pool, cfg))
register(sub, "/ping", handlePing)
+15
View File
@@ -15,3 +15,18 @@ describe('qk key generators', () => {
expect(qk.album('xyz')).toEqual(['album', 'xyz']);
});
});
describe('qk search keys', () => {
test('search summary key includes q', () => {
expect(qk.search('foo')).toEqual(['search', { q: 'foo' }]);
});
test('searchArtists key includes q', () => {
expect(qk.searchArtists('foo')).toEqual(['searchArtists', { q: 'foo' }]);
});
test('searchAlbums key includes q', () => {
expect(qk.searchAlbums('foo')).toEqual(['searchAlbums', { q: 'foo' }]);
});
test('searchTracks key includes q', () => {
expect(qk.searchTracks('foo')).toEqual(['searchTracks', { q: 'foo' }]);
});
});
+66 -1
View File
@@ -1,14 +1,20 @@
import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
import { api } from './client';
import type { ArtistRef, ArtistDetail, AlbumDetail, Page } from './types';
import type { ArtistRef, ArtistDetail, AlbumDetail, Page, SearchResponse } from './types';
export type ArtistSort = 'alpha' | 'newest';
export const ARTIST_PAGE_SIZE = 50;
export const SEARCH_SUMMARY_LIMIT = 10;
export const SEARCH_FACET_PAGE_SIZE = 50;
export const qk = {
artists: (sort: ArtistSort) => ['artists', { sort }] as const,
artist: (id: string) => ['artist', id] as const,
album: (id: string) => ['album', id] as const,
search: (q: string) => ['search', { q }] as const,
searchArtists: (q: string) => ['searchArtists', { q }] as const,
searchAlbums: (q: string) => ['searchAlbums', { q }] as const,
searchTracks: (q: string) => ['searchTracks', { q }] as const,
};
export function createArtistsQuery(sort: ArtistSort) {
@@ -39,3 +45,62 @@ export function createAlbumQuery(id: string) {
queryFn: () => api.get<AlbumDetail>(`/api/albums/${id}`),
});
}
export function createSearchQuery(q: string) {
return createQuery({
queryKey: qk.search(q),
queryFn: () =>
api.get<SearchResponse>(
`/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_SUMMARY_LIMIT}`
),
enabled: q.length > 0
});
}
export function createSearchArtistsInfiniteQuery(q: string) {
return createInfiniteQuery({
queryKey: qk.searchArtists(q),
queryFn: ({ pageParam = 0 }) =>
api.get<SearchResponse>(
`/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}`
).then((r) => r.artists),
initialPageParam: 0,
getNextPageParam: (last) => {
const loaded = last.offset + last.items.length;
return loaded >= last.total ? undefined : loaded;
},
enabled: q.length > 0
});
}
export function createSearchAlbumsInfiniteQuery(q: string) {
return createInfiniteQuery({
queryKey: qk.searchAlbums(q),
queryFn: ({ pageParam = 0 }) =>
api.get<SearchResponse>(
`/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}`
).then((r) => r.albums),
initialPageParam: 0,
getNextPageParam: (last) => {
const loaded = last.offset + last.items.length;
return loaded >= last.total ? undefined : loaded;
},
enabled: q.length > 0
});
}
export function createSearchTracksInfiniteQuery(q: string) {
return createInfiniteQuery({
queryKey: qk.searchTracks(q),
queryFn: ({ pageParam = 0 }) =>
api.get<SearchResponse>(
`/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}`
).then((r) => r.tracks),
initialPageParam: 0,
getNextPageParam: (last) => {
const loaded = last.offset + last.items.length;
return loaded >= last.total ? undefined : loaded;
},
enabled: q.length > 0
});
}
+18
View File
@@ -46,3 +46,21 @@ export type ArtistDetail = ArtistRef & {
export type AlbumDetail = AlbumRef & {
tracks: TrackRef[];
};
export type SearchResponse = {
artists: Page<ArtistRef>;
albums: Page<AlbumRef>;
tracks: Page<TrackRef>;
};
export type RadioResponse = {
tracks: TrackRef[];
};
export type EventRequest =
| { type: 'play_started'; track_id: string; client_id?: string }
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number }
| { type: 'play_skipped'; play_event_id: string; position_ms: number };
export type PlayStartedResponse = { play_event_id: string; session_id: string };
export type EventOkResponse = { ok: true };
+34 -17
View File
@@ -1,27 +1,44 @@
<script lang="ts">
import type { AlbumRef } from '$lib/api/types';
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
import { FALLBACK_COVER } from '$lib/media/covers';
import { api } from '$lib/api/client';
import { enqueueTracks } from '$lib/player/store.svelte';
let { album }: { album: AlbumRef } = $props();
function onImgError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
async function onAddClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const detail = await api.get<AlbumDetail>(`/api/albums/${album.id}`);
enqueueTracks(detail.tracks);
}
</script>
<a
href={`/albums/${album.id}`}
class="group block rounded focus-visible:ring-2 focus-visible:ring-accent"
>
<img
src={album.cover_url}
alt=""
class="aspect-square w-full rounded object-cover transition-transform group-hover:scale-[1.03]"
loading="lazy"
onerror={onImgError}
/>
<div class="mt-2 truncate text-sm font-medium">{album.title}</div>
{#if album.year}
<div class="text-xs text-text-secondary">{album.year}</div>
{/if}
</a>
<div class="relative">
<a
href={`/albums/${album.id}`}
class="group block rounded focus-visible:ring-2 focus-visible:ring-accent"
>
<img
src={album.cover_url}
alt=""
class="aspect-square w-full rounded object-cover transition-transform group-hover:scale-[1.03]"
loading="lazy"
onerror={onImgError}
/>
<div class="mt-2 truncate text-sm font-medium">{album.title}</div>
{#if album.year}
<div class="text-xs text-text-secondary">{album.year}</div>
{/if}
</a>
<button
type="button"
aria-label="Add to queue"
onclick={onAddClick}
class="absolute right-2 top-2 rounded-full bg-surface/80 px-2 py-1 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
>+</button>
</div>
+37 -4
View File
@@ -1,9 +1,19 @@
import { describe, expect, test } from 'vitest';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import AlbumCard from './AlbumCard.svelte';
import type { AlbumRef } from '$lib/api/types';
import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types';
import { FALLBACK_COVER } from '$lib/media/covers';
vi.mock('$lib/api/client', () => ({
api: { get: vi.fn() }
}));
vi.mock('$lib/player/store.svelte', () => ({
enqueueTracks: vi.fn()
}));
import AlbumCard from './AlbumCard.svelte';
import { api } from '$lib/api/client';
import { enqueueTracks } from '$lib/player/store.svelte';
const album: AlbumRef = {
id: 'xyz',
title: 'Kind of Blue',
@@ -15,6 +25,8 @@ const album: AlbumRef = {
cover_url: '/api/albums/xyz/cover',
};
afterEach(() => vi.clearAllMocks());
describe('AlbumCard', () => {
test('renders cover, title, year inside a link to /albums/:id', () => {
const { container } = render(AlbumCard, { props: { album } });
@@ -29,7 +41,6 @@ describe('AlbumCard', () => {
test('year is omitted when not present', () => {
render(AlbumCard, { props: { album: { ...album, year: undefined } } });
// No crash; no year text.
expect(screen.queryByText(/1959/)).not.toBeInTheDocument();
});
@@ -39,4 +50,26 @@ describe('AlbumCard', () => {
await fireEvent.error(img);
expect(img.src).toBe(FALLBACK_COVER);
});
test('+ queue button fetches album detail and calls enqueueTracks', async () => {
const tracks: TrackRef[] = [
{
id: 't1', title: 'So What',
album_id: 'xyz', album_title: 'Kind of Blue',
artist_id: 'm-davis', artist_name: 'Miles Davis',
track_number: 1, disc_number: 1, duration_sec: 545,
stream_url: '/api/tracks/t1/stream'
}
];
const detail: AlbumDetail = { ...album, tracks };
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
render(AlbumCard, { props: { album } });
await fireEvent.click(screen.getByRole('button', { name: /add to queue/i }));
expect(api.get).toHaveBeenCalledWith('/api/albums/xyz');
await Promise.resolve();
await Promise.resolve();
expect(enqueueTracks).toHaveBeenCalledWith(tracks);
});
});
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import {
player,
togglePlay, skipNext, skipPrev, seekTo, setVolume,
toggleShuffle, cycleRepeat, playQueue
} from '$lib/player/store.svelte';
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER } from '$lib/media/covers';
const current = $derived(player.current);
const skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
const skipNextDisabled = $derived(
player.index === player.queue.length - 1 && player.repeat === 'off'
);
const repeatLabel = $derived(
player.repeat === 'off' ? 'Repeat off' :
player.repeat === 'all' ? 'Repeat all' : 'Repeat one'
);
function onSeekInput(e: Event) {
const v = Number((e.currentTarget as HTMLInputElement).value);
seekTo(v);
}
function onVolumeInput(e: Event) {
const v = Number((e.currentTarget as HTMLInputElement).value);
setVolume(v);
}
function onCoverError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
</script>
{#if current}
<div class="flex items-center gap-4 border-t border-border bg-surface px-4 py-3">
<!-- Left: cover + title + artist -->
<div class="flex w-60 min-w-0 items-center gap-3">
<a
href={`/albums/${current.album_id}`}
aria-label="Open album"
class="shrink-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<img
src={`/api/albums/${current.album_id}/cover`}
alt=""
class="h-12 w-12 rounded object-cover"
onerror={onCoverError}
/>
</a>
<div class="min-w-0">
<div class="truncate text-sm font-medium">{current.title}</div>
<a
href={`/artists/${current.artist_id}`}
class="truncate text-xs text-text-secondary hover:text-text-primary"
>
{current.artist_name}
</a>
</div>
</div>
<!-- Center: seek row + transport row -->
{#if player.state === 'error'}
<div role="alert" class="flex flex-1 items-center justify-center gap-3 text-sm text-text-primary">
<span>{player.error ?? 'Playback failed.'}</span>
<button
type="button"
class="rounded bg-accent px-3 py-1 text-background"
onclick={() => playQueue(player.queue, player.index)}
>
Try again
</button>
</div>
{:else}
<div class="flex flex-1 flex-col items-stretch gap-2">
<div class="flex items-center gap-3">
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
{formatDuration(player.position)}
</span>
<input
type="range"
aria-label="Seek"
min="0"
max={player.duration || 0}
step="0.1"
value={player.position}
oninput={onSeekInput}
class="flex-1 accent-accent"
/>
<span class="w-12 shrink-0 text-xs tabular-nums text-text-secondary">
{formatDuration(player.duration)}
</span>
</div>
<div class="flex items-center justify-center gap-4">
<button
type="button"
aria-label="Previous"
disabled={skipPrevDisabled}
onclick={skipPrev}
class="rounded p-1 disabled:opacity-40"
></button>
<button
type="button"
aria-label={player.isPlaying ? 'Pause' : 'Play'}
onclick={togglePlay}
class="rounded px-3 py-1 bg-surface-hover"
>
{#if player.state === 'loading' || player.state === 'idle'}
<span data-testid="play-spinner" class="inline-block animate-spin"></span>
{:else if player.isPlaying}
{:else}
{/if}
</button>
<button
type="button"
aria-label="Next"
disabled={skipNextDisabled}
onclick={skipNext}
class="rounded p-1 disabled:opacity-40"
></button>
</div>
</div>
{/if}
<!-- Right: shuffle + repeat + volume -->
<div class="flex w-56 items-center justify-end gap-3">
<button
type="button"
aria-label="Shuffle"
aria-pressed={player.shuffle}
onclick={toggleShuffle}
class="rounded p-1 {player.shuffle ? 'text-accent' : 'text-text-secondary'}"
>🔀</button>
<button
type="button"
aria-label={repeatLabel}
onclick={cycleRepeat}
class="rounded p-1 {player.repeat !== 'off' ? 'text-accent' : 'text-text-secondary'}"
>🔁{player.repeat === 'one' ? '¹' : ''}</button>
<label class="flex items-center gap-2">
<span class="sr-only">Volume</span>
<input
type="range"
aria-label="Volume"
min="0"
max="1"
step="0.01"
value={player.volume}
oninput={onVolumeInput}
class="w-28 accent-accent"
/>
</label>
</div>
</div>
{/if}
+161
View File
@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
// Mutable state the mocked store reads from.
const state = vi.hoisted(() => ({
current: undefined as TrackRef | undefined,
queue: [] as TrackRef[],
index: 0,
state: 'idle' as 'idle' | 'loading' | 'playing' | 'paused' | 'error',
position: 0,
duration: 0,
volume: 1,
shuffle: false,
repeat: 'off' as 'off' | 'all' | 'one',
error: null as string | null
}));
vi.mock('$lib/player/store.svelte', () => ({
player: {
get queue() { return state.queue; },
get index() { return state.index; },
get current() { return state.current; },
get state() { return state.state; },
get isPlaying() { return state.state === 'playing'; },
get position() { return state.position; },
get duration() { return state.duration; },
get volume() { return state.volume; },
get shuffle() { return state.shuffle; },
get repeat() { return state.repeat; },
get error() { return state.error; }
},
togglePlay: vi.fn(),
skipNext: vi.fn(),
skipPrev: vi.fn(),
seekTo: vi.fn(),
setVolume: vi.fn(),
toggleShuffle: vi.fn(),
cycleRepeat: vi.fn(),
playQueue: vi.fn()
}));
import PlayerBar from './PlayerBar.svelte';
import {
togglePlay, skipNext, skipPrev, seekTo, setVolume,
toggleShuffle, cycleRepeat, playQueue
} from '$lib/player/store.svelte';
function track(): TrackRef {
return {
id: 't1', title: 'So What',
album_id: 'xyz', album_title: 'Kind of Blue',
artist_id: 'md', artist_name: 'Miles Davis',
track_number: 1, disc_number: 1,
duration_sec: 545, stream_url: '/api/tracks/t1/stream'
};
}
beforeEach(() => {
state.queue = [track()];
state.current = state.queue[0];
state.index = 0;
state.state = 'paused';
state.position = 42;
state.duration = 545;
state.volume = 0.8;
state.shuffle = false;
state.repeat = 'off';
state.error = null;
});
afterEach(() => vi.clearAllMocks());
describe('PlayerBar', () => {
test('renders title, linked artist, linked cover', () => {
render(PlayerBar);
expect(screen.getByText('So What')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Miles Davis/ })).toHaveAttribute('href', '/artists/md');
const cover = screen.getByRole('link', { name: /open album/i });
expect(cover).toHaveAttribute('href', '/albums/xyz');
});
test('play button click calls togglePlay', async () => {
render(PlayerBar);
await fireEvent.click(screen.getByRole('button', { name: /play|pause/i }));
expect(togglePlay).toHaveBeenCalledTimes(1);
});
test('skip-next click calls skipNext; skip-prev click calls skipPrev', async () => {
render(PlayerBar);
await fireEvent.click(screen.getByRole('button', { name: /next/i }));
await fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(skipNext).toHaveBeenCalledTimes(1);
expect(skipPrev).toHaveBeenCalledTimes(1);
});
test('skip-prev disabled when index=0 AND position<3', () => {
state.index = 0;
state.position = 1;
render(PlayerBar);
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
});
test('skip-next disabled at end with repeat=off', () => {
state.queue = [track()];
state.index = 0;
state.repeat = 'off';
render(PlayerBar);
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
test('seek slider input calls seekTo(value)', async () => {
render(PlayerBar);
const slider = screen.getByLabelText(/seek/i) as HTMLInputElement;
await fireEvent.input(slider, { target: { value: '120' } });
expect(seekTo).toHaveBeenCalledWith(120);
});
test('volume slider input calls setVolume(value)', async () => {
render(PlayerBar);
const slider = screen.getByLabelText(/volume/i) as HTMLInputElement;
await fireEvent.input(slider, { target: { value: '0.3' } });
expect(setVolume).toHaveBeenCalledWith(0.3);
});
test('shuffle button click calls toggleShuffle; active class reflects shuffle', () => {
state.shuffle = true;
render(PlayerBar);
const btn = screen.getByRole('button', { name: /shuffle/i });
expect(btn.getAttribute('aria-pressed')).toBe('true');
});
test('repeat button aria-label reflects current mode', () => {
state.repeat = 'one';
render(PlayerBar);
expect(screen.getByRole('button', { name: /repeat one/i })).toBeInTheDocument();
});
test('loading state shows spinner in place of play icon', () => {
state.state = 'loading';
render(PlayerBar);
expect(screen.getByTestId('play-spinner')).toBeInTheDocument();
});
test('error state renders retry card; retry click calls playQueue(queue, index)', async () => {
state.state = 'error';
state.error = 'Playback failed.';
render(PlayerBar);
expect(screen.getByText(/playback failed/i)).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(playQueue).toHaveBeenCalledWith(state.queue, state.index);
});
test('elapsed and total render formatted duration', () => {
state.position = 65;
state.duration = 245;
render(PlayerBar);
expect(screen.getByText('1:05')).toBeInTheDocument();
expect(screen.getByText('4:05')).toBeInTheDocument();
});
});
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
let value = $state(page.url.searchParams.get('q') ?? '');
let timeout: ReturnType<typeof setTimeout> | null = null;
// Re-sync local value when the URL changes from outside (back/forward,
// result-link clicks). Read `value` non-reactively to avoid a feedback
// loop where the user's typing would race against the URL re-read.
$effect(() => {
const fromUrl = page.url.searchParams.get('q') ?? '';
if (fromUrl !== untrack(() => value)) value = fromUrl;
});
function navigate(next: string) {
const onSearchPage = page.url.pathname.startsWith('/search');
const trimmed = next.trim();
if (trimmed === '') {
if (onSearchPage) goto('/search', { replaceState: true });
return;
}
goto(`/search?q=${encodeURIComponent(trimmed)}`, { replaceState: onSearchPage });
}
function onInput(e: Event) {
value = (e.currentTarget as HTMLInputElement).value;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
navigate(value);
}, 250);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
value = '';
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
navigate('');
}
}
</script>
<input
type="search"
placeholder="Search artists, albums, tracks…"
aria-label="Search"
{value}
oninput={onInput}
onkeydown={onKey}
class="w-full max-w-md rounded border border-border bg-surface px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
/>
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/')
}));
vi.mock('$app/state', () => ({
page: { get url() { return state.pageUrl; } }
}));
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
import SearchInput from './SearchInput.svelte';
import { goto } from '$app/navigation';
beforeEach(() => {
vi.useFakeTimers();
state.pageUrl = new URL('http://localhost/');
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
describe('SearchInput', () => {
test('mount pre-fills value from page.url.searchParams', () => {
state.pageUrl = new URL('http://localhost/search?q=hello');
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
expect(input.value).toBe('hello');
});
test('typing fires goto once after 250ms debounce', async () => {
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'h' } });
expect(goto).not.toHaveBeenCalled();
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false });
});
test('rapid keystrokes debounce to a single goto', async () => {
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'h' } });
vi.advanceTimersByTime(100);
await fireEvent.input(input, { target: { value: 'he' } });
vi.advanceTimersByTime(100);
await fireEvent.input(input, { target: { value: 'hel' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false });
});
test('typing while on /search uses replaceState=true', async () => {
state.pageUrl = new URL('http://localhost/search?q=h');
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'he' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true });
});
test('clearing input on /search calls goto("/search") (drops ?q=)', async () => {
state.pageUrl = new URL('http://localhost/search?q=h');
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: '' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search', { replaceState: true });
});
test('Escape clears the input value', async () => {
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'hello' } });
expect(input.value).toBe('hello');
await fireEvent.keyDown(input, { key: 'Escape' });
expect(input.value).toBe('');
});
test('encodeURIComponent applied to special chars', async () => {
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'a b&c' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false });
});
});
@@ -0,0 +1,40 @@
<script lang="ts">
const shimmer = 'animate-pulse bg-surface';
</script>
<div data-testid="search-skeleton" class="space-y-6">
<section>
<div class="mb-2 h-5 w-24 rounded {shimmer}"></div>
<div class="divide-y divide-border">
{#each Array.from({ length: 4 }) as _, i (i)}
<div class="flex items-center gap-3 px-3 py-3">
<span class="h-10 w-10 rounded-full {shimmer}"></span>
<span class="h-4 flex-1 rounded {shimmer}"></span>
<span class="h-3 w-20 rounded {shimmer}"></span>
</div>
{/each}
</div>
</section>
<section>
<div class="mb-2 h-5 w-24 rounded {shimmer}"></div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each Array.from({ length: 5 }) as _, i (i)}
<div>
<div class="aspect-square w-full rounded {shimmer}"></div>
<div class="mt-2 h-4 w-3/4 rounded {shimmer}"></div>
<div class="mt-1 h-3 w-1/3 rounded {shimmer}"></div>
</div>
{/each}
</div>
</section>
<section>
<div class="mb-2 h-5 w-24 rounded {shimmer}"></div>
<div class="space-y-2">
{#each Array.from({ length: 6 }) as _, i (i)}
<div class="h-6 w-full rounded {shimmer}"></div>
{/each}
</div>
</section>
</div>
+14 -2
View File
@@ -2,6 +2,9 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { user, logout } from '$lib/auth/store.svelte';
import { player } from '$lib/player/store.svelte';
import PlayerBar from './PlayerBar.svelte';
import SearchInput from './SearchInput.svelte';
let { children } = $props<{ children: import('svelte').Snippet }>();
@@ -27,9 +30,12 @@
<svelte:window onclick={() => (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
<div class="grid h-screen grid-cols-[auto_1fr] grid-rows-[auto_1fr] bg-background text-text-primary">
<header class="col-span-2 flex items-center justify-between border-b border-border bg-surface px-4 py-3">
<div class="grid h-screen grid-cols-[auto_1fr] grid-rows-[auto_1fr_auto] bg-background text-text-primary">
<header class="col-span-2 flex items-center gap-4 border-b border-border bg-surface px-4 py-3">
<div class="font-semibold">Minstrel</div>
<div class="flex-1">
<SearchInput />
</div>
<div class="relative">
<button
type="button"
@@ -77,4 +83,10 @@
<main class="overflow-y-auto p-4 md:col-start-2">
{@render children?.()}
</main>
{#if player.current}
<div class="col-span-2">
<PlayerBar />
</div>
{/if}
</div>
+45 -2
View File
@@ -1,14 +1,57 @@
<script lang="ts">
import type { TrackRef } from '$lib/api/types';
import { formatDuration } from '$lib/media/duration';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
let { track }: { track: TrackRef } = $props();
type PlayHandler = (tracks: TrackRef[], index: number) => void;
let {
tracks,
index,
onPlay
}: { tracks: TrackRef[]; index: number; onPlay?: PlayHandler } = $props();
const track = $derived(tracks[index]);
function activate() {
if (onPlay) onPlay(tracks, index);
else playQueue(tracks, index);
}
function onRowClick() {
activate();
}
function onRowKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activate();
}
}
function onAddClick(e: MouseEvent) {
e.stopPropagation();
enqueueTrack(track);
}
</script>
<div class="grid grid-cols-[32px_1fr_auto] items-center gap-4 px-3 py-2 text-sm odd:bg-surface/50">
<div
role="button"
tabindex="0"
aria-label={track.title}
onclick={onRowClick}
onkeydown={onRowKey}
class="grid w-full cursor-pointer grid-cols-[32px_1fr_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"
>
<span class="text-right tabular-nums text-text-secondary">
{track.track_number ?? '—'}
</span>
<span class="truncate">{track.title}</span>
<button
type="button"
aria-label="Add to queue"
onclick={onAddClick}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>+</button>
<span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span>
</div>
+64 -17
View File
@@ -1,31 +1,78 @@
import { describe, expect, test } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import TrackRow from './TrackRow.svelte';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
const track: TrackRef = {
id: 't1',
title: 'So What',
album_id: 'xyz',
album_title: 'Kind of Blue',
artist_id: 'm-davis',
artist_name: 'Miles Davis',
track_number: 1,
disc_number: 1,
duration_sec: 545,
stream_url: '/api/tracks/t1/stream',
};
vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn(),
enqueueTrack: vi.fn()
}));
import TrackRow from './TrackRow.svelte';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
const tracks: TrackRef[] = [
{
id: 't1', title: 'So What',
album_id: 'xyz', album_title: 'Kind of Blue',
artist_id: 'm-davis', artist_name: 'Miles Davis',
track_number: 1, disc_number: 1,
duration_sec: 545, stream_url: '/api/tracks/t1/stream'
},
{
id: 't2', title: 'Freddie Freeloader',
album_id: 'xyz', album_title: 'Kind of Blue',
artist_id: 'm-davis', artist_name: 'Miles Davis',
track_number: 2, disc_number: 1,
duration_sec: 565, stream_url: '/api/tracks/t2/stream'
}
];
afterEach(() => vi.clearAllMocks());
describe('TrackRow', () => {
test('renders track number, title, formatted duration', () => {
render(TrackRow, { props: { track } });
render(TrackRow, { props: { tracks, index: 0 } });
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('So What')).toBeInTheDocument();
expect(screen.getByText('9:05')).toBeInTheDocument();
});
test('track number falls back to dash when missing', () => {
render(TrackRow, { props: { track: { ...track, track_number: undefined } } });
const noNum = [{ ...tracks[0], track_number: undefined }];
render(TrackRow, { props: { tracks: noNum, index: 0 } });
expect(screen.getByText('—')).toBeInTheDocument();
});
test('clicking the row calls playQueue(tracks, index)', async () => {
render(TrackRow, { props: { tracks, index: 1 } });
await fireEvent.click(screen.getByRole('button', { name: /Freddie Freeloader/ }));
expect(playQueue).toHaveBeenCalledWith(tracks, 1);
});
test('Enter on the row activates play', async () => {
render(TrackRow, { props: { tracks, index: 0 } });
await fireEvent.keyDown(screen.getByRole('button', { name: /So What/ }), { key: 'Enter' });
expect(playQueue).toHaveBeenCalledWith(tracks, 0);
});
test('Space on the row activates play', async () => {
render(TrackRow, { props: { tracks, index: 0 } });
await fireEvent.keyDown(screen.getByRole('button', { name: /So What/ }), { key: ' ' });
expect(playQueue).toHaveBeenCalledWith(tracks, 0);
});
test('onPlay prop overrides default playQueue', async () => {
const onPlay = vi.fn();
render(TrackRow, { props: { tracks, index: 1, onPlay } });
await fireEvent.click(screen.getByRole('button', { name: /Freddie Freeloader/ }));
expect(onPlay).toHaveBeenCalledWith(tracks, 1);
expect(playQueue).not.toHaveBeenCalled();
});
test('+ queue button calls enqueueTrack and does NOT trigger row play', async () => {
render(TrackRow, { props: { tracks, index: 0 } });
await fireEvent.click(screen.getByRole('button', { name: /add to queue/i }));
expect(enqueueTrack).toHaveBeenCalledWith(tracks[0]);
expect(playQueue).not.toHaveBeenCalled();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { getOrCreateClientId } from './clientId';
beforeEach(() => sessionStorage.clear());
afterEach(() => sessionStorage.clear());
describe('getOrCreateClientId', () => {
test('generates a UUID on first call and persists it in sessionStorage', () => {
const id = getOrCreateClientId();
expect(id).toMatch(/^[0-9a-f-]{36}$/);
expect(sessionStorage.getItem('minstrel.client_id')).toBe(id);
});
test('returns the same id on subsequent calls', () => {
const a = getOrCreateClientId();
const b = getOrCreateClientId();
expect(a).toBe(b);
});
});
+13
View File
@@ -0,0 +1,13 @@
const KEY = 'minstrel.client_id';
export function getOrCreateClientId(): string {
try {
const existing = sessionStorage.getItem(KEY);
if (existing) return existing;
} catch {
// sessionStorage unavailable (non-browser env). Generate ephemerally.
}
const id = crypto.randomUUID();
try { sessionStorage.setItem(KEY, id); } catch { /* ignore */ }
return id;
}
+142
View File
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { flushSync } from 'svelte';
vi.mock('$lib/api/client', () => ({
api: {
post: vi.fn().mockResolvedValue({ play_event_id: 'pe-1', session_id: 's-1' })
}
}));
vi.mock('./clientId', () => ({
getOrCreateClientId: () => 'test-client'
}));
import { useEventsDispatcher } from './events.svelte';
import {
playQueue,
reportStateFromAudio,
reportTimeUpdate,
reportDuration,
skipNext,
} from './store.svelte';
import { api } from '$lib/api/client';
import type { TrackRef } from '$lib/api/types';
function track(id: string, dur = 200): TrackRef {
return {
id, title: `T ${id}`,
album_id: 'a', album_title: 'A',
artist_id: 'ar', artist_name: 'Ar',
track_number: Number(id), disc_number: 1,
duration_sec: dur,
stream_url: `/api/tracks/${id}/stream`
};
}
beforeEach(() => {
vi.clearAllMocks();
// Reset the player store to idle.
playQueue([]);
});
afterEach(() => {
// sendBeacon spies are restored automatically per test.
});
describe('useEventsDispatcher', () => {
test('first transition into playing for a new track POSTs play_started', async () => {
const cleanup = $effect.root(() => useEventsDispatcher());
playQueue([track('1')]);
flushSync();
reportStateFromAudio('playing');
flushSync();
// Microtask drain so the api.post promise settles.
await Promise.resolve();
await Promise.resolve();
const calls = (api.post as ReturnType<typeof vi.fn>).mock.calls;
const startedCalls = calls.filter((c) => (c[1] as { type: string }).type === 'play_started');
expect(startedCalls.length).toBe(1);
expect(startedCalls[0][0]).toBe('/api/events');
expect(startedCalls[0][1]).toMatchObject({
type: 'play_started',
track_id: '1',
client_id: 'test-client'
});
cleanup();
});
test('track end (duration reached, paused) POSTs play_ended', async () => {
const cleanup = $effect.root(() => useEventsDispatcher());
playQueue([track('1', 200)]);
flushSync();
reportDuration(200);
reportStateFromAudio('playing');
flushSync();
await Promise.resolve();
await Promise.resolve();
// Reach end of track, then natural pause.
reportTimeUpdate(200);
flushSync();
reportStateFromAudio('ended');
flushSync();
await Promise.resolve();
const calls = (api.post as ReturnType<typeof vi.fn>).mock.calls;
const types = calls.map((c) => (c[1] as { type: string }).type);
expect(types).toContain('play_started');
expect(types).toContain('play_ended');
cleanup();
});
test('user-initiated skip mid-track POSTs play_skipped', async () => {
const cleanup = $effect.root(() => useEventsDispatcher());
playQueue([track('1'), track('2')]);
flushSync();
reportStateFromAudio('playing');
flushSync();
await Promise.resolve();
await Promise.resolve();
skipNext();
flushSync();
await Promise.resolve();
const calls = (api.post as ReturnType<typeof vi.fn>).mock.calls;
const types = calls.map((c) => (c[1] as { type: string }).type);
expect(types).toContain('play_skipped');
cleanup();
});
test('pagehide fires sendBeacon when a play is open', async () => {
// jsdom doesn't ship navigator.sendBeacon — install a stub before spy.
if (typeof navigator.sendBeacon !== 'function') {
Object.defineProperty(navigator, 'sendBeacon', {
value: () => true,
configurable: true
});
}
const cleanup = $effect.root(() => useEventsDispatcher());
const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
playQueue([track('1')]);
flushSync();
reportStateFromAudio('playing');
flushSync();
await Promise.resolve();
await Promise.resolve();
window.dispatchEvent(new Event('pagehide'));
expect(beacon).toHaveBeenCalled();
expect(beacon.mock.calls[0][0]).toBe('/api/events');
cleanup();
beacon.mockRestore();
});
});
+107
View File
@@ -0,0 +1,107 @@
import { player } from './store.svelte';
import { api } from '$lib/api/client';
import { getOrCreateClientId } from './clientId';
import type { PlayStartedResponse, EventOkResponse } from '$lib/api/types';
// useEventsDispatcher installs $effect-based watchers that emit play events
// to /api/events. Call once from +layout.svelte's mount.
//
// The dispatcher is a small state machine over (current track id, state).
// Per spec data-flow:
// - First transition into 'playing' for a new track → POST play_started.
// - Track id changes while a play is open → POST play_skipped on the prior.
// - Natural completion (state moves from 'playing' to 'paused' AND
// position >= duration > 0) → POST play_ended on the open row.
// - pagehide with a live row → navigator.sendBeacon a play_skipped.
export function useEventsDispatcher(): void {
let openPlayEventId: string | null = null;
let openTrackId: string | null = null;
let lastPositionMs = 0;
let prevTrackId: string | null = null;
let prevState: string | null = null;
const clientId = getOrCreateClientId();
// Track the latest position for use by close events. Cheap; no API call.
$effect(() => {
lastPositionMs = Math.round(player.position * 1000);
});
// Main state machine. Reacts only to (current track id, state) transitions.
$effect(() => {
const t = player.current;
const s = player.state;
const tid = t?.id ?? null;
// Track changed: close any prior open play as a skip.
if (tid !== prevTrackId && openPlayEventId) {
const id = openPlayEventId;
const pos = lastPositionMs;
openPlayEventId = null;
openTrackId = null;
api.post<EventOkResponse>('/api/events', {
type: 'play_skipped',
play_event_id: id,
position_ms: pos
}).catch(() => {});
}
// Entered playing for a new track (no open row yet for this track).
if (tid && s === 'playing' && openTrackId !== tid) {
void startNew(tid);
}
// Natural end: state went playing -> paused while at/past duration.
if (
prevState === 'playing' &&
s === 'paused' &&
openPlayEventId &&
player.duration > 0 &&
player.position >= player.duration
) {
const id = openPlayEventId;
const dur = lastPositionMs;
openPlayEventId = null;
openTrackId = null;
api.post<EventOkResponse>('/api/events', {
type: 'play_ended',
play_event_id: id,
duration_played_ms: dur
}).catch(() => {});
}
prevTrackId = tid;
prevState = s;
});
// Page close: sendBeacon a play_skipped if an open play exists.
if (typeof window !== 'undefined') {
const onPageHide = () => {
if (!openPlayEventId) return;
const payload = JSON.stringify({
type: 'play_skipped',
play_event_id: openPlayEventId,
position_ms: lastPositionMs
});
navigator.sendBeacon('/api/events', new Blob([payload], { type: 'application/json' }));
};
window.addEventListener('pagehide', onPageHide);
}
async function startNew(trackId: string): Promise<void> {
try {
const res = await api.post<PlayStartedResponse>('/api/events', {
type: 'play_started',
track_id: trackId,
client_id: clientId
});
// The user may have moved on by the time the response arrives. Only
// adopt the id if we're still on the same track and still playing.
if (player.current?.id === trackId && player.state === 'playing') {
openPlayEventId = res.play_event_id;
openTrackId = trackId;
}
} catch {
// Best-effort; missed events are OK in v1.
}
}
}
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { flushSync } from 'svelte';
import type { TrackRef } from '$lib/api/types';
function track(id: string): TrackRef {
return {
id, title: `T ${id}`,
album_id: 'a1', album_title: 'A',
artist_id: 'ar1', artist_name: 'Ar',
track_number: Number(id), disc_number: 1,
duration_sec: 180, stream_url: `/api/tracks/${id}/stream`
};
}
type Handler = (details: MediaSessionActionDetails) => void;
// Spy MediaSession replaces navigator.mediaSession for the duration of each test.
function makeSpyMediaSession() {
const handlers: Record<string, Handler> = {};
const spy = {
metadata: null as MediaMetadata | null,
playbackState: 'none' as MediaSessionPlaybackState,
setActionHandler: vi.fn((action: string, h: Handler | null) => {
if (h) handlers[action] = h;
else delete handlers[action];
}),
setPositionState: vi.fn(),
handlers
};
return spy;
}
describe('useMediaSession', () => {
let spy: ReturnType<typeof makeSpyMediaSession>;
let originalMS: unknown;
beforeEach(async () => {
spy = makeSpyMediaSession();
originalMS = (navigator as unknown as { mediaSession?: unknown }).mediaSession;
Object.defineProperty(navigator, 'mediaSession', {
configurable: true,
value: spy
});
// Also stub MediaMetadata so we can construct it in jsdom.
(globalThis as unknown as { MediaMetadata: typeof Object }).MediaMetadata =
class { constructor(public init: unknown) {} } as unknown as typeof Object;
const store = await import('./store.svelte');
store.playQueue([]);
});
afterEach(() => {
if (originalMS === undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (navigator as any).mediaSession;
} else {
Object.defineProperty(navigator, 'mediaSession', {
configurable: true,
value: originalMS
});
}
vi.restoreAllMocks();
});
test('sets metadata when current track changes; clears when queue empties', async () => {
const store = await import('./store.svelte');
const { useMediaSession } = await import('./mediaSession.svelte');
const cleanup = $effect.root(() => useMediaSession());
store.playQueue([track('1')]);
flushSync();
expect(spy.metadata).not.toBeNull();
store.playQueue([]);
flushSync();
expect(spy.metadata).toBeNull();
cleanup();
});
test('registers play/pause/previoustrack/nexttrack/seekto handlers', async () => {
const { useMediaSession } = await import('./mediaSession.svelte');
const cleanup = $effect.root(() => useMediaSession());
flushSync();
expect(spy.handlers['play']).toBeTypeOf('function');
expect(spy.handlers['pause']).toBeTypeOf('function');
expect(spy.handlers['previoustrack']).toBeTypeOf('function');
expect(spy.handlers['nexttrack']).toBeTypeOf('function');
expect(spy.handlers['seekto']).toBeTypeOf('function');
cleanup();
});
test('playbackState mirrors player.isPlaying', async () => {
const store = await import('./store.svelte');
const { useMediaSession } = await import('./mediaSession.svelte');
const cleanup = $effect.root(() => useMediaSession());
store.playQueue([track('1')]);
store.reportStateFromAudio('playing');
flushSync();
expect(spy.playbackState).toBe('playing');
store.reportStateFromAudio('paused');
flushSync();
expect(spy.playbackState).toBe('paused');
cleanup();
});
test('setPositionState called when duration > 0', async () => {
const store = await import('./store.svelte');
const { useMediaSession } = await import('./mediaSession.svelte');
const cleanup = $effect.root(() => useMediaSession());
store.playQueue([track('1')]);
store.reportDuration(240);
store.reportTimeUpdate(30);
flushSync();
expect(spy.setPositionState).toHaveBeenCalled();
const call = spy.setPositionState.mock.calls.at(-1)?.[0];
expect(call?.duration).toBe(240);
expect(call?.position).toBeLessThanOrEqual(240);
cleanup();
});
});
+56
View File
@@ -0,0 +1,56 @@
import {
player,
togglePlay,
skipNext,
skipPrev,
seekTo
} from './store.svelte';
export function useMediaSession(): void {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
const ms = navigator.mediaSession;
$effect(() => {
const t = player.current;
if (!t) {
ms.metadata = null;
return;
}
ms.metadata = new MediaMetadata({
title: t.title,
artist: t.artist_name,
album: t.album_title,
artwork: [{
src: `/api/albums/${t.album_id}/cover`,
sizes: '512x512',
type: 'image/jpeg'
}]
});
});
ms.setActionHandler('play', () => togglePlay());
ms.setActionHandler('pause', () => togglePlay());
ms.setActionHandler('previoustrack', () => skipPrev());
ms.setActionHandler('nexttrack', () => skipNext());
ms.setActionHandler('seekto', (e) => {
if (typeof e.seekTime === 'number') seekTo(e.seekTime);
});
$effect(() => {
if (player.duration > 0 && 'setPositionState' in ms) {
try {
ms.setPositionState({
duration: player.duration,
position: Math.min(player.position, player.duration),
playbackRate: 1
});
} catch {
// setPositionState can throw on NaN/Infinity briefly between tracks.
}
}
});
$effect(() => {
ms.playbackState = player.isPlaying ? 'playing' : 'paused';
});
}
+217
View File
@@ -0,0 +1,217 @@
import type { TrackRef, RadioResponse } from '$lib/api/types';
import { api } from '$lib/api/client';
export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
export type RepeatMode = 'off' | 'all' | 'one';
const VOLUME_KEY = 'minstrel.volume';
export function readStoredVolume(): number {
try {
const raw = localStorage.getItem(VOLUME_KEY);
if (raw == null) return 1;
const n = Number(raw);
if (Number.isFinite(n) && n >= 0 && n <= 1) return n;
return 1;
} catch {
return 1;
}
}
let _queue = $state<TrackRef[]>([]);
let _index = $state(0);
let _state = $state<PlayerState>('idle');
let _position = $state(0);
let _duration = $state(0);
let _volume = $state(readStoredVolume());
let _shuffle = $state(false);
let _repeat = $state<RepeatMode>('off');
let _error = $state<string | null>(null);
let _audioEl: HTMLAudioElement | null = null;
export const player = {
get queue() { return _queue; },
get index() { return _index; },
get current() { return _queue[_index] as TrackRef | undefined; },
get state() { return _state; },
get isPlaying() { return _state === 'playing'; },
get position() { return _position; },
get duration() { return _duration; },
get volume() { return _volume; },
get shuffle() { return _shuffle; },
get repeat() { return _repeat; },
get error() { return _error; }
};
export function registerAudioEl(el: HTMLAudioElement | null): void {
_audioEl = el;
}
export function playQueue(tracks: TrackRef[], startIndex = 0): void {
_queue = tracks;
if (tracks.length === 0) {
_index = 0;
_state = 'idle';
} else {
_index = Math.max(0, Math.min(startIndex, tracks.length - 1));
_state = 'loading';
}
_position = 0;
_duration = 0;
_error = null;
}
export function togglePlay(): void {
if (_queue.length === 0) return;
// "Intent to play" (loading + playing) → paused. Anything else → loading (user
// wants to play; audio will catch up).
if (_state === 'playing' || _state === 'loading') {
_state = 'paused';
} else {
_state = 'loading';
}
}
export function skipNext(): void {
if (_queue.length === 0) return;
if (_index + 1 < _queue.length) {
_index++;
_position = 0;
_duration = 0;
_state = 'loading';
return;
}
// At end of queue.
if (_repeat === 'all') {
_index = 0;
_position = 0;
_duration = 0;
_state = 'loading';
} else {
// 'off' or 'one' both pause at end on EXPLICIT skip.
_state = 'paused';
_position = _duration;
}
}
export function skipPrev(): void {
if (_queue.length === 0) return;
if (_position < 3 && _index > 0) {
_index--;
_position = 0;
_duration = 0;
_state = 'loading';
} else {
// Seek to 0 of the current track.
_position = 0;
if (_audioEl) _audioEl.currentTime = 0;
}
}
export function seekTo(sec: number): void {
_position = sec;
if (_audioEl) _audioEl.currentTime = sec;
}
export function setVolume(v: number): void {
const clamped = Math.max(0, Math.min(1, v));
_volume = clamped;
try { localStorage.setItem(VOLUME_KEY, String(clamped)); } catch { /* ignore */ }
}
export function reportTimeUpdate(sec: number): void {
_position = sec;
}
export function reportDuration(sec: number): void {
_duration = Number.isFinite(sec) ? sec : 0;
}
// --- Shuffle (with injectable RNG for tests) ---
let _rng: () => number = Math.random;
export function __setShuffleRng(fn: () => number): void { _rng = fn; }
export function toggleShuffle(): void {
_shuffle = !_shuffle;
if (_shuffle && _index + 1 < _queue.length) {
// Fisher-Yates shuffle over _queue[_index+1 .. end].
const arr = _queue.slice();
for (let j = arr.length - 1; j > _index; j--) {
const r = Math.floor(_rng() * (j - _index)) + _index + 1;
[arr[j], arr[r]] = [arr[r], arr[j]];
}
_queue = arr;
}
}
// --- Repeat ---
export function cycleRepeat(): void {
_repeat = _repeat === 'off' ? 'all' : _repeat === 'all' ? 'one' : 'off';
}
export function reportStateFromAudio(
event: 'playing' | 'paused' | 'waiting' | 'ended' | 'error',
detail?: string
): void {
switch (event) {
case 'playing':
_state = 'playing';
_error = null;
return;
case 'paused':
_state = 'paused';
return;
case 'waiting':
_state = 'loading';
return;
case 'error':
_state = 'error';
_error = detail ?? 'Playback failed.';
return;
case 'ended':
if (_repeat === 'one') {
_position = 0;
_state = 'loading';
if (_audioEl) _audioEl.currentTime = 0;
return;
}
if (_index + 1 < _queue.length) {
_index++;
_position = 0;
_duration = 0;
_state = 'loading';
return;
}
if (_repeat === 'all') {
_index = 0;
_position = 0;
_duration = 0;
_state = 'loading';
} else {
_state = 'paused';
_position = _duration;
}
return;
}
}
export function enqueueTrack(t: TrackRef): void {
_queue = [..._queue, t];
if (_state === 'idle') _index = 0;
}
export function enqueueTracks(ts: TrackRef[]): void {
if (ts.length === 0) return;
_queue = [..._queue, ...ts];
if (_state === 'idle') _index = 0;
}
export async function playRadio(seedTrackId: string): Promise<void> {
const resp = await api.get<RadioResponse>(
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
);
if (resp.tracks.length > 0) playQueue(resp.tracks, 0);
}
+335
View File
@@ -0,0 +1,335 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
player,
playQueue,
togglePlay,
skipNext,
skipPrev,
seekTo,
setVolume,
registerAudioEl,
reportTimeUpdate,
reportDuration,
readStoredVolume,
toggleShuffle,
cycleRepeat,
reportStateFromAudio,
__setShuffleRng
} from './store.svelte';
import type { TrackRef } from '$lib/api/types';
function track(id: string, dur = 180): TrackRef {
return {
id, title: `T ${id}`,
album_id: 'a1', album_title: 'A',
artist_id: 'ar1', artist_name: 'Ar',
track_number: Number(id), disc_number: 1,
duration_sec: dur,
stream_url: `/api/tracks/${id}/stream`
};
}
beforeEach(() => {
localStorage.clear();
// Reset state by starting with an empty queue (playQueue([]) leaves queue=[], index=0).
playQueue([]);
setVolume(1);
registerAudioEl(null);
// Reset shuffle and repeat to defaults
if (player.shuffle) toggleShuffle();
while (player.repeat !== 'off') cycleRepeat();
});
describe('player store — basic actions', () => {
test('playQueue sets queue, index, state="loading", position=0', () => {
playQueue([track('1'), track('2'), track('3')], 1);
expect(player.queue.length).toBe(3);
expect(player.index).toBe(1);
expect(player.state).toBe('loading');
expect(player.position).toBe(0);
expect(player.current?.id).toBe('2');
});
test('playQueue clamps startIndex to [0, length-1]', () => {
playQueue([track('1'), track('2')], 99);
expect(player.index).toBe(1);
playQueue([track('1'), track('2')], -5);
expect(player.index).toBe(0);
});
test('togglePlay flips paused<->playing; no-op on idle', () => {
expect(player.state).toBe('idle');
togglePlay();
expect(player.state).toBe('idle'); // still idle with empty queue
playQueue([track('1')]);
// After playQueue we're in 'loading'; togglePlay should not advance without an audio event.
// But togglePlay from 'loading' should set the intent to play anyway — tested via report later.
// Here, simulate the paused state:
reportTimeUpdate(0);
// manually put us in paused state by calling togglePlay twice isn't clean; set via the state-report API in Task 2.
// Instead, test the simpler case: playQueue then togglePlay = still 'loading' (intent is to play).
togglePlay(); // from 'loading' — inverts intent; store moves to 'paused' intent
expect(player.state).toBe('paused');
togglePlay(); // from 'paused' — moves to 'loading' (re-play intent)
expect(player.state).toBe('loading');
});
test('skipNext mid-queue: index++, position=0', () => {
playQueue([track('1'), track('2'), track('3')], 0);
reportTimeUpdate(50);
skipNext();
expect(player.index).toBe(1);
expect(player.position).toBe(0);
});
test('skipNext at end with no repeat: index stays, state=paused, position=duration', () => {
playQueue([track('1'), track('2')], 1);
reportDuration(180);
skipNext();
expect(player.index).toBe(1);
expect(player.state).toBe('paused');
expect(player.position).toBe(180);
});
test('skipPrev with position<3 AND index>0: decrements index', () => {
playQueue([track('1'), track('2'), track('3')], 2);
reportTimeUpdate(2);
skipPrev();
expect(player.index).toBe(1);
expect(player.position).toBe(0);
});
test('skipPrev with position>=3: seeks to 0, index unchanged', () => {
playQueue([track('1'), track('2')], 1);
reportTimeUpdate(10);
skipPrev();
expect(player.index).toBe(1);
expect(player.position).toBe(0);
});
test('skipPrev at index=0: position=0, index stays', () => {
playQueue([track('1'), track('2')], 0);
reportTimeUpdate(2);
skipPrev();
expect(player.index).toBe(0);
expect(player.position).toBe(0);
});
test('setVolume clamps to [0,1] and persists to localStorage', () => {
setVolume(0.5);
expect(player.volume).toBe(0.5);
expect(localStorage.getItem('minstrel.volume')).toBe('0.5');
setVolume(-1);
expect(player.volume).toBe(0);
setVolume(2);
expect(player.volume).toBe(1);
});
test('readStoredVolume returns 1.0 when missing or invalid', () => {
localStorage.clear();
expect(readStoredVolume()).toBe(1);
localStorage.setItem('minstrel.volume', 'not-a-number');
expect(readStoredVolume()).toBe(1);
localStorage.setItem('minstrel.volume', '-10');
expect(readStoredVolume()).toBe(1); // out of range → fallback
localStorage.setItem('minstrel.volume', '0.7');
expect(readStoredVolume()).toBe(0.7);
});
test('seekTo writes position and, when registered, audioEl.currentTime', () => {
const el = { currentTime: 0 } as unknown as HTMLAudioElement;
registerAudioEl(el);
playQueue([track('1')]);
seekTo(42);
expect(player.position).toBe(42);
expect(el.currentTime).toBe(42);
// After unregister, seekTo still updates store but doesn't throw.
registerAudioEl(null);
seekTo(10);
expect(player.position).toBe(10);
});
test('reportTimeUpdate and reportDuration mutate position and duration', () => {
playQueue([track('1')]);
reportTimeUpdate(15);
reportDuration(240);
expect(player.position).toBe(15);
expect(player.duration).toBe(240);
});
});
describe('player store — shuffle + repeat + audio reports', () => {
test('cycleRepeat: off → all → one → off', () => {
playQueue([track('1')]);
expect(player.repeat).toBe('off');
cycleRepeat();
expect(player.repeat).toBe('all');
cycleRepeat();
expect(player.repeat).toBe('one');
cycleRepeat();
expect(player.repeat).toBe('off');
});
test('toggleShuffle randomizes queue[index+1..end], leaves current and earlier alone', () => {
// Deterministic RNG: always returns 0 so Fisher-Yates swaps each i with i itself → no change.
// Flip to always 0.999 to force swaps.
const seq = [0.999, 0.999, 0.999, 0.999, 0.999];
let i = 0;
__setShuffleRng(() => seq[i++ % seq.length]);
const tracks = [track('1'), track('2'), track('3'), track('4'), track('5')];
playQueue(tracks, 1);
toggleShuffle();
expect(player.shuffle).toBe(true);
// Index 0 and 1 preserved:
expect(player.queue[0].id).toBe('1');
expect(player.queue[1].id).toBe('2');
// Index 2..4 reordered: because RNG = 0.999 always, Math.floor(rng() * (j+1)) === j for each,
// so swaps are with self — queue is unchanged. So validate that the IDs 3/4/5 are still the
// tail set, in any order, to tolerate future RNG semantics.
const tailIds = [player.queue[2].id, player.queue[3].id, player.queue[4].id].sort();
expect(tailIds).toEqual(['3', '4', '5']);
__setShuffleRng(Math.random);
});
test('skipNext at end with repeat=all: wraps to 0', () => {
playQueue([track('1'), track('2')], 1);
cycleRepeat(); // repeat=all
skipNext();
expect(player.index).toBe(0);
expect(player.state).toBe('loading');
});
test('skipNext at end with repeat=one: still ends (explicit skip overrides)', () => {
playQueue([track('1')], 0);
cycleRepeat(); // all
cycleRepeat(); // one
reportDuration(120);
skipNext();
expect(player.state).toBe('paused');
expect(player.position).toBe(120);
});
test('reportStateFromAudio("ended") with repeat="one": position=0, index unchanged, state=loading', () => {
playQueue([track('1'), track('2')], 0);
reportTimeUpdate(120);
cycleRepeat(); cycleRepeat(); // repeat=one
reportStateFromAudio('ended');
expect(player.index).toBe(0);
expect(player.position).toBe(0);
expect(player.state).toBe('loading');
});
test('reportStateFromAudio("ended") with repeat="all": wraps when at end', () => {
playQueue([track('1'), track('2')], 1);
cycleRepeat(); // repeat=all
reportStateFromAudio('ended');
expect(player.index).toBe(0);
expect(player.state).toBe('loading');
});
test('reportStateFromAudio("ended") with repeat="all" mid-queue: advances', () => {
playQueue([track('1'), track('2'), track('3')], 0);
cycleRepeat(); // repeat=all
reportStateFromAudio('ended');
expect(player.index).toBe(1);
});
test('reportStateFromAudio("ended") with repeat="off" at end: paused', () => {
playQueue([track('1'), track('2')], 1);
reportDuration(180);
reportStateFromAudio('ended');
expect(player.state).toBe('paused');
expect(player.position).toBe(180);
});
test('reportStateFromAudio("playing") sets state=playing, clears error', () => {
playQueue([track('1')]);
reportStateFromAudio('error', 'boom');
expect(player.error).toBe('boom');
reportStateFromAudio('playing');
expect(player.state).toBe('playing');
expect(player.error).toBeNull();
});
test('reportStateFromAudio("paused") sets state=paused', () => {
playQueue([track('1')]);
reportStateFromAudio('playing');
reportStateFromAudio('paused');
expect(player.state).toBe('paused');
});
test('reportStateFromAudio("waiting") sets state=loading', () => {
playQueue([track('1')]);
reportStateFromAudio('playing');
reportStateFromAudio('waiting');
expect(player.state).toBe('loading');
});
test('reportStateFromAudio("error", msg) sets state=error, error=msg', () => {
playQueue([track('1')]);
reportStateFromAudio('error', 'network lost');
expect(player.state).toBe('error');
expect(player.error).toBe('network lost');
});
});
import { enqueueTrack, enqueueTracks, playRadio } from './store.svelte';
import * as client from '$lib/api/client';
describe('player store — enqueue + playRadio', () => {
test('enqueueTrack appends to a non-empty queue, _index unchanged', () => {
playQueue([track('1'), track('2')], 1);
enqueueTrack(track('3'));
expect(player.queue.length).toBe(3);
expect(player.queue[2].id).toBe('3');
expect(player.index).toBe(1);
});
test('enqueueTrack on idle (empty queue) sets index=0 and _state stays idle', () => {
playQueue([]);
expect(player.state).toBe('idle');
enqueueTrack(track('1'));
expect(player.queue.length).toBe(1);
expect(player.index).toBe(0);
expect(player.state).toBe('idle');
});
test('enqueueTracks([]) is a no-op', () => {
playQueue([track('1')]);
enqueueTracks([]);
expect(player.queue.length).toBe(1);
});
test('enqueueTracks concatenates non-empty list', () => {
playQueue([track('1')]);
enqueueTracks([track('2'), track('3')]);
expect(player.queue.map((t) => t.id)).toEqual(['1', '2', '3']);
});
test('playRadio fetches /api/radio and feeds tracks into playQueue', async () => {
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({
tracks: [track('seed'), track('next')]
});
await playRadio('seed');
expect(apiGet).toHaveBeenCalledWith('/api/radio?seed_track=seed');
expect(player.queue.length).toBe(2);
expect(player.index).toBe(0);
expect(player.state).toBe('loading');
apiGet.mockRestore();
});
test('playRadio with empty response leaves queue alone', async () => {
playQueue([track('a')]);
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: [] });
await playRadio('seed');
expect(player.queue.map((t) => t.id)).toEqual(['a']);
apiGet.mockRestore();
});
});
+58
View File
@@ -6,8 +6,18 @@
import { queryClient } from '$lib/query/client';
import { user } from '$lib/auth/store.svelte';
import Shell from '$lib/components/Shell.svelte';
import {
registerAudioEl,
reportTimeUpdate,
reportDuration,
reportStateFromAudio,
player
} from '$lib/player/store.svelte';
import { useMediaSession } from '$lib/player/mediaSession.svelte';
import { useEventsDispatcher } from '$lib/player/events.svelte';
let { children } = $props<{ children: import('svelte').Snippet }>();
let audioEl: HTMLAudioElement | undefined = $state();
// Reactive guard: runs every time page.url or user.value changes.
$effect(() => {
@@ -20,8 +30,56 @@
goto('/', { replaceState: true });
}
});
$effect(() => {
if (audioEl) registerAudioEl(audioEl);
return () => registerAudioEl(null);
});
$effect(() => {
if (!audioEl) return;
const src = player.current?.stream_url;
if (src) {
const absolute = new URL(src, window.location.origin).href;
if (audioEl.src !== absolute) audioEl.src = src;
} else {
audioEl.removeAttribute('src');
audioEl.load();
}
});
$effect(() => {
if (audioEl) audioEl.volume = player.volume;
});
$effect(() => {
if (!audioEl) return;
// "Intent to play" = loading or playing. During 'loading' the DOM hasn't
// emitted 'playing' yet, but we still need to call play() to get there.
const intent = player.state === 'playing' || player.state === 'loading';
if (intent) {
audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ });
} else {
audioEl.pause();
}
});
useMediaSession();
useEventsDispatcher();
</script>
<audio
bind:this={audioEl}
preload="metadata"
ontimeupdate={() => audioEl && reportTimeUpdate(audioEl.currentTime)}
onloadedmetadata={() => audioEl && reportDuration(audioEl.duration)}
onplaying={() => reportStateFromAudio('playing')}
onpause={() => reportStateFromAudio('paused')}
onwaiting={() => reportStateFromAudio('waiting')}
onended={() => reportStateFromAudio('ended')}
onerror={() => reportStateFromAudio('error', audioEl?.error?.message)}
></audio>
<QueryClientProvider client={queryClient}>
{#if user.value !== null && page.url.pathname !== '/login'}
<Shell>{@render children()}</Shell>
+2 -2
View File
@@ -69,8 +69,8 @@
<p class="text-text-secondary">This album has no tracks.</p>
{:else}
<div class="overflow-hidden rounded border border-border">
{#each album.tracks as track (track.id)}
<TrackRow {track} />
{#each album.tracks as track, i (track.id)}
<TrackRow tracks={album.tracks} index={i} />
{/each}
</div>
{/if}
+112 -2
View File
@@ -1,2 +1,112 @@
<h1 class="text-2xl font-semibold">Search</h1>
<p class="mt-2 text-text-secondary">Search lands in a subsequent plan.</p>
<script lang="ts">
import { page } from '$app/state';
import { createSearchQuery } from '$lib/api/queries';
import ArtistRow from '$lib/components/ArtistRow.svelte';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import TrackRow from '$lib/components/TrackRow.svelte';
import SearchSkeleton from '$lib/components/SearchSkeleton.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
import { playRadio } from '$lib/player/store.svelte';
import type { TrackRef } from '$lib/api/types';
const q = $derived((page.url.searchParams.get('q') ?? '').trim());
const queryStore = $derived(q ? createSearchQuery(q) : null);
const query = $derived(queryStore ? $queryStore : null);
const artists = $derived(query?.data?.artists);
const albums = $derived(query?.data?.albums);
const tracks = $derived(query?.data?.tracks);
const allEmpty = $derived(
!!query?.data &&
artists?.items.length === 0 &&
albums?.items.length === 0 &&
tracks?.items.length === 0
);
const showSkeleton = useDelayed(() => !!query?.isPending);
function onTrackPlay(_tracks: TrackRef[], index: number) {
playRadio(_tracks[index].id);
}
</script>
<div class="space-y-6">
{#if !q}
<p class="text-text-secondary">
Start typing to search artists, albums, and tracks.
</p>
{:else if query?.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && !query?.data}
<SearchSkeleton />
{:else if allEmpty}
<p class="text-text-secondary">
No matches for <span class="font-medium">'{q}'</span>.
</p>
{:else if query?.data}
{#if artists && artists.items.length > 0}
<section>
<header class="mb-2 flex items-baseline justify-between">
<h2 class="text-lg font-semibold">Artists</h2>
{#if artists.total > artists.items.length}
<a
href={`/search/artists?q=${encodeURIComponent(q)}`}
class="text-sm text-accent hover:underline"
>
See all {artists.total}
</a>
{/if}
</header>
<div>
{#each artists.items as a (a.id)}
<ArtistRow artist={a} />
{/each}
</div>
</section>
{/if}
{#if albums && albums.items.length > 0}
<section>
<header class="mb-2 flex items-baseline justify-between">
<h2 class="text-lg font-semibold">Albums</h2>
{#if albums.total > albums.items.length}
<a
href={`/search/albums?q=${encodeURIComponent(q)}`}
class="text-sm text-accent hover:underline"
>
See all {albums.total}
</a>
{/if}
</header>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each albums.items as al (al.id)}
<AlbumCard album={al} />
{/each}
</div>
</section>
{/if}
{#if tracks && tracks.items.length > 0}
<section>
<header class="mb-2 flex items-baseline justify-between">
<h2 class="text-lg font-semibold">Tracks</h2>
{#if tracks.total > tracks.items.length}
<a
href={`/search/tracks?q=${encodeURIComponent(q)}`}
class="text-sm text-accent hover:underline"
>
See all {tracks.total}
</a>
{/if}
</header>
<div class="overflow-hidden rounded border border-border">
{#each tracks.items as t, i (t.id)}
<TrackRow tracks={tracks.items} index={i} onPlay={onTrackPlay} />
{/each}
</div>
</section>
{/if}
{/if}
</div>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import { page } from '$app/state';
import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
const q = $derived((page.url.searchParams.get('q') ?? '').trim());
const queryStore = $derived(q ? createSearchAlbumsInfiniteQuery(q) : null);
const query = $derived(queryStore ? $queryStore : null);
const albums = $derived(query?.data?.pages?.flatMap((p) => p.items) ?? []);
const total = $derived(query?.data?.pages?.[0]?.total ?? 0);
const showSkeleton = useDelayed(() => !!query?.isPending);
</script>
<div class="space-y-4">
<header>
<a href={`/search?q=${encodeURIComponent(q)}`} class="text-sm text-text-secondary hover:text-text-primary">
← Back to search
</a>
<h1 class="mt-2 text-2xl font-semibold">Albums matching '{q}'</h1>
{#if !query?.isPending && !query?.isError && q}
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'album' : 'albums'}</p>
{/if}
</header>
{#if !q}
<p class="text-text-secondary">No query — return to search to start typing.</p>
{:else if query?.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && albums.length === 0}
<LibrarySkeleton variant="grid" count={12} />
{:else if total === 0}
<p class="text-text-secondary">No album matches.</p>
{:else}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each albums as a (a.id)}
<AlbumCard album={a} />
{/each}
</div>
{#if query?.hasNextPage}
<button
type="button"
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
disabled={query.isFetchingNextPage}
onclick={() => query.fetchNextPage()}
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{/if}
{/if}
</div>
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { mockInfiniteQuery } from '../../../test-utils/query';
import type { AlbumRef, Page } from '$lib/api/types';
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/albums?q=miles') }));
vi.mock('$app/state', () => ({
page: { get url() { return state.pageUrl; } }
}));
vi.mock('$lib/api/queries', () => ({
createSearchAlbumsInfiniteQuery: vi.fn()
}));
vi.mock('$lib/api/client', () => ({
api: { get: vi.fn() }
}));
vi.mock('$lib/player/store.svelte', () => ({
enqueueTracks: vi.fn()
}));
import AlbumsOverflow from './+page.svelte';
import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries';
function page<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
return { items, total, offset, limit };
}
const album: AlbumRef = {
id: 'al1', title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles Davis',
year: 1959, track_count: 5, duration_sec: 2630, cover_url: '/api/albums/al1/cover'
};
afterEach(() => {
vi.clearAllMocks();
state.pageUrl = new URL('http://localhost/search/albums?q=miles');
});
describe('search albums overflow', () => {
test('renders an AlbumCard per album', () => {
(createSearchAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page([album], 1)] })
);
render(AlbumsOverflow);
expect(screen.getByRole('link', { name: /Kind of Blue/ })).toHaveAttribute('href', '/albums/al1');
});
test('Load more calls fetchNextPage', async () => {
const fetchNextPage = vi.fn();
(createSearchAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({
pages: [page<AlbumRef>([], 100)],
hasNextPage: true,
fetchNextPage
})
);
render(AlbumsOverflow);
await fireEvent.click(screen.getByRole('button', { name: /load more/i }));
expect(fetchNextPage).toHaveBeenCalledTimes(1);
});
test('empty q shows a prompt', () => {
state.pageUrl = new URL('http://localhost/search/albums');
render(AlbumsOverflow);
expect(screen.getByText(/no query/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
<script lang="ts">
import { page } from '$app/state';
import { createSearchArtistsInfiniteQuery } from '$lib/api/queries';
import ArtistRow from '$lib/components/ArtistRow.svelte';
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
const q = $derived((page.url.searchParams.get('q') ?? '').trim());
const queryStore = $derived(q ? createSearchArtistsInfiniteQuery(q) : null);
const query = $derived(queryStore ? $queryStore : null);
const artists = $derived(query?.data?.pages?.flatMap((p) => p.items) ?? []);
const total = $derived(query?.data?.pages?.[0]?.total ?? 0);
const showSkeleton = useDelayed(() => !!query?.isPending);
</script>
<div class="space-y-4">
<header>
<a href={`/search?q=${encodeURIComponent(q)}`} class="text-sm text-text-secondary hover:text-text-primary">
← Back to search
</a>
<h1 class="mt-2 text-2xl font-semibold">Artists matching '{q}'</h1>
{#if !query?.isPending && !query?.isError && q}
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'artist' : 'artists'}</p>
{/if}
</header>
{#if !q}
<p class="text-text-secondary">No query — return to search to start typing.</p>
{:else if query?.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && artists.length === 0}
<LibrarySkeleton variant="list" count={12} />
{:else if total === 0}
<p class="text-text-secondary">No artist matches.</p>
{:else}
<div>
{#each artists as a (a.id)}
<ArtistRow artist={a} />
{/each}
</div>
{#if query?.hasNextPage}
<button
type="button"
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
disabled={query.isFetchingNextPage}
onclick={() => query.fetchNextPage()}
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{/if}
{/if}
</div>
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { mockInfiniteQuery } from '../../../test-utils/query';
import type { ArtistRef, Page } from '$lib/api/types';
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/artists?q=miles') }));
vi.mock('$app/state', () => ({
page: { get url() { return state.pageUrl; } }
}));
vi.mock('$lib/api/queries', () => ({
createSearchArtistsInfiniteQuery: vi.fn()
}));
import ArtistsOverflow from './+page.svelte';
import { createSearchArtistsInfiniteQuery } from '$lib/api/queries';
function page<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
return { items, total, offset, limit };
}
afterEach(() => {
vi.clearAllMocks();
state.pageUrl = new URL('http://localhost/search/artists?q=miles');
});
describe('search artists overflow', () => {
test('renders a row per artist', () => {
const a1: ArtistRef = { id: 'a1', name: 'Miles Davis', album_count: 12 };
const a2: ArtistRef = { id: 'a2', name: 'Miles Mosley', album_count: 1 };
(createSearchArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page([a1, a2], 2)] })
);
render(ArtistsOverflow);
expect(screen.getByRole('link', { name: /Miles Davis/ })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Miles Mosley/ })).toBeInTheDocument();
});
test('Load more calls fetchNextPage when hasNextPage', async () => {
const fetchNextPage = vi.fn();
(createSearchArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({
pages: [page<ArtistRef>([], 100)],
hasNextPage: true,
fetchNextPage
})
);
render(ArtistsOverflow);
await fireEvent.click(screen.getByRole('button', { name: /load more/i }));
expect(fetchNextPage).toHaveBeenCalledTimes(1);
});
test('Load more is hidden when hasNextPage is false', () => {
(createSearchArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<ArtistRef>([{ id: 'a', name: 'A', album_count: 1 }], 1)] })
);
render(ArtistsOverflow);
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
});
test('empty q shows a prompt and fires no query', () => {
state.pageUrl = new URL('http://localhost/search/artists');
render(ArtistsOverflow);
expect(screen.getByText(/no query/i)).toBeInTheDocument();
expect(createSearchArtistsInfiniteQuery).not.toHaveBeenCalled();
});
});
+135
View File
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import type { ArtistRef, AlbumRef, TrackRef, SearchResponse } from '$lib/api/types';
const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/search')
}));
vi.mock('$app/state', () => ({
page: { get url() { return state.pageUrl; } }
}));
vi.mock('$lib/api/queries', () => ({
createSearchQuery: vi.fn()
}));
vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn(),
playRadio: vi.fn(),
enqueueTrack: vi.fn(),
enqueueTracks: vi.fn()
}));
vi.mock('$lib/api/client', () => ({
api: { get: vi.fn() }
}));
import SearchPage from './+page.svelte';
import { createSearchQuery } from '$lib/api/queries';
function emptyResp(): SearchResponse {
return {
artists: { items: [], total: 0, limit: 10, offset: 0 },
albums: { items: [], total: 0, limit: 10, offset: 0 },
tracks: { items: [], total: 0, limit: 10, offset: 0 }
};
}
const artist: ArtistRef = { id: 'a1', name: 'Miles Davis', album_count: 12 };
const album: AlbumRef = {
id: 'al1', title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles Davis',
year: 1959, track_count: 5, duration_sec: 2630, cover_url: '/api/albums/al1/cover'
};
const track: TrackRef = {
id: 't1', title: 'So What',
album_id: 'al1', album_title: 'Kind of Blue',
artist_id: 'a1', artist_name: 'Miles Davis',
track_number: 1, disc_number: 1, duration_sec: 545,
stream_url: '/api/tracks/t1/stream'
};
afterEach(() => {
vi.clearAllMocks();
state.pageUrl = new URL('http://localhost/search');
});
describe('search page', () => {
test('empty ?q= shows the "Start typing" prompt and fires no query', () => {
state.pageUrl = new URL('http://localhost/search');
render(SearchPage);
expect(screen.getByText(/start typing/i)).toBeInTheDocument();
expect(createSearchQuery).not.toHaveBeenCalled();
});
test('all three sections render when all three facets have items', () => {
state.pageUrl = new URL('http://localhost/search?q=miles');
const resp: SearchResponse = {
...emptyResp(),
artists: { items: [artist], total: 1, limit: 10, offset: 0 },
albums: { items: [album], total: 1, limit: 10, offset: 0 },
tracks: { items: [track], total: 1, limit: 10, offset: 0 }
};
(createSearchQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: resp }));
render(SearchPage);
expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument();
});
test('empty facets are hidden', () => {
state.pageUrl = new URL('http://localhost/search?q=miles');
const resp: SearchResponse = {
...emptyResp(),
tracks: { items: [track], total: 1, limit: 10, offset: 0 }
};
(createSearchQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: resp }));
render(SearchPage);
expect(screen.queryByRole('heading', { name: /artists/i })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /albums/i })).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument();
});
test('"See all N →" link renders when total > items.length', () => {
state.pageUrl = new URL('http://localhost/search?q=miles');
const resp: SearchResponse = {
...emptyResp(),
artists: { items: [artist], total: 47, limit: 10, offset: 0 }
};
(createSearchQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: resp }));
render(SearchPage);
const link = screen.getByRole('link', { name: /see all 47/i });
expect(link).toHaveAttribute('href', '/search/artists?q=miles');
});
test('"See all" link omitted when total === items.length', () => {
state.pageUrl = new URL('http://localhost/search?q=miles');
const resp: SearchResponse = {
...emptyResp(),
tracks: { items: [track], total: 1, limit: 10, offset: 0 }
};
(createSearchQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: resp }));
render(SearchPage);
expect(screen.queryByRole('link', { name: /see all/i })).not.toBeInTheDocument();
});
test('all three facets empty renders "No matches"', () => {
state.pageUrl = new URL('http://localhost/search?q=zzz');
(createSearchQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: emptyResp() })
);
render(SearchPage);
expect(screen.getByText(/no matches for/i)).toBeInTheDocument();
expect(screen.getByText(/zzz/)).toBeInTheDocument();
});
test('error state renders ApiErrorBanner', () => {
state.pageUrl = new URL('http://localhost/search?q=miles');
(createSearchQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ isError: true, error: { code: 'server_error', message: 'boom' } })
);
render(SearchPage);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import { page } from '$app/state';
import { createSearchTracksInfiniteQuery } from '$lib/api/queries';
import TrackRow from '$lib/components/TrackRow.svelte';
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
import { playRadio } from '$lib/player/store.svelte';
import type { TrackRef } from '$lib/api/types';
const q = $derived((page.url.searchParams.get('q') ?? '').trim());
const queryStore = $derived(q ? createSearchTracksInfiniteQuery(q) : null);
const query = $derived(queryStore ? $queryStore : null);
const tracks = $derived(query?.data?.pages?.flatMap((p) => p.items) ?? []);
const total = $derived(query?.data?.pages?.[0]?.total ?? 0);
const showSkeleton = useDelayed(() => !!query?.isPending);
function onTrackPlay(_tracks: TrackRef[], index: number) {
playRadio(_tracks[index].id);
}
</script>
<div class="space-y-4">
<header>
<a href={`/search?q=${encodeURIComponent(q)}`} class="text-sm text-text-secondary hover:text-text-primary">
← Back to search
</a>
<h1 class="mt-2 text-2xl font-semibold">Tracks matching '{q}'</h1>
{#if !query?.isPending && !query?.isError && q}
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'track' : 'tracks'}</p>
{/if}
</header>
{#if !q}
<p class="text-text-secondary">No query — return to search to start typing.</p>
{:else if query?.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && tracks.length === 0}
<LibrarySkeleton variant="list" count={12} />
{:else if total === 0}
<p class="text-text-secondary">No track matches.</p>
{:else}
<div class="overflow-hidden rounded border border-border">
{#each tracks as t, i (t.id)}
<TrackRow tracks={tracks} index={i} onPlay={onTrackPlay} />
{/each}
</div>
{#if query?.hasNextPage}
<button
type="button"
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
disabled={query.isFetchingNextPage}
onclick={() => query.fetchNextPage()}
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{/if}
{/if}
</div>
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { mockInfiniteQuery } from '../../../test-utils/query';
import type { TrackRef, Page } from '$lib/api/types';
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/tracks?q=miles') }));
vi.mock('$app/state', () => ({
page: { get url() { return state.pageUrl; } }
}));
vi.mock('$lib/api/queries', () => ({
createSearchTracksInfiniteQuery: vi.fn()
}));
vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn(),
playRadio: vi.fn(),
enqueueTrack: vi.fn()
}));
import TracksOverflow from './+page.svelte';
import { createSearchTracksInfiniteQuery } from '$lib/api/queries';
import { playRadio } from '$lib/player/store.svelte';
function page<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
return { items, total, offset, limit };
}
const track: TrackRef = {
id: 't1', title: 'So What',
album_id: 'al1', album_title: 'Kind of Blue',
artist_id: 'a1', artist_name: 'Miles Davis',
track_number: 1, disc_number: 1, duration_sec: 545,
stream_url: '/api/tracks/t1/stream'
};
afterEach(() => {
vi.clearAllMocks();
state.pageUrl = new URL('http://localhost/search/tracks?q=miles');
});
describe('search tracks overflow', () => {
test('renders a TrackRow per track', () => {
(createSearchTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page([track], 1)] })
);
render(TracksOverflow);
expect(screen.getByRole('button', { name: /So What/ })).toBeInTheDocument();
});
test('clicking a track row triggers playRadio with the track id', async () => {
(createSearchTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page([track], 1)] })
);
render(TracksOverflow);
await fireEvent.click(screen.getByRole('button', { name: /So What/ }));
expect(playRadio).toHaveBeenCalledWith('t1');
});
test('Load more calls fetchNextPage', async () => {
const fetchNextPage = vi.fn();
(createSearchTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({
pages: [page<TrackRef>([], 100)],
hasNextPage: true,
fetchNextPage
})
);
render(TracksOverflow);
await fireEvent.click(screen.getByRole('button', { name: /load more/i }));
expect(fetchNextPage).toHaveBeenCalledTimes(1);
});
test('empty q shows a prompt', () => {
state.pageUrl = new URL('http://localhost/search/tracks');
render(TracksOverflow);
expect(screen.getByText(/no query/i)).toBeInTheDocument();
});
});
+22
View File
@@ -1 +1,23 @@
import '@testing-library/jest-dom/vitest';
// Node 25 ships a partial localStorage global that lacks getItem/setItem/clear
// unless launched with --localstorage-file. jsdom sees Node's global and skips
// installing its own. Replace with an in-memory Storage-compatible polyfill so
// tests (and browser-only code under test) see a working localStorage.
class MemoryStorage implements Storage {
private store = new Map<string, string>();
get length() { return this.store.size; }
clear(): void { this.store.clear(); }
getItem(key: string): string | null { return this.store.get(key) ?? null; }
key(i: number): string | null { return Array.from(this.store.keys())[i] ?? null; }
removeItem(key: string): void { this.store.delete(key); }
setItem(key: string, value: string): void { this.store.set(key, String(value)); }
}
const memLocal = new MemoryStorage();
const memSession = new MemoryStorage();
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: memLocal });
Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: memSession });
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'localStorage', { configurable: true, value: memLocal });
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession });
}