Compare commits
6 Commits
3edf7574f9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c8754a5d | |||
| 642ca52c71 | |||
| 165ac96830 | |||
| ff5f4e9133 | |||
| 023557de64 | |||
| 7b7b149928 |
105
docs/superpowers/plans/2026-09-04-navidrome-link.md
Normal file
105
docs/superpowers/plans/2026-09-04-navidrome-link.md
Normal 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
|
||||||
39
docs/superpowers/specs/2026-09-04-navidrome-link-design.md
Normal file
39
docs/superpowers/specs/2026-09-04-navidrome-link-design.md
Normal 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)
|
||||||
@@ -14,7 +14,6 @@ import { registerSettingsRoutes } from './routes/settingsRoutes.js'
|
|||||||
import { registerLookupRoutes } from './routes/lookupRoutes.js'
|
import { registerLookupRoutes } from './routes/lookupRoutes.js'
|
||||||
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
|
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
|
||||||
import { registerLibraryRoutes } from './routes/libraryRoutes.js'
|
import { registerLibraryRoutes } from './routes/libraryRoutes.js'
|
||||||
import { registerStreamRoutes } from './routes/streamRoutes.js'
|
|
||||||
import { registerLoanRoutes } from './routes/loanRoutes.js'
|
import { registerLoanRoutes } from './routes/loanRoutes.js'
|
||||||
import { registerStatsRoutes } from './routes/statsRoutes.js'
|
import { registerStatsRoutes } from './routes/statsRoutes.js'
|
||||||
import { registerDataRoutes } from './routes/dataRoutes.js'
|
import { registerDataRoutes } from './routes/dataRoutes.js'
|
||||||
@@ -56,7 +55,6 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
|||||||
await registerLookupRoutes(app)
|
await registerLookupRoutes(app)
|
||||||
await registerCollectionRoutes(app)
|
await registerCollectionRoutes(app)
|
||||||
await registerLibraryRoutes(app)
|
await registerLibraryRoutes(app)
|
||||||
await registerStreamRoutes(app)
|
|
||||||
await registerLoanRoutes(app)
|
await registerLoanRoutes(app)
|
||||||
await registerStatsRoutes(app)
|
await registerStatsRoutes(app)
|
||||||
await registerDataRoutes(app)
|
await registerDataRoutes(app)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { cacheArtwork } from '../artwork.js'
|
|||||||
import { resolveRipStatus, resolveRipStatusBatch, findMatchedAlbum, type RipStatus } from '../ripstatus.js'
|
import { resolveRipStatus, resolveRipStatusBatch, findMatchedAlbum, type RipStatus } from '../ripstatus.js'
|
||||||
import { requireAuth } from './authRoutes.js'
|
import { requireAuth } from './authRoutes.js'
|
||||||
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
|
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
|
||||||
|
import { getSettings } from './settingsRoutes.js'
|
||||||
|
|
||||||
interface ItemRow {
|
interface ItemRow {
|
||||||
id: number
|
id: number
|
||||||
@@ -182,9 +183,15 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
|
|||||||
const loan = db
|
const loan = db
|
||||||
.prepare('SELECT id, borrower, lent_at FROM loans WHERE item_id = ? AND returned_at IS NULL')
|
.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
|
.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 {
|
return {
|
||||||
...rowToItem(db, row),
|
...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,
|
loan: loan ? { id: loan.id, borrower: loan.borrower, lentAt: loan.lent_at } : null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,12 @@ import { discogsReleaseFixture } from './fixtures.js'
|
|||||||
function discogsStub(): typeof fetch {
|
function discogsStub(): typeof fetch {
|
||||||
return (async (input: any) => {
|
return (async (input: any) => {
|
||||||
const url = String(input)
|
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')) {
|
if (url.includes('/releases/1001')) {
|
||||||
return new Response(JSON.stringify(discogsReleaseFixture), {
|
return new Response(JSON.stringify(discogsReleaseFixture), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -220,6 +226,12 @@ describe('collection routes', () => {
|
|||||||
const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
|
const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
|
||||||
expect(detail.json().matchedAlbum).toBeNull()
|
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({
|
await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/library/albums/test-seed',
|
url: '/api/library/albums/test-seed',
|
||||||
@@ -227,7 +239,10 @@ describe('collection routes', () => {
|
|||||||
payload: { albums: [{ subsonicId: 'alb-9', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
|
payload: { albums: [{ subsonicId: 'alb-9', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
|
||||||
})
|
})
|
||||||
const again = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
|
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()
|
await app.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -51,34 +51,4 @@ describe('last_played_at stamping', () => {
|
|||||||
expect(row.last_played_at).toBe('2026-09-01T10:00:00Z')
|
expect(row.last_played_at).toBe('2026-09-01T10:00:00Z')
|
||||||
await app.close()
|
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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -2,8 +2,6 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { AuthProvider, useAuth } from './auth'
|
import { AuthProvider, useAuth } from './auth'
|
||||||
import Shell from './shell'
|
import Shell from './shell'
|
||||||
import { PlayerProvider } from './player/PlayerContext.js'
|
|
||||||
import MiniBar from './player/MiniBar.js'
|
|
||||||
import SetupPage from './pages/SetupPage'
|
import SetupPage from './pages/SetupPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import LibraryPage from './pages/LibraryPage'
|
import LibraryPage from './pages/LibraryPage'
|
||||||
@@ -34,10 +32,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<Gate>
|
<Gate>
|
||||||
<PlayerProvider>
|
|
||||||
<Shell />
|
<Shell />
|
||||||
<MiniBar />
|
|
||||||
</PlayerProvider>
|
|
||||||
</Gate>
|
</Gate>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
AlbumTracks,
|
|
||||||
BackupFile,
|
BackupFile,
|
||||||
Candidate,
|
Candidate,
|
||||||
CollectionResponse,
|
CollectionResponse,
|
||||||
@@ -48,8 +47,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
function post<T>(path: string, payload?: unknown): Promise<T> {
|
function post<T>(path: string, payload?: unknown): Promise<T> {
|
||||||
return request<T>(path, {
|
return request<T>(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
...(payload !== undefined
|
||||||
body: payload === undefined ? undefined : JSON.stringify(payload),
|
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +105,6 @@ export const api = {
|
|||||||
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
|
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
|
||||||
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
|
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'),
|
getStats: () => request<Stats>('/api/stats'),
|
||||||
exportUrl: () => '/api/export',
|
exportUrl: () => '/api/export',
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api, ApiError } from '../api.js'
|
import { api, ApiError } from '../api.js'
|
||||||
import { usePlayer } from '../player/PlayerContext.js'
|
|
||||||
import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
|
import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
|
||||||
import Cover from '../components/Cover.js'
|
import Cover from '../components/Cover.js'
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ function timeAgo(iso: string): string {
|
|||||||
export default function ItemPage() {
|
export default function ItemPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { load } = usePlayer()
|
|
||||||
const [item, setItem] = useState<ItemDetail | null>(null)
|
const [item, setItem] = useState<ItemDetail | null>(null)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const [matching, setMatching] = useState(false)
|
const [matching, setMatching] = useState(false)
|
||||||
@@ -110,14 +108,15 @@ export default function ItemPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.ripStatus === 'ripped' && item.matchedAlbum && (
|
{item.matchedAlbum?.webUrl && (
|
||||||
<button
|
<a
|
||||||
type="button"
|
href={item.matchedAlbum.webUrl}
|
||||||
onClick={() => void load({ id: item.matchedAlbum!.subsonicId, title: item.title, artist: item.artist })}
|
target="_blank"
|
||||||
className="w-full rounded-xl bg-emerald-500 py-2.5 font-medium text-neutral-950"
|
rel="noreferrer"
|
||||||
|
className="block w-full rounded-xl bg-emerald-500 py-2.5 text-center font-medium text-neutral-950"
|
||||||
>
|
>
|
||||||
▶ Play album
|
Listen in Navidrome ↗
|
||||||
</button>
|
</a>
|
||||||
)}
|
)}
|
||||||
{item.matchedAlbum?.lastPlayedAt && (
|
{item.matchedAlbum?.lastPlayedAt && (
|
||||||
<p className="text-xs text-neutral-500">Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}</p>
|
<p className="text-xs text-neutral-500">Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}</p>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -68,22 +68,11 @@ export interface DigitalAlbum {
|
|||||||
title: string
|
title: string
|
||||||
artist: 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 {
|
export interface MatchedAlbum {
|
||||||
id: number
|
id: number
|
||||||
subsonicId: string
|
subsonicId: string
|
||||||
lastPlayedAt: string | null
|
lastPlayedAt: string | null
|
||||||
|
webUrl: string | null
|
||||||
}
|
}
|
||||||
export interface Stats {
|
export interface Stats {
|
||||||
totals: { items: number; ripped: number; notRipped: number; onLoan: number }
|
totals: { items: number; ripped: number; notRipped: number; onLoan: number }
|
||||||
|
|||||||
@@ -10,15 +10,6 @@ function jsonOk(body: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('api additions', () => {
|
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 () => {
|
it('stats, loans, backups urls', async () => {
|
||||||
fetchMock.mockImplementation(() => jsonOk({}))
|
fetchMock.mockImplementation(() => jsonOk({}))
|
||||||
await api.getStats()
|
await api.getStats()
|
||||||
@@ -36,8 +27,22 @@ describe('api additions', () => {
|
|||||||
fetchMock.mockReturnValue(
|
fetchMock.mockReturnValue(
|
||||||
Promise.resolve(new Response(JSON.stringify({ error: 'no_subsonic_config' }), { status: 409 }))
|
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).toBeInstanceOf(ApiError)
|
||||||
expect((err as ApiError).code).toBe('no_subsonic_config')
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react'
|
|||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
import ItemPage from '../src/pages/ItemPage.js'
|
import ItemPage from '../src/pages/ItemPage.js'
|
||||||
import { PlayerProvider } from '../src/player/PlayerContext.js'
|
|
||||||
import type { ItemDetail, MatchedAlbum } from '../src/types.js'
|
import type { ItemDetail, MatchedAlbum } from '../src/types.js'
|
||||||
|
|
||||||
vi.mock('../src/api.js', async (importOriginal) => {
|
vi.mock('../src/api.js', async (importOriginal) => {
|
||||||
@@ -17,8 +16,6 @@ vi.mock('../src/api.js', async (importOriginal) => {
|
|||||||
setMatch: vi.fn(),
|
setMatch: vi.fn(),
|
||||||
deleteItem: vi.fn(),
|
deleteItem: vi.fn(),
|
||||||
searchAlbums: vi.fn(),
|
searchAlbums: vi.fn(),
|
||||||
getAlbumTracks: vi.fn(),
|
|
||||||
markPlayed: vi.fn(),
|
|
||||||
lendItem: vi.fn(),
|
lendItem: vi.fn(),
|
||||||
returnLoan: vi.fn(),
|
returnLoan: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -48,20 +45,21 @@ const item: ItemDetail = {
|
|||||||
loan: null,
|
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 }
|
const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched }
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
|
||||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
|
|
||||||
vi.mocked(api.getItem).mockReset()
|
vi.mocked(api.getItem).mockReset()
|
||||||
vi.mocked(api.getItem).mockResolvedValue(item as never)
|
vi.mocked(api.getItem).mockResolvedValue(item as never)
|
||||||
vi.mocked(api.setRip).mockReset()
|
vi.mocked(api.setRip).mockReset()
|
||||||
vi.mocked(api.setMatch).mockReset()
|
vi.mocked(api.setMatch).mockReset()
|
||||||
vi.mocked(api.deleteItem).mockReset()
|
vi.mocked(api.deleteItem).mockReset()
|
||||||
vi.mocked(api.searchAlbums).mockReset()
|
vi.mocked(api.searchAlbums).mockReset()
|
||||||
vi.mocked(api.getAlbumTracks).mockReset()
|
|
||||||
vi.mocked(api.markPlayed).mockReset()
|
|
||||||
vi.mocked(api.lendItem).mockReset()
|
vi.mocked(api.lendItem).mockReset()
|
||||||
vi.mocked(api.returnLoan).mockReset()
|
vi.mocked(api.returnLoan).mockReset()
|
||||||
})
|
})
|
||||||
@@ -69,12 +67,10 @@ beforeEach(() => {
|
|||||||
function renderItem() {
|
function renderItem() {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={['/item/1']}>
|
<MemoryRouter initialEntries={['/item/1']}>
|
||||||
<PlayerProvider>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/item/:id" element={<ItemPage />} />
|
<Route path="/item/:id" element={<ItemPage />} />
|
||||||
<Route path="/library" element={<p>library</p>} />
|
<Route path="/library" element={<p>library</p>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</PlayerProvider>
|
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -119,7 +115,7 @@ describe('ItemPage', () => {
|
|||||||
await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77))
|
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)
|
vi.mocked(api.getItem)
|
||||||
.mockResolvedValueOnce({ ...rippedItem, matchedAlbum: null } as never)
|
.mockResolvedValueOnce({ ...rippedItem, matchedAlbum: null } as never)
|
||||||
.mockResolvedValue({ ...rippedItem } as never)
|
.mockResolvedValue({ ...rippedItem } as never)
|
||||||
@@ -129,7 +125,7 @@ describe('ItemPage', () => {
|
|||||||
vi.mocked(api.setMatch).mockResolvedValue({ ...item } as never)
|
vi.mocked(api.setMatch).mockResolvedValue({ ...item } as never)
|
||||||
renderItem()
|
renderItem()
|
||||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
|
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: /re-match/i }))
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
|
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
|
||||||
@@ -137,7 +133,7 @@ describe('ItemPage', () => {
|
|||||||
await userEvent.click(albumRadio)
|
await userEvent.click(albumRadio)
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^link$/i }))
|
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 () => {
|
it('unlink clears the match', async () => {
|
||||||
@@ -189,21 +185,30 @@ describe('ItemPage', () => {
|
|||||||
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
|
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.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()
|
renderItem()
|
||||||
const play = await screen.findByRole('button', { name: /play album/i })
|
const link = await screen.findByRole('link', { name: /listen in navidrome/i })
|
||||||
await userEvent.click(play)
|
expect(link.getAttribute('href')).toBe('http://navidrome.local/app/#/album/alb-1')
|
||||||
await waitFor(() => expect(api.getAlbumTracks).toHaveBeenCalledWith('alb-1'))
|
expect(link.getAttribute('target')).toBe('_blank')
|
||||||
await waitFor(() => expect(api.markPlayed).toHaveBeenCalledWith('alb-1'))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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()
|
renderItem()
|
||||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
|
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 () => {
|
it('shows last played under the rip banner', async () => {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user