Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions admin-ui/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import type {
Library,
LoginResponse,
MetaMirrorStatus,
PairToken,
PathRule,
ServerInfo,
Share,
Expand Down Expand Up @@ -327,6 +328,11 @@ export const api = {
/** Revokes the stored session, or `token` when given (never touching storage). */
logout: (token?: string) => request<void>('POST', '/auth/logout', undefined, token),
me: () => request<User>('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<PairToken>('POST', '/auth/pair'),
stats: () => request<AdminStats>('GET', '/admin/stats'),
settings: () => request<AdminSettings>('GET', '/admin/settings'),
/** Queues a scan (coalesced with one already waiting); answers with the job. */
Expand Down
10 changes: 10 additions & 0 deletions admin-ui/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
54 changes: 51 additions & 3 deletions admin-ui/src/features/book/book-hero.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -8,6 +8,7 @@ import {
Ellipsis,
FileAudio,
HardDrive,
Headphones,
ImageMinus,
ImageUp,
RefreshCw,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -146,6 +148,7 @@ export function BookHero({
{b.matched ? t('book.hero.compare') : t('book.hero.match')}
</Button>
) : null}
{caps?.web_player ? <ListenButton book={b} /> : null}
<CoverMenu detail={detail} />
<MoreMenu detail={detail} root={library?.root} onAddToShare={onAddToShare} />
</div>
Expand Down Expand Up @@ -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 (
<Button variant="outline" disabled={busy} onClick={() => void listen()}>
<Headphones aria-hidden="true" />
{t('book.hero.listen')}
</Button>
);
}

/** Upload a custom cover (kept in AudioSilo's database) or remove it. */
function CoverMenu({ detail }: { detail: AdminBookDetail }) {
const { t } = useTranslation();
Expand Down
19 changes: 19 additions & 0 deletions admin-ui/src/features/book/book-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
draftErrors,
fileName,
fileStrip,
playerConnectUrl,
ribbonSegments,
saveRequest,
undoRevertRequest,
Expand Down Expand Up @@ -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?',
});
});
});
19 changes: 19 additions & 0 deletions admin-ui/src/features/book/book-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()}`;
}
118 changes: 116 additions & 2 deletions admin-ui/src/features/book/book.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<MockReply>((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();
});
});
3 changes: 3 additions & 0 deletions admin-ui/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions admin-ui/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions admin-ui/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions admin-ui/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions admin-ui/src/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions admin-ui/src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading