1
0

Compare commits

...

6 Commits

18 changed files with 227 additions and 787 deletions

View File

@@ -0,0 +1,105 @@
# Navidrome Deep Link Implementation Plan (plan 4)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the in-app album player with a server-built Navidrome album link on the item page.
**Architecture:** Deletion-heavy change: the player (context, mini-bar, audio element), stream/tracks/played proxy routes, and their tests are removed. `matchedAlbum` in the item detail gains `webUrl`, built server-side from the user's Subsonic URL. Spec: `docs/superpowers/specs/2026-09-04-navidrome-link-design.md`.
**Tech Stack:** unchanged. **Prerequisites:** main (post Wave 1), 205/205 tests. Branch: `navidrome-link`.
---
### Task 1: Backend — webUrl in matchedAlbum; delete stream routes
**Files:**
- Modify: `server/src/routes/collectionRoutes.ts`, `server/src/app.ts`
- Delete: `server/src/routes/streamRoutes.ts`, `server/test/stream.test.ts`
- Test: `server/test/collection.test.ts` (modify), `server/test/lastplayed.test.ts` (modify)
- [ ] **Step 1: Modify the detail route in `server/src/routes/collectionRoutes.ts`** — build webUrl from the user's Subsonic URL (import `getSettings` from './settingsRoutes.js'):
```ts
const matched = findMatchedAlbum(db, request.user.id, row.id)
const settings = getSettings(db, request.user.id)
const webUrl =
matched && settings.subsonic_url
? `${settings.subsonic_url.replace(/\/+$/, '')}/app/#/album/${matched.subsonicId}`
: null
return {
...rowToItem(db, row),
matchedAlbum: matched ? { ...matched, webUrl } : null,
loan: ... // unchanged
}
```
- [ ] **Step 2: Update the matchedAlbum test** in `server/test/collection.test.ts` ('detail includes matchedAlbum') — `webUrl` requires the user's subsonic_url: before the first detail assertion, PUT /api/settings with a subsonic config (reuse the ping-stub pattern from settings.test.ts so the PUT succeeds), seed album `alb-9`, then assert `matchedAlbum` matches `{ subsonicId: 'alb-9', webUrl: 'http://navidrome.local/app/#/album/alb-9' }`. Keep the pre-seed assertion (`matchedAlbum` null).
- [ ] **Step 3: Delete the stream routes** — remove `server/src/routes/streamRoutes.ts`, `server/test/stream.test.ts`, and the import/registration lines in `server/src/app.ts`. Update `server/test/lastplayed.test.ts`: the 'played endpoint' test is removed (route deleted); keep the sync-stamping test (it exercises sync.ts, which stays).
- [ ] **Step 4: Run tests**`npm test && npm run typecheck` — expect ALL PASS (205 2 played/stream tests + 0 new = 203; exact count may shift ±1 with the lastplayed restructure — report it).
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: matchedAlbum.webUrl from subsonic settings; remove stream proxy routes"
```
---
### Task 2: Web — remove player, add the link
**Files:**
- Modify: `web/src/pages/ItemPage.tsx`, `web/src/App.tsx`, `web/src/api.ts`, `web/src/types.ts`, `web/test/item.test.tsx`, `web/test/api.test.ts`
- Delete: `web/src/player/` (PlayerContext.tsx, MiniBar.tsx), `web/test/player.test.tsx`, `web/test/minibar.test.tsx`
- [ ] **Step 1: Update failing tests first in `web/test/item.test.tsx`:**
- Remove PlayerProvider from `renderItem`, media prototype mocks, getAlbumTracks/markPlayed/lendItem mock entries where player-related; keep lendItem/returnLoan (loans stay)
- The rippedItem fixture keeps `matchedAlbum: matched` — extend `matched` with `webUrl: 'http://navidrome.local/app/#/album/alb-1'`
- Replace the 'shows Play…loads the player' test with: ripped+matched → link `[aria-label="Listen in Navidrome"]`... simpler: `getByRole('link', { name: /listen in navidrome/i })` with `getAttribute('href')` = the webUrl and `target` = '_blank'
- 'hides Play when unmatched or not ripped' → renamed: link absent when `matchedAlbum: null` (even when ripped)
- 'shows last played' unchanged
- lend/return tests unchanged
- [ ] **Step 2: Implement `web/src/pages/ItemPage.tsx`:** remove `usePlayer` import + `load` usage + media code; replace the Play button block with:
```tsx
{item.matchedAlbum && (
<a
href={item.matchedAlbum.webUrl}
target="_blank"
rel="noreferrer"
className="block w-full rounded-xl bg-emerald-500 py-2.5 text-center font-medium text-neutral-950"
>
Listen in Navidrome
</a>
)}
```
- [ ] **Step 3: Remove the player from the app:** delete `web/src/player/`, `web/test/player.test.tsx`, `web/test/minibar.test.tsx`; revert `web/src/App.tsx` wiring to `<Gate><Shell /></Gate>` (no PlayerProvider/MiniBar imports).
- [ ] **Step 4: Remove dead api/types:** delete `getAlbumTracks`, `markPlayed`, `streamUrl` methods from `web/src/api.ts`; delete `Track`, `AlbumTracks` interfaces from `web/src/types.ts` AND add `webUrl: string` to the `MatchedAlbum` interface (the detail route now supplies it); remove their entries + the 'album tracks + played + stream url' test from `web/test/api.test.ts`.
- [ ] **Step 5: Run tests**`npm test && npm run typecheck` — expect ALL PASS (203 3 player tests 3 minibar tests 1 api test-block + 1 item rewrite ≈ 197; report exact).
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: navidrome deep link replaces in-app player"
```
---
### Task 3: Verification + redeploy
- [ ] **Step 1:** `npm test && npm run typecheck && npm run build` — all green, both dists.
- [ ] **Step 2:** Restart the deployed instance (anchored pkill + setsid nohup pattern; verify in a separate invocation): health ok, SPA title served, `curl -X POST .../api/album/a1/played` → 404 (route gone).
- [ ] **Step 3:** No commit expected; report results.
---
## Verification checklist (manual)
- Item page for a ripped, matched album: 'Listen in Navidrome ↗' opens the correct album in Navidrome (desktop + phone)
- 'Last played X ago' still populates after a library sync (Navidrome-provided timestamps)
- No mini-bar anywhere; audio nowhere

View File

@@ -0,0 +1,39 @@
# Replace In-App Player with Navidrome Deep Link — Design Spec
**Date:** 2026-09-04
**Status:** Approved design, pending implementation
**Parent:** docs/superpowers/specs/2026-09-03-wave1-hygiene-listening-design.md (supersedes its "Mini player" section)
## Purpose
The built-in album player (global audio element + mini-bar) is removed. Listening happens in the user's Navidrome web UI; the item page links directly to the matched album there. Rationale: Navidrome's player is better than ours, and the in-app player added a global-audio architecture for a preview-quality experience.
## Removals
- `web/src/player/` (PlayerContext, MiniBar) and their tests; PlayerProvider/MiniBar wiring in `web/src/App.tsx`
- Backend stream/tracks/played routes (`server/src/routes/streamRoutes.ts`) and their tests
- api methods `getAlbumTracks`, `markPlayed`, `streamUrl`; types `Track`, `AlbumTracks`; ItemPage's `usePlayer`/`load` wiring and media mocks
- Media-prototype mocks in `web/test/item.test.tsx` (no audio element remains)
## Changes
- **`GET /api/collection/:id` detail** — `matchedAlbum` gains `webUrl: string`: the Navidrome album URL built server-side as `<subsonic_url>/app/#/album/<subsonicId>` (trailing slashes stripped from the configured base; null-safe — `matchedAlbum` stays null when unmatched, and when no Subsonic config exists `webUrl` is still constructed from the stored URL only if the album row exists; unmatched → null either way).
- **Item page** — the ▶ Play album button becomes an anchor **"Listen in Navidrome ↗"** (`target="_blank"` `rel="noreferrer"`), shown whenever `matchedAlbum != null` (regardless of rip status — the album exists in Navidrome if it matched).
- **"Last played X ago"** stays. Stamping now comes solely from library sync reading the server's recently-played list (`getAlbumList2?type=recent`, opportunistic `played`/`playedAt` field). No in-app stamping.
## Unchanged
Stats, loans, rip queue, export/backup, sync, matching, all plan-1/2 APIs except the removed routes.
## Error handling
No new failure modes. The link is a static anchor; a wrong Subsonic URL produces a broken link the same way a wrong URL breaks sync — surfaced by Settings, not the item page.
## Testing
- Backend: detail test asserts `matchedAlbum.webUrl` (`http://n.local/app/#/album/<subsonicId>`); stream tests deleted with the routes
- Web: item tests assert the link (href + target) replaces the player tests; api.test drops the removed methods; player/minibar test files deleted
## Manual device checklist
- Link opens Navidrome's album page from the phone (PWA → browser tab)

