From 66dd84acb659ee26f26239ffcd88e1db5a367a08 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Fri, 16 Oct 2026 10:53:18 +0100 Subject: [PATCH 1/4] Console: Listen in player on the book page opens the book in the web player, signed in --- CLAUDE.md | 5 +- admin-ui/src/api/client.ts | 6 ++ admin-ui/src/api/types.ts | 23 +++++ admin-ui/src/features/book/book-hero.tsx | 49 +++++++++- admin-ui/src/features/book/book-model.test.ts | 22 +++++ admin-ui/src/features/book/book-model.ts | 23 +++++ admin-ui/src/features/book/book.test.tsx | 94 ++++++++++++++++++- admin-ui/src/i18n/locales/de.json | 3 + admin-ui/src/i18n/locales/en.json | 3 + admin-ui/src/i18n/locales/es.json | 3 + admin-ui/src/i18n/locales/fr.json | 3 + admin-ui/src/i18n/locales/it.json | 3 + admin-ui/src/i18n/locales/pt.json | 3 + 13 files changed, 236 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 62f1c495..4092faf2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -309,7 +309,10 @@ future metadata site can attach enrichment without reshaping the schema. The redeem response's `uses_remaining`/`code_expires_at` advertise the budget); recovery-derived tokens instead last `auth.recoveryPairingTTL` (multi-scan within it), and - `/auth/pair` + demo tokens stay unlinked/single-use. Invite codes minted via + `/auth/pair` + demo tokens stay unlinked/single-use (the console's book page + "Listen in player" mints one with `/auth/pair` and opens the same-origin + `/web/connect?token=&sid=&uid=&book=&path=` in a new tab, never the payload's + `web_url`, whose base may be another origin). Invite codes minted via the admin API default to 5 uses / 1-day expiry (`defaultAuthCode*` in `handlers_admin.go`); explicit values override. - **Home and away addresses (`addresses` capability)**: a server diff --git a/admin-ui/src/api/client.ts b/admin-ui/src/api/client.ts index ccd83511..023b7b19 100644 --- a/admin-ui/src/api/client.ts +++ b/admin-ui/src/api/client.ts @@ -79,6 +79,7 @@ import type { InviteCreated, Library, LoginResponse, + PairingPayload, MetaMirrorStatus, PathRule, ServerInfo, @@ -327,6 +328,11 @@ export const api = { /** Revokes the stored session, or `token` when given (never touching storage). */ logout: (token?: string) => request('POST', '/auth/logout', undefined, token), me: () => request('GET', '/me'), + /** + * A single-use, ten-minute pairing token for the signed-in admin, which "Listen in + * player" hands the web player (403 for an API key). + */ + pairSelf: () => request('POST', '/auth/pair'), stats: () => request('GET', '/admin/stats'), settings: () => request('GET', '/admin/settings'), /** Queues a scan (coalesced with one already waiting); answers with the job. */ diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts index a837ea52..275d255f 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -121,6 +121,29 @@ export interface LoginResponse { server_id: string; } +/** + * POST /api/v1/auth/pair (handlers_auth.go handlePair; api.PairingPayload, qr.go + * buildPairing): a single-use, ten-minute pairing token for the caller, with the + * links and QR a device pairs from. + */ +export interface PairingPayload { + server_name: string; + server_id: string; + base_url: string; + pairing_token: string; + /** audiosilo://connect?... (the native app). */ + uri: string; + /** /web/connect?token=... (the QR); the base may be the public_url, another origin. */ + web_url: string; + qr_png_data_uri: string; + links: { web: string; admin: string; ios?: string; android?: string }; + /** Only for a token an invite minted: never for /auth/pair. */ + code_expires_at?: string; + uses_remaining?: number; + /** The server's home and away addresses, when it knows either. */ + addresses?: { home?: string; away?: string }; +} + /** One library's book count in GET /admin/stats (handlers_stats.go libStat). */ export interface LibraryStat { id: number; diff --git a/admin-ui/src/features/book/book-hero.tsx b/admin-ui/src/features/book/book-hero.tsx index 3b7dacba..ec322438 100644 --- a/admin-ui/src/features/book/book-hero.tsx +++ b/admin-ui/src/features/book/book-hero.tsx @@ -8,6 +8,7 @@ import { Ellipsis, FileAudio, HardDrive, + Headphones, ImageMinus, ImageUp, RefreshCw, @@ -45,16 +46,17 @@ import { formatSeriesPosition, } from '@/lib/format'; import { joinLibraryPath } from '@/lib/paths'; +import { useCurrentUser } from '@/lib/session'; import { toast } from '@/lib/toast'; import { cn } from '@/lib/utils'; -import { COVER_TYPES, audioLine, coverFileProblem } from './book-model'; +import { COVER_TYPES, audioLine, coverFileProblem, playerConnectUrl } from './book-model'; import { HERO_GRID } from './layout'; import { useHeroTint } from './use-hero-tint'; /** * The book's hero: its cover blurred into a full-bleed backdrop and tinted * from the art, the title and byline, the facts that matter to an admin, and - * the book's actions (match, cover, more). + * the book's actions (match, listen, cover, more). */ export function BookHero({ detail, @@ -146,6 +148,7 @@ export function BookHero({ {b.matched ? t('book.hero.compare') : t('book.hero.match')} ) : null} + {caps?.web_player ? : null} @@ -247,6 +250,48 @@ function NameLinks({ ); } +/** + * "Listen in player": the book in the web player, in a new tab, signed in as this + * admin with no second sign-in (a one-time pairing token the player exchanges). + */ +function ListenButton({ book: b }: { book: AdminBook }) { + const { t } = useTranslation(); + const me = useCurrentUser(); + const listen = async () => { + // Opened in the click itself, or the browser takes it for a pop-up and blocks it; + // pointed at the player once the token is back. + const tab = window.open('', '_blank'); + if (!tab) { + toast.add({ + title: t('book.hero.listenFailed'), + description: t('book.hero.listenBlocked'), + type: 'error', + }); + return; + } + tab.opener = null; + try { + const pairing = await api.pairSelf(); + tab.location.href = playerConnectUrl({ + token: pairing.pairing_token, + serverId: pairing.server_id, + userId: me.id, + libraryId: b.library_id, + path: b.path, + }); + } catch (err) { + tab.close(); + toastError(t('book.hero.listenFailed'), err); + } + }; + return ( + + ); +} + /** Upload a custom cover (kept in AudioSilo's database) or remove it. */ function CoverMenu({ detail }: { detail: AdminBookDetail }) { const { t } = useTranslation(); diff --git a/admin-ui/src/features/book/book-model.test.ts b/admin-ui/src/features/book/book-model.test.ts index 09bdffed..c852b698 100644 --- a/admin-ui/src/features/book/book-model.test.ts +++ b/admin-ui/src/features/book/book-model.test.ts @@ -10,6 +10,7 @@ import { draftErrors, fileName, fileStrip, + playerConnectUrl, ribbonSegments, saveRequest, undoRevertRequest, @@ -187,3 +188,24 @@ describe('the rest of the page', () => { ); }); }); + +describe('playerConnectUrl', () => { + it("opens the book in this origin's web player, as this admin", () => { + const url = playerConnectUrl({ + token: 'tok+/=', + serverId: 'srv-1', + userId: 1, + libraryId: 2, + path: 'Brandon Sanderson/Mistborn & Co/01 #1?', + }); + const parsed = new URL(url, 'http://admin.test'); + expect(parsed.origin + parsed.pathname).toBe('http://admin.test/web/connect'); + expect(Object.fromEntries(parsed.searchParams)).toEqual({ + token: 'tok+/=', + sid: 'srv-1', + uid: '1', + book: '2', + path: 'Brandon Sanderson/Mistborn & Co/01 #1?', + }); + }); +}); diff --git a/admin-ui/src/features/book/book-model.ts b/admin-ui/src/features/book/book-model.ts index 3d0c0137..2f863593 100644 --- a/admin-ui/src/features/book/book-model.ts +++ b/admin-ui/src/features/book/book-model.ts @@ -496,3 +496,26 @@ export function audioLine( .filter(Boolean) .join(' · '); } + +/** + * Where "Listen in player" sends its tab: the web player's connect page on this + * origin (never the pairing's web_url, whose base may be the public_url, another + * origin), which exchanges the token, or skips it when that browser is already + * signed in as `userId` (sid + uid name the connection), and opens the book. + */ +export function playerConnectUrl(p: { + token: string; + serverId: string; + userId: number; + libraryId: number; + path: string; +}): string { + const q = new URLSearchParams({ + token: p.token, + sid: p.serverId, + uid: String(p.userId), + book: String(p.libraryId), + path: p.path, + }); + return `/web/connect?${q.toString()}`; +} diff --git a/admin-ui/src/features/book/book.test.tsx b/admin-ui/src/features/book/book.test.tsx index 24a3d37c..78dec94e 100644 --- a/admin-ui/src/features/book/book.test.tsx +++ b/admin-ui/src/features/book/book.test.tsx @@ -2,7 +2,7 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { keys } from '@/api/hooks'; import { setToken } from '@/api/token'; -import type { AdminBookDetail, BookEditRequest, MatchCandidate } from '@/api/types'; +import type { AdminBookDetail, BookEditRequest, MatchCandidate, PairingPayload } from '@/api/types'; import { mockFetch, type MockRequest, type MockRoute } from '@/test/fetch-mock'; import { serverInfo } from '@/test/fixtures'; import { bookDetail } from '@/test/library-fixtures'; @@ -644,3 +644,95 @@ describe('match with community metadata', () => { expect(document.querySelectorAll('style')).toHaveLength(0); }); }); + +describe('listen in player', () => { + const pairing: PairingPayload = { + server_name: 'AudioSilo', + server_id: 'srv-1', + base_url: 'https://books.example.com', + pairing_token: 'pair-tok', + uri: 'audiosilo://connect?server=https%3A%2F%2Fbooks.example.com&token=pair-tok&sid=srv-1', + // The public_url: another origin, which the tab must not go to. + web_url: 'https://books.example.com/web/connect?token=pair-tok&sid=srv-1', + qr_png_data_uri: 'data:image/png;base64,', + links: { web: 'https://books.example.com/web', admin: 'https://books.example.com/admin' }, + }; + + /** The tab window.open hands back (null: the browser blocked it). */ + function stubOpen(blocked = false) { + const tab = { opener: {} as unknown, location: { href: '' }, close: vi.fn() }; + const open = vi.fn(() => (blocked ? null : tab)); + vi.stubGlobal('open', open); + return { tab, open }; + } + + const pairs = (calls: MockRequest[]) => + calls.filter((c) => c.method === 'POST' && c.path === '/auth/pair'); + + it("opens the book in this origin's web player, signed in as this admin", async () => { + const { tab, open } = stubOpen(); + const calls = mockFetch(routes(bookDetail(), { 'POST /auth/pair': { body: pairing } })); + renderApp(URL); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Listen in player' })); + await waitFor(() => expect(tab.location.href).not.toBe('')); + // Opened in the click (no pop-up block), cut off from the console. + expect(open).toHaveBeenCalledWith('', '_blank'); + expect(tab.opener).toBeNull(); + expect(pairs(calls)).toHaveLength(1); + const url = new window.URL(tab.location.href, 'http://admin.test'); + expect(url.origin + url.pathname).toBe('http://admin.test/web/connect'); + expect(Object.fromEntries(url.searchParams)).toEqual({ + token: 'pair-tok', + sid: 'srv-1', + uid: '1', + book: '1', + path: BOOK_PATH, + }); + expect(tab.close).not.toHaveBeenCalled(); + }); + + it('closes the tab and says so when no token comes back', async () => { + const { tab } = stubOpen(); + mockFetch( + routes(bookDetail(), { + 'POST /auth/pair': { status: 403, body: { error: 'API keys cannot pair a device' } }, + }), + ); + renderApp(URL); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Listen in player' })); + expect(await screen.findByText("Couldn't open the player")).toBeInTheDocument(); + expect(tab.close).toHaveBeenCalled(); + expect(tab.location.href).toBe(''); + }); + + it('asks for pop-ups when the browser blocks the tab, before any token is made', async () => { + stubOpen(true); + const calls = mockFetch(routes(bookDetail(), { 'POST /auth/pair': { body: pairing } })); + renderApp(URL); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Listen in player' })); + expect( + await screen.findByText( + 'The browser blocked the new tab. Allow pop-ups for this site, then try again.', + ), + ).toBeInTheDocument(); + expect(pairs(calls)).toHaveLength(0); + }); + + it("isn't offered without the web player", async () => { + mockFetch( + routes(bookDetail(), { + 'GET /server': { + body: { ...serverInfo, capabilities: { ...serverInfo.capabilities, web_player: false } }, + }, + }), + ); + renderApp(URL); + await screen.findByRole('heading', { level: 1, name: 'The Way of Kings' }); + // The hero's other actions are there: only this one is missing. + expect(screen.getByRole('button', { name: 'Compare with community' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Listen in player' })).not.toBeInTheDocument(); + }); +}); diff --git a/admin-ui/src/i18n/locales/de.json b/admin-ui/src/i18n/locales/de.json index 23f66d0e..4df3aa99 100644 --- a/admin-ui/src/i18n/locales/de.json +++ b/admin-ui/src/i18n/locales/de.json @@ -520,6 +520,9 @@ "book.hero.match": "Mit Community-Metadaten abgleichen", "book.hero.compare": "Mit der Community vergleichen", "book.hero.matchBlocked": "Speichere oder verwirf deine Änderungen, bevor du abgleichst.", + "book.hero.listen": "Im Player anhören", + "book.hero.listenFailed": "Der Player konnte nicht geöffnet werden", + "book.hero.listenBlocked": "Der Browser hat den neuen Tab blockiert. Erlaube Pop-ups für diese Seite und versuche es dann erneut.", "book.cover.change": "Cover ändern", "book.cover.upload": "Bild hochladen...", "book.cover.remove": "Eigenes Cover entfernen", diff --git a/admin-ui/src/i18n/locales/en.json b/admin-ui/src/i18n/locales/en.json index 8c8719fc..7b78e599 100644 --- a/admin-ui/src/i18n/locales/en.json +++ b/admin-ui/src/i18n/locales/en.json @@ -520,6 +520,9 @@ "book.hero.match": "Match with community metadata", "book.hero.compare": "Compare with community", "book.hero.matchBlocked": "Save or discard your changes before matching.", + "book.hero.listen": "Listen in player", + "book.hero.listenFailed": "Couldn't open the player", + "book.hero.listenBlocked": "The browser blocked the new tab. Allow pop-ups for this site, then try again.", "book.cover.change": "Change cover", "book.cover.upload": "Upload an image...", "book.cover.remove": "Remove the custom cover", diff --git a/admin-ui/src/i18n/locales/es.json b/admin-ui/src/i18n/locales/es.json index c118b7a7..482ba844 100644 --- a/admin-ui/src/i18n/locales/es.json +++ b/admin-ui/src/i18n/locales/es.json @@ -541,6 +541,9 @@ "book.hero.match": "Buscar en los metadatos de la comunidad", "book.hero.compare": "Comparar con la comunidad", "book.hero.matchBlocked": "Guarda o descarta tus cambios antes de buscar coincidencias.", + "book.hero.listen": "Escuchar en el reproductor", + "book.hero.listenFailed": "No se pudo abrir el reproductor", + "book.hero.listenBlocked": "El navegador ha bloqueado la nueva pestaña. Permite las ventanas emergentes de este sitio y vuelve a intentarlo.", "book.cover.change": "Cambiar portada", "book.cover.upload": "Subir una imagen...", "book.cover.remove": "Quitar la portada personalizada", diff --git a/admin-ui/src/i18n/locales/fr.json b/admin-ui/src/i18n/locales/fr.json index 7b08b62a..a9874896 100644 --- a/admin-ui/src/i18n/locales/fr.json +++ b/admin-ui/src/i18n/locales/fr.json @@ -541,6 +541,9 @@ "book.hero.match": "Associer aux métadonnées communautaires", "book.hero.compare": "Comparer avec la communauté", "book.hero.matchBlocked": "Enregistrez ou annulez vos modifications avant d'associer ce livre.", + "book.hero.listen": "Écouter dans le lecteur", + "book.hero.listenFailed": "Impossible d'ouvrir le lecteur", + "book.hero.listenBlocked": "Le navigateur a bloqué le nouvel onglet. Autorisez les fenêtres pop-up pour ce site, puis réessayez.", "book.cover.change": "Changer la couverture", "book.cover.upload": "Importer une image...", "book.cover.remove": "Retirer la couverture personnalisée", diff --git a/admin-ui/src/i18n/locales/it.json b/admin-ui/src/i18n/locales/it.json index 4eed4e29..602c4058 100644 --- a/admin-ui/src/i18n/locales/it.json +++ b/admin-ui/src/i18n/locales/it.json @@ -541,6 +541,9 @@ "book.hero.match": "Abbina ai metadati della community", "book.hero.compare": "Confronta con la community", "book.hero.matchBlocked": "Salva o scarta le modifiche prima di abbinare.", + "book.hero.listen": "Ascolta nel lettore", + "book.hero.listenFailed": "Impossibile aprire il lettore", + "book.hero.listenBlocked": "Il browser ha bloccato la nuova scheda. Consenti i pop-up per questo sito, poi riprova.", "book.cover.change": "Cambia copertina", "book.cover.upload": "Carica un'immagine...", "book.cover.remove": "Rimuovi la copertina personalizzata", diff --git a/admin-ui/src/i18n/locales/pt.json b/admin-ui/src/i18n/locales/pt.json index c735e9ab..9a7e396d 100644 --- a/admin-ui/src/i18n/locales/pt.json +++ b/admin-ui/src/i18n/locales/pt.json @@ -541,6 +541,9 @@ "book.hero.match": "Associar aos metadados da comunidade", "book.hero.compare": "Comparar com a comunidade", "book.hero.matchBlocked": "Guarde ou descarte as suas alterações antes de associar.", + "book.hero.listen": "Ouvir no leitor", + "book.hero.listenFailed": "Não foi possível abrir o leitor", + "book.hero.listenBlocked": "O navegador bloqueou o novo separador. Permita pop-ups para este site e tente novamente.", "book.cover.change": "Mudar a capa", "book.cover.upload": "Carregar uma imagem...", "book.cover.remove": "Remover a capa personalizada", From 171be948f379c989b0a1626390e55052d8ab7098 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Fri, 16 Oct 2026 11:38:29 +0100 Subject: [PATCH 2/4] Listen in player: the simplify pass A narrow PairToken for the two fields the console reads of POST /auth/pair, playerConnectUrl takes the token, the user and the book, and the button ignores clicks while its token is on its way. --- admin-ui/src/api/client.ts | 4 +-- admin-ui/src/api/types.ts | 23 +++--------- admin-ui/src/features/book/book-hero.tsx | 19 +++++----- admin-ui/src/features/book/book-model.test.ts | 7 ++-- admin-ui/src/features/book/book-model.ts | 20 +++++------ admin-ui/src/features/book/book.test.tsx | 36 +++++++++++++------ 6 files changed, 52 insertions(+), 57 deletions(-) diff --git a/admin-ui/src/api/client.ts b/admin-ui/src/api/client.ts index 023b7b19..89ba22a4 100644 --- a/admin-ui/src/api/client.ts +++ b/admin-ui/src/api/client.ts @@ -79,8 +79,8 @@ import type { InviteCreated, Library, LoginResponse, - PairingPayload, MetaMirrorStatus, + PairToken, PathRule, ServerInfo, Share, @@ -332,7 +332,7 @@ export const api = { * A single-use, ten-minute pairing token for the signed-in admin, which "Listen in * player" hands the web player (403 for an API key). */ - pairSelf: () => request('POST', '/auth/pair'), + pairSelf: () => request('POST', '/auth/pair'), stats: () => request('GET', '/admin/stats'), settings: () => request('GET', '/admin/settings'), /** Queues a scan (coalesced with one already waiting); answers with the job. */ diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts index 275d255f..0ce659aa 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -122,26 +122,13 @@ export interface LoginResponse { } /** - * POST /api/v1/auth/pair (handlers_auth.go handlePair; api.PairingPayload, qr.go - * buildPairing): a single-use, ten-minute pairing token for the caller, with the - * links and QR a device pairs from. + * The fields the console reads of POST /api/v1/auth/pair's pairing payload + * (handlers_auth.go handlePair; api.PairingPayload, qr.go buildPairing): a + * single-use, ten-minute pairing token for the caller. */ -export interface PairingPayload { - server_name: string; - server_id: string; - base_url: string; +export interface PairToken { pairing_token: string; - /** audiosilo://connect?... (the native app). */ - uri: string; - /** /web/connect?token=... (the QR); the base may be the public_url, another origin. */ - web_url: string; - qr_png_data_uri: string; - links: { web: string; admin: string; ios?: string; android?: string }; - /** Only for a token an invite minted: never for /auth/pair. */ - code_expires_at?: string; - uses_remaining?: number; - /** The server's home and away addresses, when it knows either. */ - addresses?: { home?: string; away?: string }; + server_id: string; } /** One library's book count in GET /admin/stats (handlers_stats.go libStat). */ diff --git a/admin-ui/src/features/book/book-hero.tsx b/admin-ui/src/features/book/book-hero.tsx index ec322438..b71606f8 100644 --- a/admin-ui/src/features/book/book-hero.tsx +++ b/admin-ui/src/features/book/book-hero.tsx @@ -1,4 +1,4 @@ -import { Fragment, useRef } from 'react'; +import { Fragment, useRef, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { Trans, useTranslation } from 'react-i18next'; import { @@ -257,7 +257,10 @@ function NameLinks({ function ListenButton({ book: b }: { book: AdminBook }) { const { t } = useTranslation(); const me = useCurrentUser(); + // One token per click: a second click while one is on its way is ignored. + const [busy, setBusy] = useState(false); const listen = async () => { + if (busy) return; // Opened in the click itself, or the browser takes it for a pop-up and blocks it; // pointed at the player once the token is back. const tab = window.open('', '_blank'); @@ -270,22 +273,18 @@ function ListenButton({ book: b }: { book: AdminBook }) { return; } tab.opener = null; + setBusy(true); try { - const pairing = await api.pairSelf(); - tab.location.href = playerConnectUrl({ - token: pairing.pairing_token, - serverId: pairing.server_id, - userId: me.id, - libraryId: b.library_id, - path: b.path, - }); + tab.location.href = playerConnectUrl(await api.pairSelf(), me.id, b); } catch (err) { tab.close(); toastError(t('book.hero.listenFailed'), err); + } finally { + setBusy(false); } }; return ( - diff --git a/admin-ui/src/features/book/book-model.test.ts b/admin-ui/src/features/book/book-model.test.ts index c852b698..4c0d16b8 100644 --- a/admin-ui/src/features/book/book-model.test.ts +++ b/admin-ui/src/features/book/book-model.test.ts @@ -191,11 +191,8 @@ describe('the rest of the page', () => { describe('playerConnectUrl', () => { it("opens the book in this origin's web player, as this admin", () => { - const url = playerConnectUrl({ - token: 'tok+/=', - serverId: 'srv-1', - userId: 1, - libraryId: 2, + const url = playerConnectUrl({ pairing_token: 'tok+/=', server_id: 'srv-1' }, 1, { + library_id: 2, path: 'Brandon Sanderson/Mistborn & Co/01 #1?', }); const parsed = new URL(url, 'http://admin.test'); diff --git a/admin-ui/src/features/book/book-model.ts b/admin-ui/src/features/book/book-model.ts index 2f863593..e5fc68a3 100644 --- a/admin-ui/src/features/book/book-model.ts +++ b/admin-ui/src/features/book/book-model.ts @@ -5,11 +5,13 @@ import { type AdminChapter, type AdminFile, type BookEditRequest, + type BookRef, type CommunityChaptersDetail, type FieldSource, type FieldValue, type FolderMode, type OverrideField, + type PairToken, } from '@/api/types'; import { formatSeriesPosition } from '@/lib/format'; import type { Phrase } from '@/lib/phrase'; @@ -503,19 +505,13 @@ export function audioLine( * origin), which exchanges the token, or skips it when that browser is already * signed in as `userId` (sid + uid name the connection), and opens the book. */ -export function playerConnectUrl(p: { - token: string; - serverId: string; - userId: number; - libraryId: number; - path: string; -}): string { +export function playerConnectUrl(pairing: PairToken, userId: number, book: BookRef): string { const q = new URLSearchParams({ - token: p.token, - sid: p.serverId, - uid: String(p.userId), - book: String(p.libraryId), - path: p.path, + token: pairing.pairing_token, + sid: pairing.server_id, + uid: String(userId), + book: String(book.library_id), + path: book.path, }); return `/web/connect?${q.toString()}`; } diff --git a/admin-ui/src/features/book/book.test.tsx b/admin-ui/src/features/book/book.test.tsx index 78dec94e..5e10b8b2 100644 --- a/admin-ui/src/features/book/book.test.tsx +++ b/admin-ui/src/features/book/book.test.tsx @@ -2,8 +2,8 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { keys } from '@/api/hooks'; import { setToken } from '@/api/token'; -import type { AdminBookDetail, BookEditRequest, MatchCandidate, PairingPayload } from '@/api/types'; -import { mockFetch, type MockRequest, type MockRoute } from '@/test/fetch-mock'; +import type { AdminBookDetail, BookEditRequest, MatchCandidate, PairToken } from '@/api/types'; +import { mockFetch, type MockReply, type MockRequest, type MockRoute } from '@/test/fetch-mock'; import { serverInfo } from '@/test/fixtures'; import { bookDetail } from '@/test/library-fixtures'; import { renderApp } from '@/test/render-app'; @@ -646,17 +646,12 @@ describe('match with community metadata', () => { }); describe('listen in player', () => { - const pairing: PairingPayload = { - server_name: 'AudioSilo', - server_id: 'srv-1', - base_url: 'https://books.example.com', + const pairing = { pairing_token: 'pair-tok', - uri: 'audiosilo://connect?server=https%3A%2F%2Fbooks.example.com&token=pair-tok&sid=srv-1', + server_id: 'srv-1', // The public_url: another origin, which the tab must not go to. web_url: 'https://books.example.com/web/connect?token=pair-tok&sid=srv-1', - qr_png_data_uri: 'data:image/png;base64,', - links: { web: 'https://books.example.com/web', admin: 'https://books.example.com/admin' }, - }; + } satisfies PairToken & { web_url: string }; /** The tab window.open hands back (null: the browser blocked it). */ function stubOpen(blocked = false) { @@ -692,6 +687,27 @@ describe('listen in player', () => { expect(tab.close).not.toHaveBeenCalled(); }); + it('makes one token however often the button is pressed while it is on its way', async () => { + const { tab, open } = stubOpen(); + let answer!: (r: MockReply) => void; + const calls = mockFetch( + routes(bookDetail(), { + 'POST /auth/pair': () => new Promise((r) => (answer = r)), + }), + ); + renderApp(URL); + const user = userEvent.setup(); + const listen = await screen.findByRole('button', { name: 'Listen in player' }); + await user.click(listen); + await waitFor(() => expect(listen).toBeDisabled()); + await user.click(listen); + expect(open).toHaveBeenCalledTimes(1); + expect(pairs(calls)).toHaveLength(1); + answer({ body: pairing }); + await waitFor(() => expect(tab.location.href).not.toBe('')); + await waitFor(() => expect(listen).toBeEnabled()); + }); + it('closes the tab and says so when no token comes back', async () => { const { tab } = stubOpen(); mockFetch( From 1e456eb32599a1a900ec3ba100f39479fdc757b3 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Fri, 16 Oct 2026 11:56:56 +0100 Subject: [PATCH 3/4] Listen in player: the book tests put window.open back --- admin-ui/src/features/book/book.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/admin-ui/src/features/book/book.test.tsx b/admin-ui/src/features/book/book.test.tsx index 5e10b8b2..b732a18e 100644 --- a/admin-ui/src/features/book/book.test.tsx +++ b/admin-ui/src/features/book/book.test.tsx @@ -653,6 +653,12 @@ describe('listen in player', () => { web_url: 'https://books.example.com/web/connect?token=pair-tok&sid=srv-1', } satisfies PairToken & { web_url: string }; + // The stubbed window.open stays this block's: the real one is back after each test. + const realOpen = window.open; + afterEach(() => { + window.open = realOpen; + }); + /** The tab window.open hands back (null: the browser blocked it). */ function stubOpen(blocked = false) { const tab = { opener: {} as unknown, location: { href: '' }, close: vi.fn() }; From 29897df79b7880c708e9dfd084978e9de4a1a6ea Mon Sep 17 00:00:00 2001 From: KodeStar Date: Fri, 16 Oct 2026 12:33:44 +0100 Subject: [PATCH 4/4] Listen in player: one token per click, even within one task --- admin-ui/src/features/book/book-hero.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/admin-ui/src/features/book/book-hero.tsx b/admin-ui/src/features/book/book-hero.tsx index b71606f8..92549de7 100644 --- a/admin-ui/src/features/book/book-hero.tsx +++ b/admin-ui/src/features/book/book-hero.tsx @@ -257,10 +257,12 @@ function NameLinks({ function ListenButton({ book: b }: { book: AdminBook }) { const { t } = useTranslation(); const me = useCurrentUser(); - // One token per click: a second click while one is on its way is ignored. + // One token per click: a second click while one is on its way is ignored (the + // ref sees it at once, even within one task; the state disables the button). const [busy, setBusy] = useState(false); + const inFlight = useRef(false); const listen = async () => { - if (busy) return; + if (inFlight.current) return; // Opened in the click itself, or the browser takes it for a pop-up and blocks it; // pointed at the player once the token is back. const tab = window.open('', '_blank'); @@ -273,6 +275,7 @@ function ListenButton({ book: b }: { book: AdminBook }) { return; } tab.opener = null; + inFlight.current = true; setBusy(true); try { tab.location.href = playerConnectUrl(await api.pairSelf(), me.id, b); @@ -280,6 +283,7 @@ function ListenButton({ book: b }: { book: AdminBook }) { tab.close(); toastError(t('book.hero.listenFailed'), err); } finally { + inFlight.current = false; setBusy(false); } };