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..89ba22a4 100644 --- a/admin-ui/src/api/client.ts +++ b/admin-ui/src/api/client.ts @@ -80,6 +80,7 @@ import type { Library, LoginResponse, MetaMirrorStatus, + PairToken, PathRule, ServerInfo, Share, @@ -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..0ce659aa 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -121,6 +121,16 @@ export interface LoginResponse { server_id: string; } +/** + * 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 PairToken { + pairing_token: string; + server_id: 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..92549de7 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 { @@ -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,51 @@ 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(); + // 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 (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'); + if (!tab) { + toast.add({ + title: t('book.hero.listenFailed'), + description: t('book.hero.listenBlocked'), + type: 'error', + }); + return; + } + tab.opener = null; + inFlight.current = true; + setBusy(true); + try { + tab.location.href = playerConnectUrl(await api.pairSelf(), me.id, b); + } catch (err) { + tab.close(); + toastError(t('book.hero.listenFailed'), err); + } finally { + inFlight.current = false; + setBusy(false); + } + }; + 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..4c0d16b8 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,21 @@ describe('the rest of the page', () => { ); }); }); + +describe('playerConnectUrl', () => { + it("opens the book in this origin's web player, as this admin", () => { + 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'); + 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..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'; @@ -496,3 +498,20 @@ 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(pairing: PairToken, userId: number, book: BookRef): string { + const q = new URLSearchParams({ + 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 24a3d37c..b732a18e 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 } 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'; @@ -644,3 +644,117 @@ describe('match with community metadata', () => { expect(document.querySelectorAll('style')).toHaveLength(0); }); }); + +describe('listen in player', () => { + const pairing = { + pairing_token: 'pair-tok', + 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', + } 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() }; + 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('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( + 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",