View File

@@ -14,7 +14,6 @@ import { registerSettingsRoutes } from './routes/settingsRoutes.js'
import { registerLookupRoutes } from './routes/lookupRoutes.js'
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
import { registerLibraryRoutes } from './routes/libraryRoutes.js'
import { registerStreamRoutes } from './routes/streamRoutes.js'
import { registerLoanRoutes } from './routes/loanRoutes.js'
import { registerStatsRoutes } from './routes/statsRoutes.js'
import { registerDataRoutes } from './routes/dataRoutes.js'
@@ -56,7 +55,6 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
await registerLookupRoutes(app)
await registerCollectionRoutes(app)
await registerLibraryRoutes(app)
await registerStreamRoutes(app)
await registerLoanRoutes(app)
await registerStatsRoutes(app)
await registerDataRoutes(app)

View File

@@ -4,6 +4,7 @@ import { cacheArtwork } from '../artwork.js'
import { resolveRipStatus, resolveRipStatusBatch, findMatchedAlbum, type RipStatus } from '../ripstatus.js'
import { requireAuth } from './authRoutes.js'
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
import { getSettings } from './settingsRoutes.js'
interface ItemRow {
id: number
@@ -182,9 +183,15 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
const loan = db
.prepare('SELECT id, borrower, lent_at FROM loans WHERE item_id = ? AND returned_at IS NULL')
.get(row.id) as { id: number; borrower: string; lent_at: string } | undefined
const matched = findMatchedAlbum(db, request.user!.id, row.id)
const settings = getSettings(db, request.user!.id)
const webUrl =
matched && settings.subsonic_url
? `${settings.subsonic_url.replace(/\/+$/, '')}/app/#/album/${matched.subsonicId}`
: null
return {
...rowToItem(db, row),
matchedAlbum: findMatchedAlbum(db, request.user!.id, row.id),
matchedAlbum: matched ? { ...matched, webUrl } : null,
loan: loan ? { id: loan.id, borrower: loan.borrower, lentAt: loan.lent_at } : null,
}
})

