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:
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package dbq
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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,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,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;
|
||||
@@ -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);
|
||||
@@ -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 *;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user