View File

@@ -1,65 +0,0 @@
import { FastifyInstance } from 'fastify'
import { SubsonicClient, SubsonicError } from '../subsonic.js'
import { requireAuth } from './authRoutes.js'
import { getSettings, subsonicConfigComplete } from './settingsRoutes.js'
function clientFor(request: any): SubsonicClient | null {
const s = getSettings(request.server.db, request.user.id)
if (!subsonicConfigComplete(s)) return null
return new SubsonicClient({
url: s.subsonic_url as string,
username: s.subsonic_username as string,
password: s.subsonic_password as string,
fetchImpl: request.server.fetchImpl,
})
}
export async function registerStreamRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/stream/:songId', { preHandler: [requireAuth] }, async (request, reply) => {
const client = clientFor(request)
if (!client) return reply.code(409).send({ error: 'no_subsonic_config' })
const songId = (request.params as { songId: string }).songId
const range = request.headers.range
let upstream: Response
try {
upstream = await request.server.fetchImpl(client.url('stream', { id: songId }), {
headers: range ? { Range: range } : {},
})
} catch {
return reply.code(502).send({ error: 'stream_unavailable' })
}
if (!upstream.ok && upstream.status !== 206) {
return reply.code(502).send({ error: 'stream_unavailable' })
}
const headers: Record<string, string> = {}
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) {
const v = upstream.headers.get(h)
if (v) headers[h] = v
}
if (!upstream.headers.get('accept-ranges')) headers['accept-ranges'] = 'bytes'
return reply.code(upstream.status).headers(headers).send(upstream.body)
})
app.get('/api/album/:subsonicId/tracks', { preHandler: [requireAuth] }, async (request, reply) => {
const client = clientFor(request)
if (!client) return reply.code(409).send({ error: 'no_subsonic_config' })
try {
const album = await client.getAlbum((request.params as { subsonicId: string }).subsonicId)
return album
} catch (err) {
if (err instanceof SubsonicError) return reply.code(502).send({ error: 'subsonic_error', detail: err.message })
return reply.code(502).send({ error: 'subsonic_unreachable' })
}
})
app.post('/api/album/:subsonicId/played', { preHandler: [requireAuth] }, async (request, reply) => {
const subsonicId = (request.params as { subsonicId: string }).subsonicId
const info = request.server.db
.prepare(
'UPDATE digital_albums SET last_played_at = ? WHERE user_id = ? AND subsonic_id = ?'
)
.run(new Date().toISOString(), request.user!.id, subsonicId)
if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
return { ok: true }
})
}

View File

@@ -5,6 +5,12 @@ import { discogsReleaseFixture } from './fixtures.js'
function discogsStub(): typeof fetch {
return (async (input: any) => {
const url = String(input)
if (url.includes('/rest/ping')) {
return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.includes('/releases/1001')) {
return new Response(JSON.stringify(discogsReleaseFixture), {
status: 200,
@@ -220,6 +226,12 @@ describe('collection routes', () => {
const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
expect(detail.json().matchedAlbum).toBeNull()
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { subsonicUrl: 'http://navidrome.local', subsonicUsername: 'sam', subsonicPassword: 'pass' },
})
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
@@ -227,7 +239,10 @@ describe('collection routes', () => {
payload: { albums: [{ subsonicId: 'alb-9', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
})
const again = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
expect(again.json().matchedAlbum).toMatchObject({ subsonicId: 'alb-9', lastPlayedAt: null })
expect(again.json().matchedAlbum).toMatchObject({
subsonicId: 'alb-9',
webUrl: 'http://navidrome.local/app/#/album/alb-9',
})
await app.close()
})

View File

@@ -51,34 +51,4 @@ describe('last_played_at stamping', () => {
expect(row.last_played_at).toBe('2026-09-01T10:00:00Z')
await app.close()
})
it('played endpoint stamps now and returns 404 for unknown albums', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'TCO' }] },
})
const before = (
app.db.prepare("SELECT last_played_at FROM digital_albums WHERE subsonic_id = 'a1'").get() as {
last_played_at: string | null
}
).last_played_at
expect(before).toBeNull()
const res = await app.inject({ method: 'POST', url: '/api/album/a1/played', ...auth(cookie) })
expect(res.statusCode).toBe(200)
const after = (
app.db.prepare("SELECT last_played_at FROM digital_albums WHERE subsonic_id = 'a1'").get() as {
last_played_at: string | null
}
).last_played_at
expect(after).toBeTruthy()
const missing = await app.inject({ method: 'POST', url: '/api/album/zzz/played', ...auth(cookie) })
expect(missing.statusCode).toBe(404)
await app.close()
})
})

View File

@@ -1,203 +0,0 @@
import { describe, it, expect } from 'vitest'
import { openDatabase } from '../src/db.js'
import { buildTestAppWithDb, setupAdmin, auth } from './helpers.js'
const PASSWORD = 's3cret-pass-xyz'
const AUDIO = new TextEncoder().encode('fake-audio-payload')
interface UpstreamRequest {
url: URL
range: string | null
}
interface StubOptions {
album?: () => Response
stream?: (req: UpstreamRequest) => Response
onStream?: (req: UpstreamRequest) => void
}
/** Subsonic stub: ping/getAlbumList2 always ok (settings PUT + sync); album/stream configurable. */
function subsonicStub(opts: StubOptions): typeof fetch {
return (async (input: any, init?: any) => {
const url = new URL(String(input))
if (url.pathname.endsWith('/rest/ping')) {
return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.pathname.endsWith('/rest/getAlbumList2')) {
return new Response(
JSON.stringify({ 'subsonic-response': { status: 'ok', albumList2: { album: [] } } }),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
}
if (url.pathname.endsWith('/rest/getAlbum')) {
return opts.album ? opts.album() : new Response('nope', { status: 404 })
}
if (url.pathname.endsWith('/rest/stream')) {
const req: UpstreamRequest = { url, range: init?.headers?.Range ?? null }
opts.onStream?.(req)
return opts.stream ? opts.stream(req) : new Response('nope', { status: 404 })
}
return new Response('nope', { status: 404 })
}) as typeof fetch
}
async function appWithSubsonic(fetchImpl: typeof fetch) {
const app = await buildTestAppWithDb(openDatabase(':memory:'), fetchImpl)
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { subsonicUrl: 'http://n.local', subsonicUsername: 'sam', subsonicPassword: PASSWORD },
})
return { app, cookie }
}
// Note: the played endpoint is covered in lastplayed.test.ts — this file
// focuses on the stream/album proxy behaviour.
describe('stream/album proxy routes', () => {
it('forwards the Range header to the stream upstream', async () => {
const seen: UpstreamRequest[] = []
const { app, cookie } = await appWithSubsonic(
subsonicStub({
onStream: (req) => {
seen.push(req)
},
stream: () =>
new Response(AUDIO, { status: 200, headers: { 'content-type': 'audio/mpeg' } }),
})
)
const res = await app.inject({
method: 'GET',
url: '/api/stream/song-1',
...auth(cookie),
headers: { range: 'bytes=100-' },
})
expect(res.statusCode).toBe(200)
expect(seen[0]?.range).toBe('bytes=100-')
expect(res.body).toBe('fake-audio-payload')
await app.close()
})
it('passes through an upstream 206 partial response with content-range', async () => {
const { app, cookie } = await appWithSubsonic(
subsonicStub({
stream: () =>
new Response(AUDIO, {
status: 206,
headers: { 'content-type': 'audio/mpeg', 'content-range': 'bytes 100-116/500' },
}),
})
)
const res = await app.inject({
method: 'GET',
url: '/api/stream/song-1',
...auth(cookie),
headers: { range: 'bytes=100-' },
})
expect(res.statusCode).toBe(206)
expect(res.headers['content-type']).toBe('audio/mpeg')
expect(res.headers['content-range']).toBe('bytes 100-116/500')
expect(res.body).toBe('fake-audio-payload')
await app.close()
})
it('returns 502 stream_unavailable when the upstream fetch throws', async () => {
const { app, cookie } = await appWithSubsonic(
subsonicStub({
stream: () => {
throw new Error('upstream down')
},
})
)
const res = await app.inject({ method: 'GET', url: '/api/stream/song-1', ...auth(cookie) })
expect(res.statusCode).toBe(502)
expect(res.json()).toEqual({ error: 'stream_unavailable' })
await app.close()
})
it('leaks no subsonic credentials in body or headers (token auth upstream)', async () => {
const upstreamUrls: URL[] = []
const { app, cookie } = await appWithSubsonic(
subsonicStub({
onStream: (req) => {
upstreamUrls.push(req.url)
},
stream: () =>
new Response(AUDIO, { status: 200, headers: { 'content-type': 'audio/mpeg' } }),
})
)
const res = await app.inject({ method: 'GET', url: '/api/stream/song-1', ...auth(cookie) })
expect(res.statusCode).toBe(200)
expect(res.body).not.toContain(PASSWORD)
expect(JSON.stringify(res.headers)).not.toContain(PASSWORD)
// upstream request used token auth — raw password never sent
const upstreamUrl = upstreamUrls[0]
expect(upstreamUrl?.searchParams.get('u')).toBe('sam')
expect(upstreamUrl?.searchParams.get('t')).toMatch(/^[0-9a-f]{32}$/)
expect(upstreamUrl?.searchParams.has('p')).toBe(false)
expect(upstreamUrl?.toString()).not.toContain(PASSWORD)
await app.close()
})
it('album tracks endpoint returns the getAlbum payload with ordered tracks', async () => {
const { app, cookie } = await appWithSubsonic(
subsonicStub({
album: () =>
new Response(
JSON.stringify({
'subsonic-response': {
status: 'ok',
album: {
id: 'alb-1',
name: 'Motion',
artist: 'The Cinematic Orchestra',
song: [
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
],
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
),
})
)
const res = await app.inject({ method: 'GET', url: '/api/album/alb-1/tracks', ...auth(cookie) })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({
id: 'alb-1',
title: 'Motion',
artist: 'The Cinematic Orchestra',
tracks: [
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
],
})
await app.close()
})
it('returns 502 subsonic_error when subsonic rejects the credentials', async () => {
const { app, cookie } = await appWithSubsonic(
subsonicStub({
album: () =>
new Response(
JSON.stringify({
'subsonic-response': {
status: 'failed',
error: { code: 40, message: 'Wrong username or password' },
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
),
})
)
const res = await app.inject({ method: 'GET', url: '/api/album/alb-1/tracks', ...auth(cookie) })
expect(res.statusCode).toBe(502)
expect(res.json().error).toBe('subsonic_error')
await app.close()
})
})

View File

@@ -2,8 +2,6 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import type { ReactNode } from 'react'
import { AuthProvider, useAuth } from './auth'
import Shell from './shell'
import { PlayerProvider } from './player/PlayerContext.js'
import MiniBar from './player/MiniBar.js'
import SetupPage from './pages/SetupPage'
import LoginPage from './pages/LoginPage'
import LibraryPage from './pages/LibraryPage'
@@ -34,10 +32,7 @@ export default function App() {
<Route
element={
<Gate>
<PlayerProvider>
<Shell />
<MiniBar />
</PlayerProvider>
</Gate>
}
>

View File

@@ -1,5 +1,4 @@
import type {
AlbumTracks,
BackupFile,
Candidate,
CollectionResponse,
@@ -48,8 +47,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
function post<T>(path: string, payload?: unknown): Promise<T> {
return request<T>(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload === undefined ? undefined : JSON.stringify(payload),
...(payload !== undefined
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
: {}),
})
}
@@ -105,10 +105,6 @@ export const api = {
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
getAlbumTracks: (subsonicId: string) => request<AlbumTracks>(`/api/album/${encodeURIComponent(subsonicId)}/tracks`),
markPlayed: (subsonicId: string) => post<{ ok: boolean }>(`/api/album/${encodeURIComponent(subsonicId)}/played`),
streamUrl: (songId: string) => `/api/stream/${encodeURIComponent(songId)}`,
getStats: () => request<Stats>('/api/stats'),
exportUrl: () => '/api/export',

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, ApiError } from '../api.js'
import { usePlayer } from '../player/PlayerContext.js'
import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
import Cover from '../components/Cover.js'
@@ -22,7 +21,6 @@ function timeAgo(iso: string): string {
export default function ItemPage() {
const { id } = useParams()
const navigate = useNavigate()
const { load } = usePlayer()
const [item, setItem] = useState<ItemDetail | null>(null)
const [error, setError] = useState(false)
const [matching, setMatching] = useState(false)
@@ -110,14 +108,15 @@ export default function ItemPage() {
</p>
)}
{item.ripStatus === 'ripped' && item.matchedAlbum && (
<button
type="button"
onClick={() => void load({ id: item.matchedAlbum!.subsonicId, title: item.title, artist: item.artist })}
className="w-full rounded-xl bg-emerald-500 py-2.5 font-medium text-neutral-950"
{item.matchedAlbum?.webUrl && (
<a
href={item.matchedAlbum.webUrl}
target="_blank"
rel="noreferrer"
className="block w-full rounded-xl bg-emerald-500 py-2.5 text-center font-medium text-neutral-950"
>
Play album
</button>
Listen in Navidrome
</a>
)}
{item.matchedAlbum?.lastPlayedAt && (
<p className="text-xs text-neutral-500">Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}</p>

View File

@@ -1,92 +0,0 @@
import { useState } from 'react'
import { usePlayer } from './PlayerContext.js'
import Cover from '../components/Cover.js'
export default function MiniBar() {
const { state, toggle, next, prev, close } = usePlayer()
const [expanded, setExpanded] = useState(false)
if (!state.album) return null
const current = state.tracks[state.index]
if (expanded) {
return (
<div className="fixed inset-x-0 bottom-16 z-20 mx-auto max-w-3xl rounded-t-2xl border border-neutral-700 bg-neutral-900 p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="truncate font-medium">{state.album.title}</p>
<p className="truncate text-sm text-neutral-400">{state.album.artist}</p>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={prev} aria-label="previous track" className="px-1 text-neutral-300">
</button>
<button
type="button"
onClick={toggle}
aria-label={state.playing ? 'pause' : 'play'}
className="rounded-full bg-emerald-500 px-3 py-1.5 text-neutral-950"
>
{state.playing ? '⏸' : '▶'}
</button>
<button type="button" onClick={next} aria-label="next track" className="px-1 text-neutral-300">
</button>
<button type="button" onClick={() => setExpanded(false)} aria-label="collapse player" className="text-neutral-400">
</button>
<button type="button" onClick={close} aria-label="close player" className="text-neutral-400">
</button>
</div>
</div>
<ol className="mt-3 max-h-64 space-y-1 overflow-y-auto text-sm">
{state.tracks.map((t, i) => (
<li
key={t.id}
className={`flex justify-between rounded-lg px-2 py-1 ${
i === state.index ? 'bg-neutral-800 text-emerald-400' : 'text-neutral-300'
} ${state.failed.includes(i) ? 'line-through opacity-50' : ''}`}
>
<span className="truncate">
{t.track ?? i + 1}. {t.title}
</span>
{t.duration != null && <span className="ml-2 shrink-0 text-neutral-500">{Math.floor(t.duration / 60)}:{String(t.duration % 60).padStart(2, '0')}</span>}
</li>
))}
</ol>
</div>
)
}
return (
<div className="fixed inset-x-0 bottom-16 z-20 mx-auto max-w-3xl px-4">
<div className="flex items-center gap-3 rounded-2xl border border-neutral-700 bg-neutral-900/95 p-2 shadow-lg backdrop-blur">
<button type="button" onClick={() => setExpanded(true)} aria-label="expand player" className="shrink-0">
<Cover src={null} alt="" className="size-10" />
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{current?.title}</p>
<p className="truncate text-xs text-neutral-400">{state.album.title}</p>
</div>
<button type="button" onClick={prev} aria-label="previous track" className="px-1 text-neutral-300">
</button>
<button
type="button"
onClick={toggle}
aria-label={state.playing ? 'pause' : 'play'}
className="rounded-full bg-emerald-500 px-3 py-1.5 text-neutral-950"
>
{state.playing ? '⏸' : '▶'}
</button>
<button type="button" onClick={next} aria-label="next track" className="px-1 text-neutral-300">
</button>
<button type="button" onClick={close} aria-label="close player" className="px-1 text-neutral-500">
</button>
</div>
</div>
)
}

View File

@@ -1,107 +0,0 @@
import { createContext, useContext, useEffect, useReducer, useCallback, useRef, type ReactNode } from 'react'
import { api } from '../api.js'
import type { Track } from '../types.js'
export interface PlayerAlbum {
id: string
title: string
artist: string
}
export interface PlayerState {
album: PlayerAlbum | null
tracks: Track[]
index: number
playing: boolean
failed: number[]
}
type PlayerAction =
| { type: 'LOAD'; album: PlayerAlbum; tracks: Track[] }
| { type: 'TOGGLE' }
| { type: 'NEXT' }
| { type: 'PREV' }
| { type: 'TRACK_ERROR' }
| { type: 'CLOSE' }
const INITIAL: PlayerState = { album: null, tracks: [], index: 0, playing: false, failed: [] }
export function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
switch (action.type) {
case 'LOAD':
return { album: action.album, tracks: action.tracks, index: 0, playing: action.tracks.length > 0, failed: [] }
case 'TOGGLE':
return { ...state, playing: !state.playing }
case 'NEXT':
return state.index < state.tracks.length - 1 ? { ...state, index: state.index + 1, playing: true } : { ...state, playing: false }
case 'PREV':
return state.index > 0 ? { ...state, index: state.index - 1, playing: true } : state
case 'TRACK_ERROR':
return {
...state,
failed: state.failed.includes(state.index) ? state.failed : [...state.failed, state.index],
...(state.index < state.tracks.length - 1 ? { index: state.index + 1, playing: true } : { playing: false }),
}
case 'CLOSE':
return INITIAL
}
}
interface PlayerContextValue {
state: PlayerState
load: (album: PlayerAlbum) => Promise<void>
toggle: () => void
next: () => void
prev: () => void
close: () => void
}
const PlayerContext = createContext<PlayerContextValue | null>(null)
export function PlayerProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(playerReducer, INITIAL)
const load = useCallback(async (album: PlayerAlbum) => {
const data = await api.getAlbumTracks(album.id)
dispatch({ type: 'LOAD', album: { id: data.id, title: data.title, artist: data.artist }, tracks: data.tracks })
void api.markPlayed(album.id).catch(() => {})
}, [])
const audioRef = useRef<HTMLAudioElement | null>(null)
const current = state.tracks[state.index]
// keep the single audio element in sync with the reducer
useEffect(() => {
const audio = audioRef.current
if (!audio) return
if (!current) {
audio.pause()
audio.removeAttribute('src')
return
}
const wanted = api.streamUrl(current.id)
if (!audio.src.endsWith(wanted)) audio.src = wanted
if (state.playing) void audio.play().catch(() => {})
else audio.pause()
}, [current, state.playing])
const onEnded = useCallback(() => dispatch({ type: 'NEXT' }), [])
const onError = useCallback(() => dispatch({ type: 'TRACK_ERROR' }), [])
const toggle = useCallback(() => dispatch({ type: 'TOGGLE' }), [])
const next = useCallback(() => dispatch({ type: 'NEXT' }), [])
const prev = useCallback(() => dispatch({ type: 'PREV' }), [])
const close = useCallback(() => dispatch({ type: 'CLOSE' }), [])
return (
<PlayerContext.Provider value={{ state, load, toggle, next, prev, close }}>
{children}
<audio ref={audioRef} onEnded={onEnded} onError={onError} preload="none" />
</PlayerContext.Provider>
)
}
export function usePlayer(): PlayerContextValue {
const ctx = useContext(PlayerContext)
if (!ctx) throw new Error('usePlayer outside PlayerProvider')
return ctx
}

View File

@@ -68,22 +68,11 @@ export interface DigitalAlbum {
title: string
artist: string
}
export interface Track {
id: string
title: string
duration: number | null
track: number | null
}
export interface AlbumTracks {
id: string
title: string
artist: string
tracks: Track[]
}
export interface MatchedAlbum {
id: number
subsonicId: string
lastPlayedAt: string | null
webUrl: string | null
}
export interface Stats {
totals: { items: number; ripped: number; notRipped: number; onLoan: number }

View File

@@ -10,15 +10,6 @@ function jsonOk(body: unknown) {
}
describe('api additions', () => {
it('album tracks + played + stream url', async () => {
fetchMock.mockImplementation(() => jsonOk({ id: 'a1', title: 'Motion', artist: 'TCO', tracks: [] }))
await api.getAlbumTracks('a1')
expect(fetchMock).toHaveBeenCalledWith('/api/album/a1/tracks', expect.anything())
await api.markPlayed('a1')
expect(fetchMock).toHaveBeenCalledWith('/api/album/a1/played', expect.objectContaining({ method: 'POST' }))
expect(api.streamUrl('s 1')).toBe('/api/stream/s%201')
})
it('stats, loans, backups urls', async () => {
fetchMock.mockImplementation(() => jsonOk({}))
await api.getStats()
@@ -36,8 +27,22 @@ describe('api additions', () => {
fetchMock.mockReturnValue(
Promise.resolve(new Response(JSON.stringify({ error: 'no_subsonic_config' }), { status: 409 }))
)
const err = await api.getAlbumTracks('a1').catch((e) => e)
const err = await api.startSync().catch((e) => e)
expect(err).toBeInstanceOf(ApiError)
expect((err as ApiError).code).toBe('no_subsonic_config')
})
it('bodyless posts do not send a json content-type (Fastify 400s on empty json bodies)', async () => {
fetchMock.mockClear()
fetchMock.mockImplementation(() => jsonOk({ ok: true }))
await api.returnLoan(7)
await api.logout()
await api.triggerBackup()
for (const call of fetchMock.mock.calls) {
const init = call[1] as RequestInit | undefined
const headers = (init?.headers ?? {}) as Record<string, string>
expect(headers['Content-Type']).toBeUndefined()
expect(init?.body).toBeUndefined()
}
})
})

View File

@@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import ItemPage from '../src/pages/ItemPage.js'
import { PlayerProvider } from '../src/player/PlayerContext.js'
import type { ItemDetail, MatchedAlbum } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
@@ -17,8 +16,6 @@ vi.mock('../src/api.js', async (importOriginal) => {
setMatch: vi.fn(),
deleteItem: vi.fn(),
searchAlbums: vi.fn(),
getAlbumTracks: vi.fn(),
markPlayed: vi.fn(),
lendItem: vi.fn(),
returnLoan: vi.fn(),
},
@@ -48,20 +45,21 @@ const item: ItemDetail = {
loan: null,
}
const matched: MatchedAlbum = { id: 77, subsonicId: 'alb-1', lastPlayedAt: '2026-09-01T10:00:00Z' }
const matched: MatchedAlbum = {
id: 77,
subsonicId: 'alb-1',
lastPlayedAt: '2026-09-01T10:00:00Z',
webUrl: 'http://navidrome.local/app/#/album/alb-1',
}
const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched }
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
vi.mocked(api.getItem).mockReset()
vi.mocked(api.getItem).mockResolvedValue(item as never)
vi.mocked(api.setRip).mockReset()
vi.mocked(api.setMatch).mockReset()
vi.mocked(api.deleteItem).mockReset()
vi.mocked(api.searchAlbums).mockReset()
vi.mocked(api.getAlbumTracks).mockReset()
vi.mocked(api.markPlayed).mockReset()
vi.mocked(api.lendItem).mockReset()
vi.mocked(api.returnLoan).mockReset()
})
@@ -69,12 +67,10 @@ beforeEach(() => {
function renderItem() {
return render(
<MemoryRouter initialEntries={['/item/1']}>
<PlayerProvider>
<Routes>
<Route path="/item/:id" element={<ItemPage />} />
<Route path="/library" element={<p>library</p>} />
</Routes>
</PlayerProvider>
</MemoryRouter>
)
}
@@ -119,7 +115,7 @@ describe('ItemPage', () => {
await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77))
})
it('re-match refreshes the play button state', async () => {
it('re-match refreshes the link state', async () => {
vi.mocked(api.getItem)
.mockResolvedValueOnce({ ...rippedItem, matchedAlbum: null } as never)
.mockResolvedValue({ ...rippedItem } as never)
@@ -129,7 +125,7 @@ describe('ItemPage', () => {
vi.mocked(api.setMatch).mockResolvedValue({ ...item } as never)
renderItem()
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
expect(screen.queryByRole('button', { name: /play album/i })).toBeNull()
expect(screen.queryByRole('link', { name: /listen in navidrome/i })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: /re-match/i }))
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
@@ -137,7 +133,7 @@ describe('ItemPage', () => {
await userEvent.click(albumRadio)
await userEvent.click(screen.getByRole('button', { name: /^link$/i }))
await waitFor(() => expect(screen.getByRole('button', { name: /play album/i })).toBeTruthy())
await waitFor(() => expect(screen.getByRole('link', { name: /listen in navidrome/i })).toBeTruthy())
})
it('unlink clears the match', async () => {
@@ -189,21 +185,30 @@ describe('ItemPage', () => {
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
})
it('shows Play for a ripped item with a matched album and loads the player', async () => {
it('shows Listen in Navidrome link for a ripped item with a matched album', async () => {
vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'alb-1', title: 'Motion', artist: 'TCO', tracks: [] } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderItem()
const play = await screen.findByRole('button', { name: /play album/i })
await userEvent.click(play)
await waitFor(() => expect(api.getAlbumTracks).toHaveBeenCalledWith('alb-1'))
await waitFor(() => expect(api.markPlayed).toHaveBeenCalledWith('alb-1'))
const link = await screen.findByRole('link', { name: /listen in navidrome/i })
expect(link.getAttribute('href')).toBe('http://navidrome.local/app/#/album/alb-1')
expect(link.getAttribute('target')).toBe('_blank')
})
it('hides Play when unmatched or not ripped', async () => {
it('hides the link when matchedAlbum is null even if ripped', async () => {
vi.mocked(api.getItem).mockResolvedValue({ ...rippedItem, matchedAlbum: null } as never)
renderItem()
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
expect(screen.queryByRole('button', { name: /play album/i })).toBeNull()
expect(screen.queryByRole('link', { name: /listen in navidrome/i })).toBeNull()
})
it('link hidden when the subsonic url was cleared (webUrl null)', async () => {
vi.mocked(api.getItem).mockResolvedValue({
...item,
ripStatus: 'ripped',
matchedAlbum: { id: 77, subsonicId: 'alb-1', lastPlayedAt: null, webUrl: null },
} as never)
renderItem()
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
expect(screen.queryByRole('link', { name: /listen in navidrome/i })).toBeNull()
})
it('shows last played under the rip banner', async () => {

View File

@@ -1,81 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PlayerProvider, usePlayer } from '../src/player/PlayerContext.js'
import MiniBar from '../src/player/MiniBar.js'
import type { Track } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return { ...actual, api: { ...actual.api, getAlbumTracks: vi.fn(), markPlayed: vi.fn() } }
})
import { api } from '../src/api.js'
const tracks: Track[] = [
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
]
function Loader() {
const { load } = usePlayer()
return (
<button type="button" onClick={() => void load({ id: 'a1', title: 'Motion', artist: 'TCO' })}>
load
</button>
)
}
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
})
function renderBar() {
return render(
<PlayerProvider>
<Loader />
<MiniBar />
</PlayerProvider>
)
}
describe('MiniBar', () => {
it('hidden when nothing is loaded, shows controls when playing', async () => {
renderBar()
expect(screen.queryByRole('button', { name: /play or pause/i })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('Motion')).toBeTruthy())
expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy()
})
it('pause/resume works from the bar', async () => {
renderBar()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
const pauseBtn = await screen.findByRole('button', { name: /pause/i })
await userEvent.click(pauseBtn)
expect(screen.getByRole('button', { name: 'play' })).toBeTruthy()
})
it('expands to the track list and closes', async () => {
renderBar()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await userEvent.click(await screen.findByRole('button', { name: /expand/i }))
expect(screen.getByText(/Theme de Yoyo/)).toBeTruthy()
expect(screen.getByText(/Overture/)).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: /collapse/i }))
expect(screen.queryByText(/Theme de Yoyo/)).toBeNull()
await userEvent.click(screen.getByRole('button', { name: /close player/i }))
await waitFor(() => expect(screen.queryByText('Motion')).toBeNull())
})
it('expanded view has prev/next/pause controls', async () => {
renderBar()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await userEvent.click(await screen.findByRole('button', { name: /expand/i }))
expect(screen.getByRole('button', { name: /previous track/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /next track/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy()
})
})

View File

@@ -1,135 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PlayerProvider, usePlayer, playerReducer, type PlayerState } from '../src/player/PlayerContext.js'
import type { Track } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return { ...actual, api: { ...actual.api, getAlbumTracks: vi.fn(), markPlayed: vi.fn() } }
})
import { api } from '../src/api.js'
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
})
const tracks: Track[] = [
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
]
function Probe() {
const { state, load, toggle, next, prev, close } = usePlayer()
return (
<div>
<div>phase:{state.album ? (state.playing ? 'playing' : 'paused') : 'empty'}</div>
<div>track:{state.tracks[state.index]?.title ?? 'none'}</div>
<button type="button" onClick={() => load({ id: 'a1', title: 'Motion', artist: 'TCO' })}>load</button>
<button type="button" onClick={() => toggle()}>toggle</button>
<button type="button" onClick={() => next()}>next</button>
<button type="button" onClick={() => prev()}>prev</button>
<button type="button" onClick={() => close()}>close</button>
</div>
)
}
function renderPlayer() {
return render(
<PlayerProvider>
<Probe />
</PlayerProvider>
)
}
describe('PlayerProvider', () => {
it('loads a queue, stamps played, starts at track 1', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
expect(screen.getByText('track:Overture')).toBeTruthy()
expect(api.getAlbumTracks).toHaveBeenCalledWith('a1')
expect(api.markPlayed).toHaveBeenCalledWith('a1')
})
it('toggle pauses and resumes', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: 'toggle' }))
expect(screen.getByText('phase:paused')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: 'toggle' }))
expect(screen.getByText('phase:playing')).toBeTruthy()
})
it('next/prev move through the queue and stop at the edges', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('track:Overture')).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: 'next' }))
expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: 'next' }))
expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy() // last track: no advance
await userEvent.click(screen.getByRole('button', { name: 'prev' }))
expect(screen.getByText('track:Overture')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: 'prev' }))
expect(screen.getByText('track:Overture')).toBeTruthy() // first track: no rewind
})
it('close empties the player', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: 'close' }))
expect(screen.getByText('phase:empty')).toBeTruthy()
})
it('audio error marks the track failed and skips to the next', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('track:Overture')).toBeTruthy())
act(() => {
document.querySelector('audio')!.dispatchEvent(new Event('error'))
})
await waitFor(() => expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy())
})
})
const twoTracks: Track[] = [
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
]
describe('playerReducer edges', () => {
const loaded: PlayerState = playerReducer(
{ album: null, tracks: [], index: 0, playing: false, failed: [] },
{ type: 'LOAD', album: { id: 'a1', title: 'Motion', artist: 'TCO' }, tracks: twoTracks }
)
it('LOAD with empty tracks does not play', () => {
const s = playerReducer(loaded, { type: 'LOAD', album: loaded.album!, tracks: [] })
expect(s.playing).toBe(false)
})
it('TRACK_ERROR at last track pauses without advancing, records failure once', () => {
const atLast = playerReducer(loaded, { type: 'NEXT' })
const err = playerReducer(atLast, { type: 'TRACK_ERROR' })
expect(err.index).toBe(1)
expect(err.playing).toBe(false)
expect(err.failed).toEqual([1])
const again = playerReducer(err, { type: 'TRACK_ERROR' })
expect(again.failed).toEqual([1])
})
})