From a6b92c52b81b8955675352c016f90a848c703ad7 Mon Sep 17 00:00:00 2001 From: Ncbudd Date: Wed, 11 Nov 2026 16:47:27 -0500 Subject: [PATCH 1/4] [SeerrNG #658] Lumen: narrator picker, request list and small-screen fixes --- lumen/src/api.ts | 10 +- lumen/src/auth.tsx | 50 ++- lumen/src/components/cards.tsx | 36 +- lumen/src/hooks.ts | 12 +- lumen/src/mediaServer.ts | 6 + lumen/src/pages/BookDetail.tsx | 91 ++++- lumen/src/pages/Comics.tsx | 2 +- lumen/src/pages/Discover.tsx | 2 +- lumen/src/pages/MovieDetail.tsx | 2 +- lumen/src/pages/Requests.tsx | 56 ++- lumen/src/styles/app-react.css | 6 + lumen/src/styles/app.css | 109 +++++- lumen/src/styles/touch.css | 30 ++ lumen/tests/browser/app.spec.ts | 11 +- lumen/tests/browser/book-description.spec.ts | 92 +++++ .../tests/browser/coming-soon-titles.spec.ts | 68 ++++ lumen/tests/browser/followups.spec.ts | 175 +++++++++ lumen/tests/browser/narrations.spec.ts | 360 ++++++++++++++++++ lumen/tests/browser/narrow-short-nav.spec.ts | 94 +++++ lumen/tests/browser/quick-focus.spec.ts | 75 ++++ lumen/tests/browser/quick-sheet-short.spec.ts | 94 +++++ lumen/tests/browser/release-dates.spec.ts | 104 +++++ lumen/tests/browser/requests-editions.spec.ts | 97 +++++ .../browser/requests-narrow-actions.spec.ts | 75 ++++ .../browser/requests-narrow-pill.spec.ts | 88 +++++ .../browser/requests-narrow-tabs.spec.ts | 84 ++++ lumen/tests/browser/requests-pages.spec.ts | 13 +- lumen/tests/browser/touch-all-chip.spec.ts | 37 ++ 28 files changed, 1819 insertions(+), 60 deletions(-) create mode 100644 lumen/tests/browser/book-description.spec.ts create mode 100644 lumen/tests/browser/coming-soon-titles.spec.ts create mode 100644 lumen/tests/browser/followups.spec.ts create mode 100644 lumen/tests/browser/narrations.spec.ts create mode 100644 lumen/tests/browser/narrow-short-nav.spec.ts create mode 100644 lumen/tests/browser/quick-focus.spec.ts create mode 100644 lumen/tests/browser/quick-sheet-short.spec.ts create mode 100644 lumen/tests/browser/release-dates.spec.ts create mode 100644 lumen/tests/browser/requests-editions.spec.ts create mode 100644 lumen/tests/browser/requests-narrow-actions.spec.ts create mode 100644 lumen/tests/browser/requests-narrow-pill.spec.ts create mode 100644 lumen/tests/browser/requests-narrow-tabs.spec.ts create mode 100644 lumen/tests/browser/touch-all-chip.spec.ts diff --git a/lumen/src/api.ts b/lumen/src/api.ts index d8a2c1d..2979a5e 100644 --- a/lumen/src/api.ts +++ b/lumen/src/api.ts @@ -205,10 +205,16 @@ export async function englishAudioEdition( narrators?: string[] | null; selected?: boolean; durationSeconds?: number | null; + requestable?: boolean; }[]; }>(audiobookEditionsPath(workId)); + // copies SeerrNG can't pin come with an empty id; naming one would pin nothing const en = d.editions.filter( - (e) => englishLang(e.language) && e.narrators?.length + (e) => + englishLang(e.language) && + e.narrators?.length && + e.id && + e.requestable !== false ); return ( en.find((e) => e.selected) || @@ -483,6 +489,7 @@ export interface Edition { releaseDate?: string; owned?: boolean; selected?: boolean; + requestable?: boolean; // false: no edition id SeerrNG can pin a request to } export interface DownloadItem { size: number; @@ -502,6 +509,7 @@ export interface RequestItem { is4k: boolean; createdAt: string; bookFormat?: string; + preferredEditionId?: string | null; seasons: { seasonNumber: number }[]; requestedBy: { id: number; displayName: string }; media: MediaInfo & { diff --git a/lumen/src/auth.tsx b/lumen/src/auth.tsx index c4769d9..e5ea651 100644 --- a/lumen/src/auth.tsx +++ b/lumen/src/auth.tsx @@ -32,6 +32,13 @@ export const isAdministrator = (permissions: unknown): boolean => (BigInt(permissions) & 2n) !== 0n; export const useMediaServerName = () => serverName(); +// the page-load check of who is signed in: one call even if two providers start it together +let meInFlight: Promise | null = null; +const sharedMe = () => + (meInFlight ??= api('/auth/me').finally(() => { + meInFlight = null; + })); + export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(undefined); const [unreachable, setUnreachable] = useState(false); @@ -43,24 +50,28 @@ export function AuthProvider({ children }: { children: ReactNode }) { principal.current = id; setUser(next); }, []); - const refresh = useCallback(async () => { - const request = ++authRequest.current; - try { - const next = await api('/auth/me'); - if (request === authRequest.current) { - setUnreachable(false); - setPrincipal(next); + const check = useCallback( + async (share: boolean) => { + const request = ++authRequest.current; + try { + const next = await (share ? sharedMe() : api('/auth/me')); + if (request === authRequest.current) { + setUnreachable(false); + setPrincipal(next); + } + } catch (e) { + if (request !== authRequest.current) return; + // only an answer from the server means signed out; a network drop or 5xx must not + // sign anyone out (whoever was signed in stays, otherwise a retry screen is shown) + if (e instanceof ApiError && (e.status === 401 || e.status === 403)) { + setUnreachable(false); + setPrincipal(null); + } else setUnreachable(true); } - } catch (e) { - if (request !== authRequest.current) return; - // only an answer from the server means signed out; a network drop or 5xx must not - // sign anyone out (whoever was signed in stays, otherwise a retry screen is shown) - if (e instanceof ApiError && (e.status === 401 || e.status === 403)) { - setUnreachable(false); - setPrincipal(null); - } else setUnreachable(true); - } - }, [setPrincipal]); + }, + [setPrincipal] + ); + const refresh = useCallback(() => check(false), [check]); const signOut = useCallback(async () => { ++authRequest.current; try { @@ -72,8 +83,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { setPrincipal(null); }, [setPrincipal]); useEffect(() => { - void refresh(); - }, [refresh]); + // the first check is shared, so a remounted provider (StrictMode in development) doesn't ask twice + void check(true); + }, [check]); return ( {children} diff --git a/lumen/src/components/cards.tsx b/lumen/src/components/cards.tsx index 8fef33d..c99bfe3 100644 --- a/lumen/src/components/cards.tsx +++ b/lumen/src/components/cards.tsx @@ -18,13 +18,13 @@ import { type TitleResult, } from '../api'; import { useMediaServerName } from '../auth'; +import { friendlyError } from '../errors'; import { dropBookStates, useBookStates } from '../hooks'; import { Icon, type IconName } from '../icons'; +import { scrollBehavior } from '../motion'; import { usePrefs } from '../settings'; import { useQuickRequest } from './requestSheet'; import { useToast } from './toast'; -import { friendlyError } from '../errors'; -import { scrollBehavior } from '../motion'; // A stable theme-owned tone per title keeps drawn covers consistent between visits. function toneOf(text: string) { @@ -183,8 +183,15 @@ const MON = [ 'Nov', 'Dec', ]; -export const shortDate = (d?: string) => - d ? `${MON[+d.slice(5, 7) - 1]} ${+d.slice(8, 10)}` : ''; +// "Mar 11", or "Mar 11, 2027" when the date is in another year than this one (`year: false` for +// callers that print the year themselves) +export const shortDate = (d?: string, year = true) => + d + ? `${MON[+d.slice(5, 7) - 1]} ${+d.slice(8, 10)}` + + (year && +d.slice(0, 4) !== new Date().getFullYear() + ? `, ${+d.slice(0, 4)}` + : '') + : ''; // local request state so a card flips to "Requested" straight away function useRequested(initial: Stat) { @@ -235,6 +242,18 @@ export function TitleCard({ it, meta }: { it: TitleResult; meta?: string }) { ); }); const [request, busy] = useBusy(quick.request); + // The Request button sits in an overlay that only shows on hover or focus. While the sheet is + // open the poster has neither, so keep the overlay up until focus is back on the button. + const goRef = useRef(null); + const sheetOpen = quick.sheet !== null; + const [hold, setHold] = useState(false); + useEffect(() => { + if (sheetOpen) setHold(true); + else if (hold) { + goRef.current?.focus({ preventScroll: true }); + setHold(false); + } + }, [sheetOpen, hold]); let qb: ReactNode; const plex = plexUrl(it.mediaInfo); if (s?.k === 'ok') @@ -269,7 +288,12 @@ export function TitleCard({ it, meta }: { it: TitleResult; meta?: string }) { else qb = ( <> - @@ -286,7 +310,7 @@ export function TitleCard({ it, meta }: { it: TitleResult; meta?: string }) { ); return ( -
+
(path: string | null) { @@ -62,7 +66,11 @@ function fetchShared(path: string): Promise { return shared(path, async () => { const generation = sessionGeneration(); const cacheVersion = cacheGeneration(); - const d = await api(path); + const d = + path === PUBLIC_PATH + ? ((recentPublicSettings(RECHECK_MS) as T | null) ?? + (await api(path))) + : await api(path); if (generation === sessionGeneration()) remember(path, d, cacheVersion); return d; }); diff --git a/lumen/src/mediaServer.ts b/lumen/src/mediaServer.ts index 7786d90..eacd1f0 100644 --- a/lumen/src/mediaServer.ts +++ b/lumen/src/mediaServer.ts @@ -246,6 +246,11 @@ export const retryAfterMs = ( const date = value ? Date.parse(value) : NaN; return Number.isFinite(date) ? Math.max(0, date - now) : 2000 * (attempt + 1); }; +// The answer the provider just fetched. The rest of the app asks for the same path through +// useApi, and a sign-in clears the saved answers, so without this each page load asked twice. +let lastPublic: { at: number; body: unknown } | null = null; +export const recentPublicSettings = (maxAgeMs: number) => + lastPublic && Date.now() - lastPublic.at < maxAgeMs ? lastPublic.body : null; async function loadPublicSettings(signal: AbortSignal): Promise { for (let attempt = 0; attempt < 3 && !signal.aborted; attempt++) { const r = await fetch('/api/v1/settings/public', { @@ -256,6 +261,7 @@ async function loadPublicSettings(signal: AbortSignal): Promise { if (r?.ok) { const body = await r.json().catch(() => null); if (body && typeof body === 'object' && !Array.isArray(body)) { + lastPublic = { at: Date.now(), body }; try { sessionStorage.setItem(PUBLIC_KEY, JSON.stringify(body)); } catch { diff --git a/lumen/src/pages/BookDetail.tsx b/lumen/src/pages/BookDetail.tsx index a343542..4eceb3e 100644 --- a/lumen/src/pages/BookDetail.tsx +++ b/lumen/src/pages/BookDetail.tsx @@ -30,10 +30,10 @@ import { } from '../components/detail'; import { ServiceNote } from '../components/extra'; import { useToast } from '../components/toast'; +import { friendlyError } from '../errors'; import { dropBookStates, useApi } from '../hooks'; import { Icon } from '../icons'; import { usePrefs } from '../settings'; -import { friendlyError } from '../errors'; const english = (e: Edition) => !e.language || /^(en|eng|english|und)$/i.test(e.language); @@ -44,13 +44,19 @@ const who = (e: Edition) => : e.narrators.join(' & ') : 'Narrator not listed'; -// one row per distinct narrator set; the copy with the most detail wins -function narrations(list: Edition[]) { - const by = new Map(); +// SeerrNG lists catalogue copies it can't pin (no Hardcover edition id) with an empty id +const pinnable = (e: Edition) => !!e.id && e.requestable !== false; +// a narration row: one recording, keyed by its narrators (edition ids can be empty or shared) +type NarrationRow = Edition & { key: string }; + +// one row per distinct narrator set; a copy that can be requested wins, then the most detail +function narrations(list: Edition[]): NarrationRow[] { + const by = new Map(); for (const e of list.filter((x) => x.format === 'audiobook' && english(x))) { const k = narratorKey(e.narrators) || 'unknown:' + e.id; const cur = by.get(k); const score = (x: Edition) => + (pinnable(x) ? 16 : 0) + (x.durationSeconds ? 2 : 0) + (x.publisher ? 1 : 0) + (x.owned ? 4 : 0) + @@ -58,13 +64,21 @@ function narrations(list: Edition[]) { if (!cur || score(e) > score(cur)) by.set(k, { ...e, + key: k, owned: e.owned || cur?.owned, selected: e.selected || cur?.selected, }); + else if (e.owned || e.selected) + by.set(k, { + ...cur, + owned: cur.owned || e.owned, + selected: cur.selected || e.selected, + }); } // full-cast productions show up as several listings (different cast subsets, or just "Full Cast"); - // keep one row for them: the one with the most detail + // keep one row for them: one that can be requested, then the one with the most detail const castScore = (x: Edition) => + (pinnable(x) ? 16 : 0) + (x.durationSeconds ? 4 : 0) + (x.owned ? 8 : 0) + (x.narrators?.length || 0) / 100; @@ -75,7 +89,13 @@ function narrations(list: Edition[]) { .sort((a, b) => castScore(b) - castScore(a)); const rows = [...by.values()] .filter((x) => !isCast(x)) - .concat(cast.slice(0, 1)); + .concat( + cast.slice(0, 1).map((x) => ({ + ...x, + owned: cast.some((c) => c.owned), + selected: cast.some((c) => c.selected), + })) + ); // unnamed recordings only add noise when named ones exist; "Any English narrator" covers them const named = rows.filter((r) => r.narrators?.length || r.owned); return (named.length ? named : rows).sort( @@ -103,6 +123,19 @@ function sameCopy(k: string, narrator?: string) { // Hardcover tags that name a format, not a subject ("Audiobook" on the ebook page) const FORMAT_TAG = /^(audio ?books?|e-?books?|kindle|audible)$/i; +// bookshelf:N: -> the same book under bookshelf:N: +function hcFormId(id: string): string | null { + const m = /^(bookshelf:\d+:)([A-Za-z0-9_-]+)$/.exec(id); + if (!m) return null; + try { + const raw = atob(m[2].replace(/-/g, '+').replace(/_/g, '/')); + if (!/^[1-9]\d*$/.test(raw)) return null; + return `${m[1]}${btoa(`hc:${raw}`).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`; + } catch { + return null; + } +} + export default function BookDetail() { const { id = '' } = useParams(); const [p] = useSearchParams(); @@ -114,7 +147,20 @@ export default function BookDetail() { const plainId = bookIdFor(decodeURIComponent(id), f); const first = useApi(`/book/${encodeURIComponent(plainId)}`); const bookId = plainId; - const b = first.data; + // Books stored under a bare Hardcover id can come back without a description while the + // "hc:" form of the same work has one: ask for that form once, and only to fill the blurb. + const altId = + first.data && !first.data.description ? hcFormId(plainId) : null; + const alt = useApi( + altId ? `/book/${encodeURIComponent(altId)}` : null + ); + const b = useMemo( + () => + first.data && !first.data.description && altId && alt.data?.description + ? { ...first.data, description: alt.data.description } + : first.data, + [first.data, alt.data, altId] + ); const error = first.error; const reload = first.reload; const work = hardcoverWorkId(bookId); @@ -200,9 +246,11 @@ export default function BookDetail() { return r.owned ? 'library' : ''; }; const chaptarrFor = (r: Edition): ChaptarrEdition | undefined => { - const num = r.id.replace(/^hc:/, ''); + const num = (r.id || '').replace(/^hc:/, ''); return ( - cEditions.find((e) => String(e.foreignEditionId) === num) || + (num + ? cEditions.find((e) => String(e.foreignEditionId) === num) + : undefined) || cEditions.find( (e) => narratorKey( @@ -242,17 +290,20 @@ export default function BookDetail() { ); const ownedRows = rows.filter((r) => stateOf(r) === 'library'); const ownsAny = ownedRows.length > 0 || local.some((l) => l.hasFiles); - // another-narrator copies need a named narrator and ChaptarrNG's edition id + // another-narrator copies need a named narrator and ChaptarrNG's edition id; a first request + // pins SeerrNG's edition id, so a recording SeerrNG can't pin can't be chosen const requestable = rows.filter( (r) => - !stateOf(r) && (!anotherMode || (r.narrators?.length && chaptarrFor(r))) + !stateOf(r) && + (anotherMode ? r.narrators?.length && chaptarrFor(r) : pinnable(r)) ); + // the selection is a row key ('*' = any English narrator, '' = nothing picked yet) const chosen = pick ?? (anotherMode ? '' - : ((requestable.find((r) => r.selected) || requestable[0])?.id ?? '*')); - const chosenRow = rows.find((r) => r.id === chosen); + : ((requestable.find((r) => r.selected) || requestable[0])?.key ?? '*')); + const chosenRow = requestable.find((r) => r.key === chosen); const longest = Math.max(0, ...rows.map((r) => r.durationSeconds || 0)); const series = b.series?.[0]; const audio = f === 'audiobook'; @@ -288,7 +339,7 @@ export default function BookDetail() { }; const requestAnother = async () => { - const row = rows.find((r) => r.id === chosen); + const row = chosenRow; const ce = row && chaptarrFor(row); if (!row || !ce) return; setBusy(true); @@ -312,7 +363,7 @@ export default function BookDetail() { setBusy(true); try { // "any narrator" or narrator choice off: still target an English recording - const exact = audio && showNarr && chosen !== '*' ? chosen : undefined; + const exact = audio && showNarr ? chosenRow?.id || undefined : undefined; const fallback = audio && !exact ? rows.find((r) => r.selected && r.narrators?.length)?.id || @@ -513,23 +564,23 @@ export default function BookDetail() { {rows.map((r) => { const st = stateOf(r); const locked = !!st || !requestable.includes(r); - const on = !locked && chosen === r.id; + const on = !locked && chosen === r.key; return (
!locked && setPick(r.id)} + onClick={() => !locked && setPick(r.key)} onKeyDown={(e) => { if ( !locked && (e.key === ' ' || e.key === 'Enter') ) { e.preventDefault(); - setPick(r.id); + setPick(r.key); } }} > @@ -555,7 +606,7 @@ export default function BookDetail() { .join(' · ') || 'Details unknown'}
- {r.selected && !st && !anotherMode && ( + {r.selected && !locked && !anotherMode && ( Recommended diff --git a/lumen/src/pages/Comics.tsx b/lumen/src/pages/Comics.tsx index b288e19..7fae3a6 100644 --- a/lumen/src/pages/Comics.tsx +++ b/lumen/src/pages/Comics.tsx @@ -171,7 +171,7 @@ export function ComicDetail() { {i.name || `Issue #${i.issueNumber}`} {i.coverDate && ( - {shortDate(i.coverDate)},{' '} + {shortDate(i.coverDate, false)},{' '} {i.coverDate.slice(0, 4)} )} diff --git a/lumen/src/pages/Discover.tsx b/lumen/src/pages/Discover.tsx index 45ec1d6..bb21294 100644 --- a/lumen/src/pages/Discover.tsx +++ b/lumen/src/pages/Discover.tsx @@ -314,7 +314,7 @@ export default function Discover() { )} {on.comingSoon && !empty(soon.data?.results) && ( -
+
{soon.data ? ( {soon.data.results.map((m) => ( diff --git a/lumen/src/pages/MovieDetail.tsx b/lumen/src/pages/MovieDetail.tsx index 4c24fd3..1e8658b 100644 --- a/lumen/src/pages/MovieDetail.tsx +++ b/lumen/src/pages/MovieDetail.tsx @@ -232,7 +232,7 @@ export default function MovieDetail() { ['Runtime', fmtRun(m.runtime)], [ 'Released', - date ? `${shortDate(date)}, ${date.slice(0, 4)}` : '', + date ? `${shortDate(date, false)}, ${date.slice(0, 4)}` : '', ], ['Director', director], ['Rated', cert(m)], diff --git a/lumen/src/pages/Requests.tsx b/lumen/src/pages/Requests.tsx index 4de8fc2..02b3083 100644 --- a/lumen/src/pages/Requests.tsx +++ b/lumen/src/pages/Requests.tsx @@ -211,6 +211,29 @@ function seasonList(ns: number[]) { return `${ns.length === 1 ? 'Season' : 'Seasons'} ${runs.join(', ')}`; } +// an edition id that names a specific edition rather than just "this book" (Open Library work ids +// and plain edition ids from the book service are not pins) +const OL_EDITION = /^(?:\/?books\/)?OL\d+M$/i; +function otherEditionInLibrary(r: RequestItem): boolean { + if (r.type !== 'book' || r.status !== 5) return false; + if (r.media.status !== 4 && r.media.status !== 5) return false; + const pin = r.preferredEditionId; + if (!pin || OL_EDITION.test(pin)) return false; + const m = r.media; + const ebook = !!m.availableEbookServiceIds?.length; + const audiobook = + !!m.availableAudiobookServiceIds?.length || + !!m.audiobookLibraryItemId || + m.hasPlexAudiobookFile === true || + !!m.ratingKey; + const format = r.bookFormat ?? 'ebook'; + return format === 'both' + ? ebook && audiobook + : format === 'audiobook' + ? audiobook + : ebook; +} + function state( x: Row, serverName = 'Plex' @@ -274,6 +297,17 @@ function state( case 'importing': return { ...base, k: 'wait', word: 'Importing', ico: 'clock' }; case 'library': + // a completed request pinned to one edition, while another edition of the book is what's + // in the library: the server keeps saying "library" for it, which would never settle + if (otherEditionInLibrary(r)) + return { + k: 'ok', + word: 'Different edition in library', + ico: 'check', + active: false, + detail: + 'This book is in your library in another edition than the one requested.', + }; return { ...base, k: 'wait', word: 'Checking library', ico: 'clock' }; case 'failed': return { ...base, k: 'bad', word: 'Couldn’t get it', ico: 'x' }; @@ -415,6 +449,24 @@ async function startedDownloads(query = '') { (first.pageInfo?.pages || 1) > 1 ? (await page(100)).results : []; return first.results.concat(more).filter((r) => progress(r) > 0); } +// All of one person's requests, newest first, a page of the list at a time (one call each), so +// the tab counts are the real totals and nothing falls out of every tab. Row detail lookups stay +// limited separately (describeAll). +const OWN_PAGE = 15; +const OWN_MAX = 2000; // a safety stop, not a limit anyone should meet +async function ownRequests(userId: number): Promise { + const out: RequestItem[] = []; + for (let skip = 0; skip < OWN_MAX; skip += OWN_PAGE) { + const d = await requestList( + `/request?take=${OWN_PAGE}&skip=${skip}&filter=all&sort=added&requestedBy=${userId}` + ); + out.push(...d.results); + const total = d.pageInfo?.results; + if (d.results.length < OWN_PAGE) break; + if (total !== undefined && out.length >= total) break; + } + return out; +} const merge = (...lists: RequestItem[][]) => { const seen = new Set(); return lists.flat().filter((r) => !seen.has(r.id) && !!seen.add(r.id)); @@ -874,9 +926,7 @@ export default function Requests() { let live = true; setOwnError(false); const media = Promise.all([ - requestList( - `/request?take=40&skip=0&filter=all&sort=added&requestedBy=${user.id}` - ).then((d) => d.results), + ownRequests(user.id), startedDownloads(`&requestedBy=${user.id}`).catch(() => []), ]).then(([recent, started]) => describeAll( diff --git a/lumen/src/styles/app-react.css b/lumen/src/styles/app-react.css index be219ba..c5836d6 100644 --- a/lumen/src/styles/app-react.css +++ b/lumen/src/styles/app-react.css @@ -303,6 +303,12 @@ } .rq .rqa .btn { min-height: 34px; + /* a long label ("Watch on Plex") wraps inside a narrow card instead of running past its edge */ + max-width: 100%; + min-width: 0; + white-space: normal; + text-align: center; + line-height: 1.2; } /* extra categories (music, comics, magazines, games, sports, adult) */ diff --git a/lumen/src/styles/app.css b/lumen/src/styles/app.css index aad21ec..c049777 100644 --- a/lumen/src/styles/app.css +++ b/lumen/src/styles/app.css @@ -226,6 +226,14 @@ body.is-detail .tabbar { .seg.wide { display: flex; } +/* four tabs (Active, Ready, All, Everyone) plus counts must fit a 360px phone without scrolling */ +@media (max-width: 400px) { + .seg button, + .seg a { + padding: 0 9px; + gap: 4px; + } +} /* ---------- Toolbar (sort + filters) ---------- */ .toolbar { @@ -481,7 +489,8 @@ body.is-detail .tabbar { } @media (hover: hover) and (pointer: fine) { .poster:hover .qa, - .poster:focus-within .qa { + .poster:focus-within .qa, + .poster.hold .qa { display: flex; } .poster:hover .art .glyph { @@ -968,12 +977,17 @@ body.is-detail .tabbar { } .actionbar .btn { flex: 1; + min-width: 0; + white-space: normal; + line-height: 1.15; + text-align: center; } .actionbar .ab-meta { font-size: 12px; color: var(--ink-3); line-height: 1.3; flex: 0 1 auto; + min-width: 28%; max-width: 42%; } .actionbar .ab-meta b { @@ -1370,6 +1384,14 @@ body.is-detail .tabbar { font-size: 13px; color: var(--ink-2); } +/* a long status ("Different edition in library") wraps instead of leaving the row */ +.rq .st .pill { + max-width: 100%; + height: auto; + min-height: 24px; + padding-block: 3px; + white-space: normal; +} .rq .rqa { position: relative; z-index: 2; @@ -1456,9 +1478,13 @@ body.is-detail .tabbar { .si .tt { font-weight: 700; font-size: 15px; - white-space: nowrap; + line-height: 1.25; overflow: hidden; - text-overflow: ellipsis; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; } .si .tt a::after { content: ''; @@ -1723,6 +1749,21 @@ body.is-detail .toast { display: grid; gap: 6px; } +/* short landscape screens: a pinned Request/Cancel footer would cover the options and push Cancel + off the screen, so the whole sheet scrolls as one piece and the two buttons share a row */ +@media (max-height: 480px) { + .sheet .pk { + gap: 10px; + } + .sheet .pk-foot { + position: static; + padding-top: 0; + background: none; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: 8px; + } +} .sheet .pk .close { background: var(--surface-2); color: var(--ink); @@ -2080,3 +2121,65 @@ a.brand { display: none; } } + +/* Phones turned sideways: a floating bottom bar would eat a quarter of a 360px-tall screen and sit + over the content, so the tabs become a slim rail down the left edge and the page makes room for it. + Only when the screen is wide enough to spare the rail: a short, narrow screen (360x288) is + landscape by shape but keeps the bottom bar, or the content would be left under 300px wide */ +@media (min-width: 560px) and (max-width: 1039px) and (max-height: 500px) and (orientation: landscape) { + :root { + --rail: calc(76px + env(safe-area-inset-left)); + } + .tabbar { + top: 0; + bottom: 0; + left: 0; + width: var(--rail); + transform: none; + flex-direction: column; + justify-content: center; + gap: 0; + padding: 4px 4px 4px calc(4px + env(safe-area-inset-left)); + border-radius: 0; + border-width: 0 1px 0 0; + overflow-y: auto; + } + .tab { + flex: 0 1 auto; + padding: 5px 0 3px; + min-height: 44px; + align-content: center; + } + body.is-detail .tabbar { + transform: translateX(-110%); + } + .view { + padding-left: calc(var(--rail) + var(--gutter)); + padding-right: calc(var(--gutter) + env(safe-area-inset-right)); + padding-bottom: calc(32px + env(safe-area-inset-bottom)); + } + body.is-detail .view { + padding-left: calc(var(--gutter) + env(safe-area-inset-left)); + } + .detail { + margin-left: calc(-1 * var(--gutter) - env(safe-area-inset-left)); + margin-right: calc(-1 * var(--gutter) - env(safe-area-inset-right)); + } + .toast, + body.is-detail .toast { + bottom: calc(16px + env(safe-area-inset-bottom)); + } + .dhero { + height: 240px; + } + .dtop { + margin-top: -120px; + } + .dback { + left: calc(12px + env(safe-area-inset-left)); + } + .actionbar { + padding-left: calc(16px + env(safe-area-inset-left)); + padding-right: calc(16px + env(safe-area-inset-right)); + } +} diff --git a/lumen/src/styles/touch.css b/lumen/src/styles/touch.css index af5003f..034288a 100644 --- a/lumen/src/styles/touch.css +++ b/lumen/src/styles/touch.css @@ -242,6 +242,11 @@ html.no-overscroll body { .set a.go { min-height: 44px; } + /* a one-word button ("All") is still a full-size target */ + .seg button, + .seg a { + min-width: 44px; + } } /* second pass: the remaining controls a real phone measured under 44px */ @@ -275,3 +280,28 @@ html.no-overscroll body { margin-block: -12px; } } + +/* the whole search bar is the tap target: the field fills its height instead of a 25px strip */ +@media (pointer: coarse) { + .search:has(input) { + padding-right: 0; + } + .search input { + align-self: stretch; + padding-right: 14px; + } +} + +/* the "Let people change it" box was 13px: a bigger box inside a full-height label */ +@media (pointer: coarse) { + .cfg-lock { + gap: 10px; + min-width: 44px; + } + .cfg-lock input { + width: 24px; + height: 24px; + margin: 0; + flex: none; + } +} diff --git a/lumen/tests/browser/app.spec.ts b/lumen/tests/browser/app.spec.ts index bf48c57..ef45a9e 100644 --- a/lumen/tests/browser/app.spec.ts +++ b/lumen/tests/browser/app.spec.ts @@ -709,7 +709,16 @@ test('a category page says when SeerrNG’s settings fail and retries', async ({ ? route.fulfill({ status: 503, json: { message: 'Starting up' } }) : route.fallback() ); - await page.goto('/music'); + // the app reads the settings once when it opens and reuses that answer; a day later the saved + // copy has expired, so opening Music asks again, and that read fails + await page.clock.install(); + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Discover' })).toBeVisible(); + await page.clock.fastForward(25 * 60 * 60 * 1000); + await page.evaluate(() => { + history.pushState({}, '', '/music'); + dispatchEvent(new PopStateEvent('popstate')); + }); const alert = page.getByRole('alert'); await expect(alert).toContainText('Couldn’t load this page'); fail = false; diff --git a/lumen/tests/browser/book-description.spec.ts b/lumen/tests/browser/book-description.spec.ts new file mode 100644 index 0000000..ea1000a --- /dev/null +++ b/lumen/tests/browser/book-description.spec.ts @@ -0,0 +1,92 @@ +import { expect, test, type Page } from '@playwright/test'; + +// base64url of the bare Hardcover work id "260468" and of "hc:260468" +const bareId = 'bookshelf:1:MjYwNDY4'; +const hcId = 'bookshelf:1:aGM6MjYwNDY4'; +const blurb = 'In a ruined and toxic world, people live in a silo.'; + +const book = (id: string, description?: string) => ({ + id, + mediaType: 'book', + bookFormat: 'ebook', + title: 'Wool', + author: 'Hugh Howey', + posterPath: null, + ...(description ? { description } : {}), +}); + +async function mocks(page: Page, bare: unknown, hc: unknown) { + const lookups: string[] = []; + await page.route('https://**/*', (r) => r.abort()); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/seerr-classic/**', (r) => + r.fulfill({ status: 404, body: '' }) + ); + await page.route('**/api/v1/**', (route) => { + const path = new URL(route.request().url()).pathname.slice(7); + if (path === '/auth/me') + return route.fulfill({ + json: { id: 1, displayName: 'viewer', permissions: 2 }, + }); + if (path === '/settings/public') + return route.fulfill({ + json: { + mediaServerType: 2, + booksEnabled: true, + ebookServiceEnabled: true, + audiobookServiceEnabled: true, + enabledMediaCategories: { ebook: true, audiobook: true }, + }, + }); + if (path.startsWith('/book/bookshelf')) { + lookups.push(decodeURIComponent(path.slice(6))); + const body = path.endsWith(encodeURIComponent(hcId)) ? hc : bare; + return body + ? route.fulfill({ json: body }) + : route.fulfill({ status: 404, json: { message: 'nope' } }); + } + return route.fulfill({ json: {} }); + }); + return lookups; +} + +test('a book stored under a bare Hardcover id gets its description from the hc: form', async ({ + page, +}) => { + const lookups = await mocks(page, book(bareId), book(hcId, blurb)); + await page.goto(`/book/${encodeURIComponent(bareId)}`); + await expect(page.locator('h1')).toContainText('Wool'); + await expect(page.getByText(blurb)).toBeVisible(); + // exactly one extra lookup, and the page keeps addressing the original id + expect(lookups.sort()).toEqual([bareId, hcId].sort()); + await expect(page).toHaveURL(new RegExp(encodeURIComponent(bareId))); +}); + +test('a book that already has a description makes no second lookup', async ({ + page, +}) => { + const lookups = await mocks( + page, + book(bareId, 'Own blurb.'), + book(hcId, blurb) + ); + await page.goto(`/book/${encodeURIComponent(bareId)}`); + await expect(page.getByText('Own blurb.')).toBeVisible(); + await expect(page.getByText(blurb)).toHaveCount(0); + expect(lookups).toEqual([bareId]); +}); + +test('an hc: id or a failing second lookup does not retry or break the page', async ({ + page, +}) => { + const lookups = await mocks(page, book(bareId), null); + await page.goto(`/book/${encodeURIComponent(bareId)}`); + await expect(page.locator('h1')).toContainText('Wool'); + await expect(page.locator('p.ov.long')).toHaveCount(0); + expect(lookups.filter((l) => l === hcId)).toHaveLength(1); + + const again = await mocks(page, book(hcId), book(hcId)); + await page.goto(`/book/${encodeURIComponent(hcId)}`); + await expect(page.locator('h1')).toContainText('Wool'); + expect(again).toEqual([hcId]); +}); diff --git a/lumen/tests/browser/coming-soon-titles.spec.ts b/lumen/tests/browser/coming-soon-titles.spec.ts new file mode 100644 index 0000000..4f7b401 --- /dev/null +++ b/lumen/tests/browser/coming-soon-titles.spec.ts @@ -0,0 +1,68 @@ +import { expect, test, type Page } from '@playwright/test'; + +const paged = (results: unknown[]) => ({ + page: 1, + totalPages: 1, + totalResults: results.length, + results, +}); + +async function fixtures(page: Page) { + const upcoming = Array.from({ length: 6 }, (_, i) => ({ + id: 900 + i, + title: `Victorian Psychosis Stories ${i + 1}`, + mediaType: 'movie', + releaseDate: '2027-03-15', + })); + await page.route('**/lumen-config.json', (route) => + route.fulfill({ json: {} }) + ); + await page.route('**/api/v1/**', (route) => { + const path = new URL(route.request().url()).pathname.slice( + '/api/v1'.length + ); + if (path === '/auth/me') + return route.fulfill({ + json: { id: 1, displayName: 'test', permissions: 2 }, + }); + if (path === '/settings/public') + return route.fulfill({ + json: { applicationTitle: 'SeerrNG', movie4kEnabled: false }, + }); + return route.fulfill({ + json: paged(path === '/discover/movies/upcoming' ? upcoming : []), + }); + }); +} + +test('Coming soon titles wrap to two lines instead of cutting to a few letters', async ({ + page, +}, info) => { + test.skip(info.project.name !== 'desktop', 'sets its own viewport'); + await fixtures(page); + await page.goto('/soon'); + await expect(page.locator('.si')).toHaveCount(6); + for (const width of [1320, 1366, 1440, 1520, 1599]) { + await page.setViewportSize({ width, height: 900 }); + const bad = await page + .locator('.sl') + .first() + .evaluate((grid) => { + const problems: string[] = []; + for (const card of Array.from( + grid.querySelectorAll('.si') + )) { + const tt = card.querySelector('.tt')!; + const line = parseFloat(getComputedStyle(tt).lineHeight); + if (tt.clientWidth < 100) + problems.push(`title only ${tt.clientWidth}px wide`); + if (tt.scrollWidth > tt.clientWidth + 1) + problems.push(`title cut sideways (${tt.clientWidth}px wide)`); + if (tt.clientHeight > line * 2 + 1) + problems.push('title over 2 lines'); + } + return problems; + }); + expect(bad, `at ${width}px`).toEqual([]); + } +}); diff --git a/lumen/tests/browser/followups.spec.ts b/lumen/tests/browser/followups.spec.ts new file mode 100644 index 0000000..a265f20 --- /dev/null +++ b/lumen/tests/browser/followups.spec.ts @@ -0,0 +1,175 @@ +import { expect, test, type Page } from '@playwright/test'; + +// Follow-ups to the audit pass: sideways phones, search tap target, settings checkbox, and +// one request per page load for the public settings and the signed-in user. +async function fixtures(page: Page, counts: Record = {}) { + await page.route('https://**/*', (r) => r.abort()); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/seerr-classic/**', (r) => r.fulfill({ status: 404 })); + await page.route('**/api/v1/**', async (route) => { + const path = new URL(route.request().url()).pathname.slice(7); + counts[path] = (counts[path] || 0) + 1; + if (path === '/settings/public') + return route.fulfill({ + json: { mediaServerType: 1, localLogin: true, booksEnabled: true }, + }); + if (path === '/auth/me') + return route.fulfill({ + json: { id: 1, displayName: 'fam', permissions: 2 }, + }); + if ( + path.startsWith('/discover/genreslider') || + path.startsWith('/service/') + ) + return route.fulfill({ json: [] }); + return route.fulfill({ + json: { page: 1, totalPages: 1, totalResults: 0, results: [] }, + }); + }); +} + +type Box = { x: number; y: number; width: number; height: number }; +const overlap = (a: Box, b: Box) => + a.x < b.x + b.width && + b.x < a.x + a.width && + a.y < b.y + b.height && + b.y < a.y + a.height; + +// sideways phones: the tabs sit in a rail beside the page, never over the shortcut chips or the +// first content, and the rail fits inside the short screen +for (const [width, height] of [ + [640, 360], + [915, 412], +]) { + test(`landscape phone ${width}x${height}: tabs do not cover Discover`, async ({ + browser, + }) => { + const context = await browser.newContext({ + viewport: { width, height }, + isMobile: true, + hasTouch: true, + }); + const page = await context.newPage(); + await fixtures(page); + await page.goto('/'); + const tabs = page.getByRole('navigation', { name: 'Tabs' }); + const chips = page.locator('.navchips'); + await expect(chips).toBeVisible(); + const box = async (l: typeof chips) => (await l.boundingBox())!; + for (const y of [0, 99999]) { + await page.evaluate((top) => window.scrollTo(0, top), y); + const t = await box(tabs); + expect(t.y).toBeGreaterThanOrEqual(0); + expect(t.y + t.height).toBeLessThanOrEqual(height); + expect(t.x + t.width).toBeLessThanOrEqual(width / 3); + const first = await box(page.locator('.view > *').first()); + expect(overlap(t, first)).toBe(false); + for (const tab of await tabs.locator('.tab').all()) + expect((await box(tab)).height).toBeGreaterThanOrEqual(44); + } + await page.evaluate(() => window.scrollTo(0, 0)); + expect(overlap(await box(tabs), await box(chips))).toBe(false); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ).toBe(true); + await context.close(); + }); +} + +test('the search field fills its bar so a tap anywhere in it types', async ({ + page, +}) => { + test.skip(page.viewportSize()!.width >= 1040, 'phone and tablet search box'); + await fixtures(page); + await page.goto('/search'); + const bar = (await page.locator('#sq-label').boundingBox())!; + const field = (await page.locator('#sq').boundingBox())!; + expect(bar.height).toBeGreaterThanOrEqual(44); + expect(field.height).toBeGreaterThanOrEqual(bar.height - 1); + expect(field.x + field.width).toBeGreaterThanOrEqual(bar.x + bar.width - 1); + // a tap near the bar edge, outside the old 25px strip, still focuses the field + await page.locator('#sq').blur(); + await page.touchscreen.tap(bar.x + bar.width / 2, bar.y + 3); + await expect(page.locator('#sq')).toBeFocused(); +}); + +test('the Lumen settings checkbox has a 44px touch target and stays keyboard friendly', async ({ + page, +}) => { + await fixtures(page); + await page.goto('/me/lumen'); + const lock = page.locator('.cfg-lock').first(); + await expect(lock).toBeVisible(); + const input = lock.locator('input'); + const coarse = await page.evaluate( + () => matchMedia('(pointer: coarse)').matches + ); + if (coarse) { + await lock.scrollIntoViewIfNeeded(); + const label = (await lock.boundingBox())!; + const mark = (await input.boundingBox())!; + expect(label.height).toBeGreaterThanOrEqual(44); + expect(label.width).toBeGreaterThanOrEqual(44); + expect(mark.width).toBeGreaterThanOrEqual(22); + // the very left edge of the control, beside the box, still toggles it + const before = await input.isChecked(); + await page.touchscreen.tap(label.x + 1, label.y + label.height / 2); + expect(await input.isChecked()).toBe(!before); + } + await input.focus(); + const was = await input.isChecked(); + await page.keyboard.press('Space'); + expect(await input.isChecked()).toBe(!was); +}); + +test('a cold load asks for the public settings and the signed-in user once each', async ({ + page, +}) => { + const counts: Record = {}; + await fixtures(page, counts); + await page.goto('/'); + await expect(page.locator('.navchips')).toBeVisible(); + // let late rows and rechecks settle before counting + await page.waitForTimeout(1500); + expect(counts).toMatchObject({ '/settings/public': 1, '/auth/me': 1 }); +}); + +test('signing in does not ask for the public settings again', async ({ + page, +}) => { + const counts: Record = {}; + await fixtures(page); + let signedIn = false; + await page.route('**/api/v1/auth/me', (route) => { + counts['/auth/me'] = (counts['/auth/me'] || 0) + 1; + return route.fulfill( + signedIn + ? { json: { id: 1, displayName: 'fam', permissions: 2 } } + : { status: 401, json: { message: 'NO_SESSION' } } + ); + }); + await page.route('**/lumen-config.json', (route) => + route.fulfill({ json: { loginStyle: 'form' } }) + ); + await page.route('**/api/v1/settings/public', (route) => { + counts['/settings/public'] = (counts['/settings/public'] || 0) + 1; + return route.fulfill({ + json: { mediaServerType: 2, localLogin: true, booksEnabled: true }, + }); + }); + await page.route('**/api/v1/auth/jellyfin', (route) => { + signedIn = true; + return route.fulfill({ json: { id: 1, displayName: 'fam' } }); + }); + await page.goto('/'); + await page.getByLabel('Username', { exact: true }).fill('fam'); + await page.getByLabel('Password').fill('secret'); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await expect(page.locator('.navchips')).toBeVisible(); + await page.waitForTimeout(1000); + expect(counts['/settings/public']).toBe(1); + // once to find out nobody is signed in, once after signing in + expect(counts['/auth/me']).toBe(2); +}); diff --git a/lumen/tests/browser/narrations.spec.ts b/lumen/tests/browser/narrations.spec.ts new file mode 100644 index 0000000..5697256 --- /dev/null +++ b/lumen/tests/browser/narrations.spec.ts @@ -0,0 +1,360 @@ +import { expect, test, type Page } from '@playwright/test'; + +// Narration lists built from SeerrNG's audiobook editions. SeerrNG also lists catalogue copies +// it can't pin a request to; those come with an empty id ("requestable": false), several per +// narrator. Shapes below follow the live response for Wool (Hardcover work 260468). +const woolId = 'bookshelf:1:aGM6MjYwNDY4'; +const ed = ( + id: string, + narrators: string[], + extra: Record = {} +) => ({ + id, + requestable: !!id, + title: 'Wool', + format: 'audiobook', + language: 'eng', + narrators, + durationSeconds: 0, + publisher: '', + selected: false, + owned: false, + ...extra, +}); +const woolEditions = [ + ed('31877632', ['Edoardo Ballerini'], { selected: true, owned: true }), + ed('31747196', ['Susannah Harker'], { durationSeconds: 60000 }), + ed('', ['Amanda Sayle'], { publisher: 'Audible Studios' }), + ed('33383711', ['Amanda Sayle'], { publisher: 'Brilliance Audio' }), + ed('', ['Susannah Harker']), + ed('33383714', []), + ed('', ['Edoardo Ballerini'], { durationSeconds: 54000 }), + ed('33113576', ['Minnie Goode']), + ed('', ['Susannah Harper']), + ed('', ['Minnie Goode'], { publisher: 'Broad Reach Publishing' }), + ed('', ['Minnie Goode'], { durationSeconds: 64380 }), +]; +const cEd = (id: number, foreignEditionId: string, narrator: string) => ({ + id, + bookId: 329, + foreignEditionId, + narratorNames: narrator ? [narrator] : [], + narrator, + language: 'eng', + format: 'audiobook', +}); +// ChaptarrNG: Edoardo Ballerini downloaded, Susannah Harker monitored (requested) +const woolLookup = [ + { + title: 'Wool', + editions: [ + cEd(3334, '31877632', 'Edoardo Ballerini'), + cEd(3336, '31747196', 'Susannah Harker'), + cEd(3338, '33113576', 'Minnie Goode'), + cEd(3344, '33383711', 'Amanda Sayle'), + cEd(3350, '33383714', ''), + ], + localAudiobookBooks: [ + { + id: 329, + title: 'Wool', + narrator: 'Edoardo Ballerini', + monitored: true, + hasFiles: true, + }, + { + id: 2833, + title: 'Wool', + narrator: 'Susannah Harker', + monitored: true, + hasFiles: false, + }, + ], + }, +]; + +type Sent = { url: string; body: unknown }; +async function fixtures( + page: Page, + editions: unknown[], + lookup: unknown | null +): Promise { + const sent: Sent[] = []; + await page.route('https://**/*', (r) => r.abort()); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/seerr-classic/**', (route) => { + const req = route.request(); + if (req.method() === 'POST') { + sent.push({ url: req.url(), body: req.postDataJSON() }); + return route.fulfill({ + json: { ok: true, bookId: 329, narrators: 'x', alreadyTracked: false }, + }); + } + if (lookup && req.url().includes('/seerr-classic/lookup')) + return route.fulfill({ json: lookup }); + return route.fulfill({ status: 404 }); + }); + await page.route('**/api/v1/**', async (route) => { + const req = route.request(); + const path = new URL(req.url()).pathname.slice(7); + if (req.method() === 'POST') { + sent.push({ url: req.url(), body: req.postDataJSON() }); + return route.fulfill({ json: { id: 99 } }); + } + if (path === '/settings/public') + return route.fulfill({ + json: { + mediaServerType: 1, + localLogin: true, + booksEnabled: true, + ebookServiceEnabled: true, + audiobookServiceEnabled: true, + enabledMediaCategories: { ebook: true, audiobook: true }, + }, + }); + if (path === '/auth/me') + return route.fulfill({ + json: { id: 1, displayName: 'fam', permissions: 2 }, + }); + if (path === '/editions/book') return route.fulfill({ json: { editions } }); + if (path.startsWith('/book/')) + return route.fulfill({ + json: { + id: woolId, + mediaType: 'book', + bookFormat: 'audiobook', + title: 'Wool', + author: 'Hugh Howey', + description: 'A silo story.', + posterPath: '/cover.jpg', + }, + }); + if (path.startsWith('/service/') || path.startsWith('/genres/')) + return route.fulfill({ json: [] }); + return route.fulfill({ + json: { page: 1, totalPages: 1, totalResults: 0, results: [] }, + }); + }); + return sent; +} + +const radios = (page: Page) => + page.getByRole('radiogroup', { name: 'Narrations' }).getByRole('radio'); +const row = (page: Page, name: string) => + radios(page).filter({ hasText: name }); +// the page's request button, or the sticky action bar's copy of it on small screens +const requestButton = (page: Page) => + page + .getByRole('button', { name: /^Request/ }) + .filter({ visible: true }) + .first(); + +test('another narrator: one selection at a time, and the click moves it', async ({ + page, +}) => { + const sent = await fixtures(page, woolEditions, woolLookup); + await page.goto(`/book/${encodeURIComponent(woolId)}?f=audio`); + await expect(row(page, 'Edoardo Ballerini')).toContainText('In library'); + await expect(row(page, 'Susannah Harker')).toContainText('Requested'); + // nothing picked yet: no row looks selected + await expect( + radios(page).and(page.locator('[aria-checked="true"]')) + ).toHaveCount(0); + + await row(page, 'Amanda Sayle').click(); + await expect(row(page, 'Amanda Sayle')).toHaveAttribute( + 'aria-checked', + 'true' + ); + await expect(row(page, 'Minnie Goode')).toHaveAttribute( + 'aria-checked', + 'false' + ); + const button = requestButton(page); + await expect(button).toHaveText(/Request another narrator · Amanda Sayle/); + + await row(page, 'Minnie Goode').click(); + await expect(row(page, 'Minnie Goode')).toHaveAttribute( + 'aria-checked', + 'true' + ); + await expect(row(page, 'Amanda Sayle')).toHaveAttribute( + 'aria-checked', + 'false' + ); + await expect(button).toHaveText(/Request another narrator · Minnie Goode/); + + // keyboard too + await row(page, 'Amanda Sayle').focus(); + await page.keyboard.press('Space'); + await expect(row(page, 'Amanda Sayle')).toHaveAttribute( + 'aria-checked', + 'true' + ); + await expect( + radios(page).and(page.locator('[aria-checked="true"]')) + ).toHaveCount(1); + + // owned, requested and unrequestable rows stay put + for (const name of [ + 'Edoardo Ballerini', + 'Susannah Harker', + 'Susannah Harper', + ]) + await expect(row(page, name)).toHaveAttribute('aria-disabled', 'true'); + + // the request names Minnie Goode's ChaptarrNG edition + await row(page, 'Minnie Goode').click(); + await button.click(); + await expect.poll(() => sent.length).toBe(1); + expect(sent[0].url).toContain('/seerr-classic/narration'); + expect(sent[0].body).toEqual({ bookId: 329, editionId: 3338 }); +}); + +test('first request: rows SeerrNG can pin are selectable and send their edition id', async ({ + page, +}) => { + // nothing in the library yet (no companion): every row with an edition id is requestable + const editions = woolEditions.map((e) => ({ ...e, owned: false })); + const sent = await fixtures(page, editions, null); + await page.goto(`/book/${encodeURIComponent(woolId)}?f=audio`); + await expect( + page.getByRole('heading', { name: 'Choose a narrator' }) + ).toBeVisible(); + // the recommended copy starts selected, and only that one + await expect(row(page, 'Edoardo Ballerini')).toHaveAttribute( + 'aria-checked', + 'true' + ); + await expect( + radios(page).and(page.locator('[aria-checked="true"]')) + ).toHaveCount(1); + // Susannah Harper is only listed without an edition id: it can't be pinned + await expect(row(page, 'Susannah Harper')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + await row(page, 'Susannah Harper').click({ force: true }); + await expect(row(page, 'Susannah Harper')).toHaveAttribute( + 'aria-checked', + 'false' + ); + + for (const [name, id] of [ + ['Amanda Sayle', '33383711'], + ['Minnie Goode', '33113576'], + ] as const) { + await row(page, name).click(); + await expect( + radios(page).and(page.locator('[aria-checked="true"]')) + ).toHaveCount(1); + await expect(row(page, name)).toHaveAttribute('aria-checked', 'true'); + await expect(requestButton(page)).toHaveText( + new RegExp(`Request · ${name}`) + ); + } + await requestButton(page).click(); + await expect.poll(() => sent.length).toBe(1); + expect(sent[0].body).toMatchObject({ + mediaType: 'book', + format: 'audiobook', + preferredEditionId: '33113576', + editionPolicy: 'only', + }); +}); + +test('a full cast listed without an edition id does not share a selection with a narrator', async ({ + page, +}) => { + const cast = [ + 'Adeel Akhtar', + 'Arabella Stanton', + 'Cush Jumbo', + 'Frankie Treadaway', + 'Hugh Laurie', + ]; + const editions = [ + ed('32850979', ['Stephen Fry'], { selected: true, durationSeconds: 30300 }), + ed('', cast, { durationSeconds: 31260 }), + ed('', ['Ann Saunders']), + ed('32752239', cast), + ed('32063555', ['Jim Dale'], { durationSeconds: 29880 }), + ]; + const sent = await fixtures(page, editions, null); + await page.goto(`/book/${encodeURIComponent(woolId)}?f=audio`); + const castRow = row(page, 'Full cast'); + await castRow.click(); + await expect(castRow).toHaveAttribute('aria-checked', 'true'); + await expect(row(page, 'Ann Saunders')).toHaveAttribute( + 'aria-checked', + 'false' + ); + await expect(row(page, 'Ann Saunders')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + await expect( + radios(page).and(page.locator('[aria-checked="true"]')) + ).toHaveCount(1); + await expect(requestButton(page)).toHaveText(/Adeel Akhtar/); + await requestButton(page).click(); + await expect.poll(() => sent.length).toBe(1); + expect(sent[0].body).toMatchObject({ preferredEditionId: '32752239' }); +}); + +test('"Any English narrator" still names an edition SeerrNG can pin', async ({ + page, +}) => { + // no recommended copy; the only one with a length is listed without an edition id + const editions = [ + ed('', ['Minnie Goode'], { durationSeconds: 64380 }), + ed('33113576', ['Minnie Goode']), + ed('33383711', ['Amanda Sayle']), + ]; + const sent = await fixtures(page, editions, null); + await page.goto(`/book/${encodeURIComponent(woolId)}?f=audio`); + await row(page, 'Any English narrator').click(); + await expect(row(page, 'Any English narrator')).toHaveAttribute( + 'aria-checked', + 'true' + ); + await expect(requestButton(page)).toHaveText(/Request audiobook/); + await requestButton(page).click(); + await expect.poll(() => sent.length).toBe(1); + expect(sent[0].body).toMatchObject({ + format: 'audiobook', + preferredEditionId: '33113576', + editionPolicy: 'either', + }); +}); + +test('a recommended recording SeerrNG can only list without an id is not offered', async ({ + page, +}) => { + // live shape of Cheshire Crossing: one full-cast listing, recommended, no edition id + const cast = [ + 'Brittany Pressley', + 'James Monroe Iglehart', + 'Kristen DiMercurio', + 'Ann Marie Gideon', + ]; + const sent = await fixtures( + page, + [ed('', cast, { selected: true, durationSeconds: 5880 })], + null + ); + await page.goto(`/book/${encodeURIComponent(woolId)}?f=audio`); + const castRow = row(page, 'Full cast'); + await expect(castRow).toHaveAttribute('aria-disabled', 'true'); + await expect(castRow).toContainText('Can’t be requested yet'); + await expect(castRow).not.toContainText('Recommended'); + await expect(row(page, 'Any English narrator')).toHaveAttribute( + 'aria-checked', + 'true' + ); + await expect(requestButton(page)).toHaveText(/Request audiobook/); + await requestButton(page).click(); + await expect.poll(() => sent.length).toBe(1); + expect(sent[0].body).toMatchObject({ format: 'audiobook' }); + expect(sent[0].body).not.toHaveProperty('editionPolicy', 'only'); +}); diff --git a/lumen/tests/browser/narrow-short-nav.spec.ts b/lumen/tests/browser/narrow-short-nav.spec.ts new file mode 100644 index 0000000..edb662d --- /dev/null +++ b/lumen/tests/browser/narrow-short-nav.spec.ts @@ -0,0 +1,94 @@ +import { expect, test, type Page } from '@playwright/test'; + +// A short, narrow screen (360x288) is landscape by shape, but there is no room to give a third of +// its width to a side rail: it keeps the bottom tab bar and its pages must not scroll sideways. +async function fixture(page: Page) { + await page.route('https://**/*', (r) => r.abort()); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/seerr-classic/**', (r) => r.fulfill({ status: 404 })); + await page.route('**/api/v1/**', (r) => { + const url = new URL(r.request().url()); + const path = url.pathname.slice(7); + if (path === '/auth/me') + return r.fulfill({ + json: { id: 1, displayName: 'reader', permissions: 2 }, + }); + if (path === '/settings/public') + return r.fulfill({ json: { mediaServerType: 1 } }); + if (path.startsWith('/movie/')) + return r.fulfill({ json: { id: 603, title: 'Ready movie' } }); + if (path === '/discover/movies/upcoming') + return r.fulfill({ + json: { + page: 1, + totalPages: 1, + totalResults: 2, + results: [1, 2].map((i) => ({ + id: 900 + i, + title: `Victorian Psychosis Stories ${i}`, + mediaType: 'movie', + releaseDate: '2027-03-15', + })), + }, + }); + if (path === '/request') + return r.fulfill({ + json: { + pageInfo: { pages: 1, results: 1 }, + results: [ + { + id: 1, + type: 'movie', + status: 2, + is4k: false, + createdAt: '2026-11-08T12:00:00Z', + requestedBy: { id: 1, displayName: 'reader' }, + media: { + mediaType: 'movie', + tmdbId: 603, + status: 5, + mediaUrl: 'https://plex.example/web/index.html#!/server/x', + }, + }, + ], + }, + }); + return r.fulfill({ + json: { page: 1, totalPages: 1, totalResults: 0, results: [] }, + }); + }); +} + +test('a short narrow screen keeps the bottom tab bar and never scrolls sideways', async ({ + page, +}, info) => { + test.skip(info.project.name !== 'desktop', 'sets its own viewport'); + await fixture(page); + await page.setViewportSize({ width: 360, height: 288 }); + for (const path of ['/requests?tab=all', '/soon', '/me/lumen']) { + await page.goto(path); + await expect(page.locator('.view')).toBeVisible(); + await page.waitForTimeout(400); + const m = await page.evaluate(() => { + const bar = document.querySelector('.tabbar')!.getBoundingClientRect(); + return { + barIsRow: bar.width > bar.height, + scroll: document.documentElement.scrollWidth, + client: document.documentElement.clientWidth, + content: document.querySelector('.view')!.getBoundingClientRect().left, + }; + }); + expect(m.barIsRow, `${path}: tab bar should be along the bottom`).toBe( + true + ); + expect(m.scroll, `${path}: page wider than the screen`).toBeLessThanOrEqual( + m.client + ); + expect(m.content, `${path}: no rail gutter`).toBeLessThan(40); + } + // a wide short phone still gets the side rail + await page.setViewportSize({ width: 740, height: 360 }); + await page.goto('/soon'); + const rail = await page.locator('.tabbar').boundingBox(); + expect(rail!.height).toBeGreaterThan(rail!.width); +}); diff --git a/lumen/tests/browser/quick-focus.spec.ts b/lumen/tests/browser/quick-focus.spec.ts new file mode 100644 index 0000000..8e388df --- /dev/null +++ b/lumen/tests/browser/quick-focus.spec.ts @@ -0,0 +1,75 @@ +import { expect, test, type Page } from '@playwright/test'; + +// On desktop the poster's Request button lives in a hover/focus overlay. When the quick-request +// sheet closes (Escape or Cancel) keyboard focus has to come back to that button. +const movie = (id: number, title: string) => ({ + id, + title, + mediaType: 'movie', + releaseDate: '2020-01-01', + posterPath: `/p${id}.png`, + voteAverage: 7, +}); +const paged = (results: unknown[]) => ({ + page: 1, + totalPages: 1, + totalResults: results.length, + results, +}); + +async function fixture(page: Page) { + await page.route('https://**/*', (r) => r.abort()); + await page.route(/\/(imageproxy|branding)\//, (r) => + r.fulfill({ + contentType: 'image/svg+xml', + body: '', + }) + ); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/api/v1/**', (route) => { + const path = new URL(route.request().url()).pathname.slice(7); + let json: unknown = {}; + if (path === '/settings/public') + json = { mediaServerType: 2, movie4kEnabled: true }; + else if (path === '/auth/me') + json = { id: 1, displayName: 'fam', permissions: 32 | 1024 }; + else if (path === '/discover/movies' || path === '/discover/trending') + json = paged([movie(302, 'Fresh Pick')]); + else if (path === '/discover/genreslider/movie') json = []; + else if (/^\/discover\//.test(path) || path === '/media') json = paged([]); + else if (path === '/request') + json = { pageInfo: { pages: 1, results: 0 }, results: [] }; + else if (/^\/user\/\d+\/quota$/.test(path)) json = {}; + return route.fulfill({ json }); + }); +} + +for (const how of ['Escape', 'Cancel'] as const) { + test(`${how} on the quick-request sheet returns focus to the poster's Request button`, async ({ + page, + }, info) => { + test.skip( + info.project.name !== 'desktop', + 'the hover overlay is desktop only' + ); + await fixture(page); + await page.goto('/'); + const poster = page.locator('.poster', { hasText: 'Fresh Pick' }).first(); + await poster.scrollIntoViewIfNeeded(); + const trigger = poster.locator('.qbtn.go'); + await poster.hover(); + await trigger.focus(); + await page.mouse.move(2, 2); + await expect(trigger).toBeFocused(); + await page.keyboard.press('Enter'); + const sheet = page.getByRole('dialog', { name: 'Request Fresh Pick' }); + await expect(sheet).toBeVisible(); + // the pointer is somewhere else, so only focus keeps the overlay shown + await page.mouse.move(2, 2); + if (how === 'Escape') await page.keyboard.press('Escape'); + else await sheet.getByRole('button', { name: 'Cancel' }).click(); + await expect(sheet).toHaveCount(0); + await page.mouse.move(2, 2); + await expect(trigger).toBeFocused(); + }); +} diff --git a/lumen/tests/browser/quick-sheet-short.spec.ts b/lumen/tests/browser/quick-sheet-short.spec.ts new file mode 100644 index 0000000..2dd28cb --- /dev/null +++ b/lumen/tests/browser/quick-sheet-short.spec.ts @@ -0,0 +1,94 @@ +import { expect, test, type Page } from '@playwright/test'; + +// On a short landscape screen the quick-request sheet's pinned Request/Cancel footer used to cover +// the options and push Cancel off the screen. The sheet now scrolls as one piece. +const movie = { + id: 302, + title: 'Fresh Pick', + mediaType: 'movie', + releaseDate: '2020-01-01', + posterPath: '/p302.png', + voteAverage: 7, +}; +const paged = (results: unknown[]) => ({ + page: 1, + totalPages: 1, + totalResults: results.length, + results, +}); + +async function fixture(page: Page) { + await page.route('https://**/*', (r) => r.abort()); + await page.route(/\/(imageproxy|branding)\//, (r) => + r.fulfill({ + contentType: 'image/svg+xml', + body: '', + }) + ); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/api/v1/**', (route) => { + const path = new URL(route.request().url()).pathname.slice(7); + let json: unknown = {}; + if (path === '/settings/public') + json = { mediaServerType: 2, movie4kEnabled: true }; + else if (path === '/auth/me') + json = { id: 1, displayName: 'fam', permissions: 32 | 1024 }; + else if (path === '/discover/movies' || path === '/discover/trending') + json = paged([movie]); + else if (path === '/discover/genreslider/movie') json = []; + else if (/^\/discover\//.test(path) || path === '/media') json = paged([]); + else if (path === '/request') + json = { pageInfo: { pages: 1, results: 0 }, results: [] }; + else if (/^\/user\/\d+\/quota$/.test(path)) json = {}; + return route.fulfill({ json }); + }); +} + +for (const [width, height] of [ + [640, 256], + [863, 304], + [945, 316], +] as const) { + test(`quick-request sheet is usable on a ${width}x${height} screen`, async ({ + page, + }, info) => { + test.skip(info.project.name !== 'desktop', 'sets its own viewport'); + await fixture(page); + await page.setViewportSize({ width, height }); + await page.goto('/'); + const poster = page.locator('.poster', { hasText: 'Fresh Pick' }).first(); + await poster.scrollIntoViewIfNeeded(); + await poster.hover(); + await poster.locator('.qbtn.go').focus(); + await page.keyboard.press('Enter'); + const sheet = page.getByRole('dialog', { name: 'Request Fresh Pick' }); + await expect(sheet).toBeVisible(); + await page.waitForTimeout(600); // the sheet slides up first + const geo = () => + sheet.evaluate((el) => { + const r = (s: string) => + el.querySelector(s)!.getBoundingClientRect().toJSON(); + return { + foot: r('.pk-foot'), + seg: r('.seg'), + cancel: r('.pk-foot .btn-tonal'), + pos: getComputedStyle(el.querySelector('.pk-foot')!).position, + scrollable: el.scrollHeight > el.clientHeight, + vh: innerHeight, + }; + }); + // the footer never sits on top of the options + let g = await geo(); + expect(g.pos).not.toBe('sticky'); + const overlap = + Math.min(g.seg.bottom, g.foot.bottom) - Math.max(g.seg.top, g.foot.top); + expect(overlap, 'footer covers the quality options').toBeLessThanOrEqual(0); + // scrolling the sheet brings Request and Cancel fully on screen + await sheet.evaluate((el) => el.scrollTo(0, el.scrollHeight)); + g = await geo(); + expect(g.cancel.bottom).toBeLessThanOrEqual(g.vh); + expect(g.cancel.top).toBeGreaterThanOrEqual(0); + await page.keyboard.press('Escape'); + await expect(sheet).toBeHidden(); + }); +} diff --git a/lumen/tests/browser/release-dates.spec.ts b/lumen/tests/browser/release-dates.spec.ts new file mode 100644 index 0000000..c2ea816 --- /dev/null +++ b/lumen/tests/browser/release-dates.spec.ts @@ -0,0 +1,104 @@ +import { expect, test, type Page } from '@playwright/test'; + +// Release dates name the year when it isn't this one ("Mar 11, 2027"), and Discover's +// Coming soon row links to the Coming soon page like the other rows. +const year = new Date().getFullYear(); +const nextYear = year + 1; + +const movie = (id: number, title: string, releaseDate: string) => ({ + id, + title, + mediaType: 'movie', + releaseDate, + posterPath: `/p${id}.png`, + voteAverage: 0, +}); +const paged = (results: unknown[]) => ({ + page: 1, + totalPages: 1, + totalResults: results.length, + results, +}); + +async function fixture(page: Page) { + await page.route('https://**/*', (r) => r.abort()); + await page.route(/\/(imageproxy|branding)\//, (r) => + r.fulfill({ + contentType: 'image/svg+xml', + body: '', + }) + ); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/api/v1/**', (route) => { + const path = new URL(route.request().url()).pathname.slice(7); + let json: unknown = {}; + if (path === '/settings/public') json = { mediaServerType: 2 }; + else if (path === '/auth/me') + json = { id: 1, displayName: 'fam', permissions: 2 }; + else if (path === '/discover/movies/upcoming') + json = paged([ + movie(401, 'Later This Year', `${year}-12-31`), + movie(402, 'Next Year Film', `${nextYear}-03-11`), + ]); + else if (path === '/movie/402/recommendations') json = paged([]); + else if (path === '/movie/402') + json = { + ...movie(402, 'Next Year Film', `${nextYear}-03-11`), + genres: [], + overview: 'Not out yet.', + credits: { cast: [], crew: [] }, + relatedVideos: [], + releases: { results: [] }, + }; + else if (path === '/discover/genreslider/movie') json = []; + else if (/^\/discover\//.test(path) || path === '/media') json = paged([]); + else if (path === '/request') + json = { pageInfo: { pages: 1, results: 0 }, results: [] }; + return route.fulfill({ json }); + }); +} + +test('the detail page names the year of a release that is not this year', async ({ + page, +}) => { + await fixture(page); + await page.goto('/movie/402'); + await expect(page.locator('.dtitle h1')).toHaveText('Next Year Film'); + await expect(page.getByText(`In theaters Mar 11, ${nextYear}`)).toBeVisible(); +}); + +test('Coming soon on Discover names the year and links to the full page', async ({ + page, +}) => { + await fixture(page); + await page.goto('/'); + const row = page.locator('section', { + has: page.getByRole('heading', { name: 'Coming soon' }), + }); + await expect( + row.locator('.poster', { hasText: 'Next Year Film' }).locator('.m') + ).toHaveText(`Mar 11, ${nextYear}`); + // this year's dates stay short + await expect( + row.locator('.poster', { hasText: 'Later This Year' }).locator('.m') + ).toHaveText('Dec 31'); + const seeAll = row.getByRole('link', { name: 'See all' }); + await expect(seeAll).toHaveAttribute('href', '/soon'); +}); + +test('the phone request bar keeps its button on screen and the title readable', async ({ + page, +}, info) => { + test.skip(info.project.name !== 'iphone', 'phone layout'); + await fixture(page); + await page.goto('/movie/402'); + const bar = page.locator('.actionbar'); + await expect(bar).toBeVisible(); + const button = bar.getByRole('button', { name: /Request/ }); + const box = (await button.boundingBox())!; + expect(box.x + box.width).toBeLessThanOrEqual( + page.viewportSize()!.width + 0.5 + ); + const title = (await bar.locator('.ab-meta').boundingBox())!; + expect(title.height).toBeLessThan(40); +}); diff --git a/lumen/tests/browser/requests-editions.spec.ts b/lumen/tests/browser/requests-editions.spec.ts new file mode 100644 index 0000000..a02d7b0 --- /dev/null +++ b/lumen/tests/browser/requests-editions.spec.ts @@ -0,0 +1,97 @@ +import { expect, test, type Page } from '@playwright/test'; + +// The server says stage "library" for a finished request pinned to one edition when another +// edition of the book is what the library has. That never settles, so it must not read as +// "Checking library" and must not sit in Active. +async function fixture(page: Page, preferredEditionId: string | null) { + await page.route('https://**/*', (r) => r.abort()); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/seerr-classic/**', (r) => r.fulfill({ status: 404 })); + await page.route('**/api/v1/**', (r) => { + const url = new URL(r.request().url()); + const path = url.pathname.slice(7); + if (path === '/auth/me') + return r.fulfill({ + json: { id: 1, displayName: 'reader', permissions: 2 }, + }); + if (path === '/settings/public') + return r.fulfill({ + json: { + booksEnabled: true, + ebookServiceEnabled: true, + enabledMediaCategories: { ebook: true }, + }, + }); + if (path.startsWith('/book/')) + return r.fulfill({ + json: { id: 'OL614W', title: 'Pinned book', author: 'Reader' }, + }); + if (path === '/request') { + const processing = url.searchParams.get('filter') === 'processing'; + return r.fulfill({ + json: { + pageInfo: { pages: 1, results: processing ? 0 : 1 }, + results: processing + ? [] + : [ + { + id: 614, + type: 'book', + status: 5, + bookFormat: 'ebook', + is4k: false, + createdAt: '2026-11-08T12:00:00Z', + requestedBy: { id: 1, displayName: 'reader' }, + ...(preferredEditionId ? { preferredEditionId } : {}), + currentStatus: { + stage: 'library', + percent: null, + message: 'The media is being added to your library.', + }, + media: { + mediaType: 'book', + status: 5, + availableEbookServiceIds: [3], + availableAudiobookServiceIds: [], + identifiers: [{ provider: 'openlibrary', value: 'OL614W' }], + }, + }, + ], + }, + }); + } + return r.fulfill({ json: {} }); + }); +} + +test('a pinned edition that is not the one on disk reads as ready', async ({ + page, +}) => { + await fixture(page, 'hardcover:98765'); + await page.goto('/requests?tab=ready'); + const row = page.locator('.rq').filter({ hasText: 'Pinned book' }); + await expect(row).toBeVisible(); + await expect(row.locator('.st')).toContainText( + 'Different edition in library' + ); + await expect(row).toContainText( + 'This book is in your library in another edition than the one requested.' + ); + await expect(row).not.toContainText('Checking library'); + await expect( + page.getByRole('button', { name: /^Active\s*0$/ }) + ).toBeVisible(); + await expect(page.getByRole('button', { name: /^Ready\s*1$/ })).toBeVisible(); +}); + +for (const [name, pin] of [ + ['unpinned', null], + ['Open Library edition pinned', 'OL123M'], +] as const) { + test(`${name} request still says checking library`, async ({ page }) => { + await fixture(page, pin); + await page.goto('/requests'); + const row = page.locator('.rq').filter({ hasText: 'Pinned book' }); + await expect(row.locator('.st')).toContainText('Checking library'); + }); +} diff --git a/lumen/tests/browser/requests-narrow-actions.spec.ts b/lumen/tests/browser/requests-narrow-actions.spec.ts new file mode 100644 index 0000000..d6f3e19 --- /dev/null +++ b/lumen/tests/browser/requests-narrow-actions.spec.ts @@ -0,0 +1,75 @@ +import { expect, test, type Page } from '@playwright/test'; + +// A ready request's "Watch on Plex" button must stay inside its card on narrow cards (two columns on +// a folded phone, a 360px-wide short phone). +async function fixture(page: Page) { + await page.route('https://**/*', (r) => r.abort()); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/seerr-classic/**', (r) => r.fulfill({ status: 404 })); + await page.route('**/api/v1/**', (r) => { + const url = new URL(r.request().url()); + const path = url.pathname.slice(7); + if (path === '/auth/me') + return r.fulfill({ + json: { id: 1, displayName: 'reader', permissions: 2 }, + }); + if (path === '/settings/public') + return r.fulfill({ json: { mediaServerType: 1 } }); + if (path.startsWith('/movie/')) + return r.fulfill({ json: { id: 603, title: 'Ready movie' } }); + if (path === '/request') + return r.fulfill({ + json: { + pageInfo: { pages: 1, results: 1 }, + results: [ + { + id: 1, + type: 'movie', + status: 2, + is4k: false, + createdAt: '2026-11-08T12:00:00Z', + requestedBy: { id: 1, displayName: 'reader' }, + media: { + mediaType: 'movie', + tmdbId: 603, + status: 5, + mediaUrl: 'https://plex.example/web/index.html#!/server/x', + }, + }, + ], + }, + }); + return r.fulfill({ json: {} }); + }); +} + +test('the Watch on Plex button stays inside a narrow request card', async ({ + page, +}, info) => { + test.skip(info.project.name !== 'desktop', 'sets its own viewport'); + await fixture(page); + await page.emulateMedia({ media: 'screen' }); + for (const [width, height] of [ + [701, 685], + [360, 800], + [320, 640], + [360, 288], + ]) { + await page.setViewportSize({ width, height }); + await page.goto('/requests?tab=all'); + const card = page.locator('.rq').filter({ hasText: 'Ready movie' }); + const btn = card.locator('.rqa a.btn'); + await expect(btn).toBeVisible(); + const spill = await card.evaluate((c) => { + const outer = c.getBoundingClientRect(); + return Array.from(c.querySelectorAll('.rqa .btn')).map( + (b) => b.getBoundingClientRect().right - outer.right + ); + }); + for (const s of spill) + expect( + s, + `button past its card at ${width}x${height}` + ).toBeLessThanOrEqual(0.5); + } +}); diff --git a/lumen/tests/browser/requests-narrow-pill.spec.ts b/lumen/tests/browser/requests-narrow-pill.spec.ts new file mode 100644 index 0000000..f996ab9 --- /dev/null +++ b/lumen/tests/browser/requests-narrow-pill.spec.ts @@ -0,0 +1,88 @@ +import { expect, test, type Page } from '@playwright/test'; + +// Narrow phones: the four Requests tabs must all be on screen without scrolling the tab bar, and a +// long status pill must stay inside its row. +async function fixture(page: Page) { + await page.route('https://**/*', (r) => r.abort()); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/seerr-classic/**', (r) => r.fulfill({ status: 404 })); + await page.route('**/api/v1/**', (r) => { + const url = new URL(r.request().url()); + const path = url.pathname.slice(7); + if (path === '/auth/me') + return r.fulfill({ + json: { id: 1, displayName: 'reader', permissions: 2 }, + }); + if (path === '/settings/public') + return r.fulfill({ + json: { + booksEnabled: true, + ebookServiceEnabled: true, + enabledMediaCategories: { ebook: true }, + }, + }); + if (path.startsWith('/book/')) + return r.fulfill({ + json: { id: 'OL614W', title: 'Pinned book', author: 'Reader' }, + }); + if (path === '/request') { + const processing = url.searchParams.get('filter') === 'processing'; + return r.fulfill({ + json: { + pageInfo: { pages: 1, results: processing ? 0 : 1 }, + results: processing + ? [] + : [ + { + id: 614, + type: 'book', + status: 5, + bookFormat: 'ebook', + is4k: false, + createdAt: '2026-11-08T12:00:00Z', + requestedBy: { id: 1, displayName: 'reader' }, + preferredEditionId: 'hardcover:98765', + currentStatus: { + stage: 'library', + percent: null, + message: 'The media is being added to your library.', + }, + media: { + mediaType: 'book', + status: 5, + availableEbookServiceIds: [3], + availableAudiobookServiceIds: [], + identifiers: [{ provider: 'openlibrary', value: 'OL614W' }], + }, + }, + ], + }, + }); + } + return r.fulfill({ json: {} }); + }); +} + +test('a long status pill stays inside its Requests row on narrow screens', async ({ + page, +}, info) => { + test.skip(info.project.name !== 'desktop', 'sets its own viewport'); + await fixture(page); + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto('/requests?tab=all'); + const row = page.locator('.rq').filter({ hasText: 'Pinned book' }); + await expect(row.locator('.st .pill')).toBeVisible(); + for (const width of [360, 375, 390, 430, 768]) { + await page.setViewportSize({ width, height: 800 }); + const spill = await row.locator('.st').evaluate((st) => { + const outer = st.getBoundingClientRect(); + return Array.from(st.children).map( + (c) => c.getBoundingClientRect().right - outer.right + ); + }); + for (const s of spill) + expect(s, `status pill past the row at ${width}px`).toBeLessThanOrEqual( + 1 + ); + } +}); diff --git a/lumen/tests/browser/requests-narrow-tabs.spec.ts b/lumen/tests/browser/requests-narrow-tabs.spec.ts new file mode 100644 index 0000000..77ee921 --- /dev/null +++ b/lumen/tests/browser/requests-narrow-tabs.spec.ts @@ -0,0 +1,84 @@ +import { expect, test, type Page } from '@playwright/test'; + +// Narrow phones: the four Requests tabs must all be on screen without scrolling the tab bar, and a +// long status pill must stay inside its row. +async function fixture(page: Page) { + await page.route('https://**/*', (r) => r.abort()); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/seerr-classic/**', (r) => r.fulfill({ status: 404 })); + await page.route('**/api/v1/**', (r) => { + const url = new URL(r.request().url()); + const path = url.pathname.slice(7); + if (path === '/auth/me') + return r.fulfill({ + json: { id: 1, displayName: 'reader', permissions: 2 }, + }); + if (path === '/settings/public') + return r.fulfill({ + json: { + booksEnabled: true, + ebookServiceEnabled: true, + enabledMediaCategories: { ebook: true }, + }, + }); + if (path.startsWith('/book/')) + return r.fulfill({ + json: { id: 'OL614W', title: 'Pinned book', author: 'Reader' }, + }); + if (path === '/request') { + const processing = url.searchParams.get('filter') === 'processing'; + return r.fulfill({ + json: { + pageInfo: { pages: 1, results: processing ? 0 : 1 }, + results: processing + ? [] + : [ + { + id: 614, + type: 'book', + status: 5, + bookFormat: 'ebook', + is4k: false, + createdAt: '2026-11-08T12:00:00Z', + requestedBy: { id: 1, displayName: 'reader' }, + preferredEditionId: 'hardcover:98765', + currentStatus: { + stage: 'library', + percent: null, + message: 'The media is being added to your library.', + }, + media: { + mediaType: 'book', + status: 5, + availableEbookServiceIds: [3], + availableAudiobookServiceIds: [], + identifiers: [{ provider: 'openlibrary', value: 'OL614W' }], + }, + }, + ], + }, + }); + } + return r.fulfill({ json: {} }); + }); +} + +test('all four Requests tabs fit on narrow screens without scrolling the tab bar', async ({ + page, +}, info) => { + test.skip(info.project.name !== 'desktop', 'sets its own viewport'); + await fixture(page); + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto('/requests?tab=all'); + await expect(page.locator('.seg button')).toHaveCount(4); + for (const width of [360, 375, 390, 430, 768]) { + await page.setViewportSize({ width, height: 800 }); + const seg = await page + .locator('.seg') + .first() + .evaluate((el) => ({ scroll: el.scrollWidth, client: el.clientWidth })); + expect(seg.scroll, `tabs cut off at ${width}px`).toBeLessThanOrEqual( + seg.client + ); + } +}); diff --git a/lumen/tests/browser/requests-pages.spec.ts b/lumen/tests/browser/requests-pages.spec.ts index 1d16b5c..60e5dca 100644 --- a/lumen/tests/browser/requests-pages.spec.ts +++ b/lumen/tests/browser/requests-pages.spec.ts @@ -2,9 +2,9 @@ import { expect, test, type Page } from '@playwright/test'; // SeerrNG verifies at most 32 title variants per answer and reports the rest as not available, // so request lists are fetched in pages small enough that every title keeps its real status. -async function fixture(page: Page) { +async function fixture(page: Page, count = 40) { const takes: number[] = []; - const all = Array.from({ length: 40 }, (_, i) => ({ + const all = Array.from({ length: count }, (_, i) => ({ id: i + 1, type: 'movie', status: 2, @@ -64,3 +64,12 @@ for (const everyone of [false, true]) { expect(Math.max(...takes)).toBeLessThanOrEqual(15); }); } + +test('My requests loads every page so the counts are the real totals', async ({ + page, +}) => { + await fixture(page, 47); + await page.goto('/requests?tab=all'); + await expect(page.getByRole('button', { name: /^All\s*47$/ })).toBeVisible(); + await expect(page.locator('.rq')).toHaveCount(47); +}); diff --git a/lumen/tests/browser/touch-all-chip.spec.ts b/lumen/tests/browser/touch-all-chip.spec.ts new file mode 100644 index 0000000..8973561 --- /dev/null +++ b/lumen/tests/browser/touch-all-chip.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; + +// The short "All" filter button on Movies/Series/Books/Audiobooks must still be a 44px touch target. +test('the All availability button is at least 44px wide on touch screens', async ({ + page, +}, info) => { + test.skip( + info.project.name !== 'android' && info.project.name !== 'iphone', + 'touch only' + ); + await page.route('https://**/*', (r) => r.abort()); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/api/v1/**', (r) => { + const path = new URL(r.request().url()).pathname.slice(7); + if (path === '/auth/me') + return r.fulfill({ + json: { id: 1, displayName: 'reader', permissions: 2 }, + }); + if (path === '/settings/public') + return r.fulfill({ json: { mediaServerType: 1 } }); + if (path.includes('genre')) return r.fulfill({ json: [] }); + return r.fulfill({ + json: { page: 1, totalPages: 1, totalResults: 0, results: [] }, + }); + }); + await page.goto('/movies'); + const all = page + .getByRole('group', { name: 'Availability' }) + .getByRole('button', { + name: 'All', + exact: true, + }); + await expect(all).toBeVisible(); + const box = await all.evaluate((el) => el.getBoundingClientRect().toJSON()); + expect(box.width).toBeGreaterThanOrEqual(43.5); + expect(box.height).toBeGreaterThanOrEqual(43.5); +}); From 99340c4fff5f6263f6e84c99dd2c6afb631b6a4e Mon Sep 17 00:00:00 2001 From: Ncbudd Date: Wed, 11 Nov 2026 17:11:54 -0500 Subject: [PATCH 2/4] [SeerrNG #658] Lumen: Discover feature card meta wraps on narrow screens --- lumen/src/styles/app.css | 8 +- .../browser/discover-feature-meta.spec.ts | 77 +++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 lumen/tests/browser/discover-feature-meta.spec.ts diff --git a/lumen/src/styles/app.css b/lumen/src/styles/app.css index c049777..78fe0c7 100644 --- a/lumen/src/styles/app.css +++ b/lumen/src/styles/app.css @@ -579,9 +579,11 @@ body.is-detail .tabbar { .feat .mm { font-size: 13px; opacity: 0.88; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + /* wrap instead of cutting the genre/rating off on narrow cards */ + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.3; + flex: 1 1 auto; min-width: 0; } .feat .go { diff --git a/lumen/tests/browser/discover-feature-meta.spec.ts b/lumen/tests/browser/discover-feature-meta.spec.ts new file mode 100644 index 0000000..ba3660f --- /dev/null +++ b/lumen/tests/browser/discover-feature-meta.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; + +const tv = { + id: 7, + mediaType: 'tv', + name: 'Hero Series', + posterPath: '/p.jpg', + backdropPath: '/b.jpg', + firstAirDate: '2003-01-01', + voteAverage: 8.3, +}; +const paged = (results: unknown[]) => ({ + page: 1, + totalPages: 1, + totalResults: results.length, + results, +}); + +test('Discover feature card meta is not cut off on narrow screens', async ({ + page, +}, info) => { + test.skip(info.project.name !== 'desktop', 'sets its own viewports'); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/api/v1/**', (route) => { + const path = new URL(route.request().url()).pathname.slice( + '/api/v1'.length + ); + if (path === '/auth/me') + return route.fulfill({ + json: { id: 1, displayName: 'test', permissions: 2 }, + }); + if (path === '/settings/public') + return route.fulfill({ + json: { applicationTitle: 'SeerrNG', movie4kEnabled: false }, + }); + if (/^\/discover\/(trending|movies|tv)$/.test(path)) + return route.fulfill({ json: paged([tv]) }); + if (path.startsWith('/discover/genreslider/')) + return route.fulfill({ json: [] }); + return route.fulfill({ json: paged([]) }); + }); + for (const [width, height] of [ + [360, 536], + [412, 783], + [701, 685], + ]) { + await page.setViewportSize({ width, height }); + await page.goto('/'); + const card = page.locator('.feat').first(); + await expect(card.locator('.mm')).toBeVisible(); + const r = await card.evaluate((el) => { + const mm = el.querySelector('.mm')!; + const box = el.getBoundingClientRect(); + const inside = Array.from( + el.querySelectorAll('.cap h3, .cap .go, .mm') + ).every((n) => { + const b = n.getBoundingClientRect(); + return ( + b.left >= box.left - 1 && + b.right <= box.right + 1 && + b.top >= box.top - 1 && + b.bottom <= box.bottom + 1 + ); + }); + return { + clipped: mm.scrollWidth > mm.clientWidth + 1, + ellipsis: getComputedStyle(mm).textOverflow === 'ellipsis', + inside, + text: mm.textContent, + }; + }); + expect(r.text, `${width}px`).toContain('★'); + expect(r.clipped, `${width}px clipped`).toBe(false); + expect(r.ellipsis, `${width}px ellipsis`).toBe(false); + expect(r.inside, `${width}px overflow`).toBe(true); + } +}); From 0fbc97ad2bb5da5e88e8fc93512b45190006c1e8 Mon Sep 17 00:00:00 2001 From: Ncbudd Date: Wed, 11 Nov 2026 19:27:34 -0500 Subject: [PATCH 3/4] [SeerrNG #658] Lumen: book cards show the books already in the library --- lumen/src/components/cards.tsx | 17 +- lumen/src/hooks.ts | 136 +++++++++++- lumen/src/pages/Books.tsx | 29 ++- lumen/tests/browser/book-owned.spec.ts | 277 +++++++++++++++++++++++++ 4 files changed, 442 insertions(+), 17 deletions(-) create mode 100644 lumen/tests/browser/book-owned.spec.ts diff --git a/lumen/src/components/cards.tsx b/lumen/src/components/cards.tsx index c99bfe3..daa8d8e 100644 --- a/lumen/src/components/cards.tsx +++ b/lumen/src/components/cards.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { - bookMediaForFormat, englishAudioEdition, hardcoverWorkId, mediaLink, @@ -19,7 +18,12 @@ import { } from '../api'; import { useMediaServerName } from '../auth'; import { friendlyError } from '../errors'; -import { dropBookStates, useBookStates } from '../hooks'; +import { + bookMediaWithLibrary, + dropBookStates, + useBookStates, + useOwnedBooks, +} from '../hooks'; import { Icon, type IconName } from '../icons'; import { scrollBehavior } from '../motion'; import { usePrefs } from '../settings'; @@ -349,12 +353,11 @@ export function BookCard({ b }: { b: BookResult }) { const { prefs } = usePrefs(); const toast = useToast(); const states = useBookStates(); - const known = states?.get(b.id)?.[audio ? 'audiobook' : 'ebook']; + const format = audio ? 'audiobook' : 'ebook'; + const library = useOwnedBooks(format); + const known = states?.get(b.id)?.[format]; const [s0, markRequested] = useRequested( - statusOf( - bookMediaForFormat(b.mediaInfo, audio ? 'audiobook' : 'ebook'), - true - ) + statusOf(bookMediaWithLibrary(b, format, library), true) ); const s = s0 || diff --git a/lumen/src/hooks.ts b/lumen/src/hooks.ts index 3d30535..adb9032 100644 --- a/lumen/src/hooks.ts +++ b/lumen/src/hooks.ts @@ -1,10 +1,21 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { api, ApiError, hardcoverWorkId, hcCovers } from './api'; +import { + api, + ApiError, + bookMediaForFormat, + hardcoverWorkId, + hcCovers, + MediaStatus, + type BookResult, + type MediaInfo, + type Paged, +} from './api'; import { cached, cacheGeneration, forget, + FRESH_MS, isFresh, onMarkStale, RECHECK_MS, @@ -304,6 +315,129 @@ export function useBookStates() { return m; } +// ---- books already in the library ---- +// The catalogue feeds don't say which books are owned (SeerrNG before the fix sends no +// mediaInfo on them), so book cards also check the owned list: the same /book/library answer +// the "In library" filter shows, from the shared answer cache. SeerrNG lists only books with +// that format's files, and bookMediaForFormat checks the file evidence again. +type BookFormat = 'ebook' | 'audiobook'; +export type OwnedBooks = { + byId: Map; + byWork: Map; + // null: two different owned books share the title and author, so neither is matched + byTitle: Map; +}; +// SeerrNG pages the owned list 50 at a time; past this the rest is left to the server +const MAX_LIBRARY_PAGES = 20; +const owned = new Map }>(); +// Open Library and the manager spell titles differently; compare letters and digits only, +// without the subtitle, a parenthesised edition note or a trailing "Book 4" +const titleKey = (title?: string, author?: string) => { + const norm = (x: string) => + x + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/&/g, ' and '); + if (!title || !author) return ''; + const t = norm(title) + .split(':')[0] + .replace(/\([^)]*\)|\[[^\]]*\]/g, ' ') + .replace(/,?\s*(?:book|volume|vol\.?)\s*\d+\s*$/, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + const a = norm(author).replace(/[^a-z0-9]+/g, ''); + return t && a ? `${t}|${a}` : ''; +}; +const isOpenLibraryWork = (id: string) => /^OL[1-9]\d*W$/.test(id); +// The Hardcover work number of a Bookshelf id, for matching owned rows only: rows from +// older installs carry the bare number, newer ones "hc:", so accept both spellings +const ownedWorkKey = (id: string): string | null => { + if (!id.startsWith('bookshelf:')) return null; + try { + const raw = atob( + (id.split(':')[2] ?? '').replace(/-/g, '+').replace(/_/g, '/') + ); + return /^(?:hc:)?(\d+)$/.exec(raw)?.[1] ?? null; + } catch { + return null; + } +}; +async function fetchOwnedBooks(format: BookFormat): Promise { + const rows: BookResult[] = []; + for (let page = 1; page <= MAX_LIBRARY_PAGES; page++) { + const d = await cachedApi>>( + `/book/library?format=${format}&page=${page}` + ); + rows.push(...(Array.isArray(d?.results) ? d.results : [])); + if (!d || page >= (d.totalPages || 1)) break; + } + const out: OwnedBooks = { + byId: new Map(), + byWork: new Map(), + byTitle: new Map(), + }; + for (const b of rows) { + const mi = bookMediaForFormat(b.mediaInfo, format); + if (!b?.id || !mi || mi.status !== MediaStatus.AVAILABLE) continue; + out.byId.set(b.id, mi); + const work = ownedWorkKey(b.id); + if (work) out.byWork.set(work, mi); + const key = titleKey(b.title, b.author); + if (!key) continue; + const seen = out.byTitle.get(key); + out.byTitle.set(key, seen === undefined || seen === mi ? mi : null); + } + return out; +} +export function loadOwnedBooks(format: BookFormat): Promise { + const cur = owned.get(format); + if (cur && Date.now() - cur.at < FRESH_MS) return cur.p; + const p = fetchOwnedBooks(format).catch(() => { + // no access to the category, or the library is down: nothing is marked, and the next + // look asks again + if (owned.get(format)?.p === p) owned.delete(format); + return { byId: new Map(), byWork: new Map(), byTitle: new Map() }; + }); + owned.set(format, { at: Date.now(), p }); + return p; +} +// a request, an import or another account: ask again next time +onMarkStale(() => owned.clear()); +onSessionReset(() => owned.clear()); +// null while the owned list loads +export function useOwnedBooks(format: BookFormat | null) { + const [m, setM] = useState(null); + useEffect(() => { + if (!format) return; + let live = true; + loadOwnedBooks(format).then((x) => live && setM(x)); + return () => { + live = false; + }; + }, [format]); + return m; +} +// The book's state in one format: SeerrNG's own answer when it says the book is there, +// else the matching owned-list row (same book id, same Hardcover work, or for an Open +// Library work an unambiguous title and author) +export function bookMediaWithLibrary( + b: Pick, + format: BookFormat, + lib: OwnedBooks | null +): MediaInfo | undefined { + const mi = bookMediaForFormat(b.mediaInfo, format); + if (mi?.status === MediaStatus.AVAILABLE || !lib) return mi; + const work = ownedWorkKey(b.id); + const match = + lib.byId.get(b.id) ?? + (work ? lib.byWork.get(work) : undefined) ?? + (isOpenLibraryWork(b.id) + ? lib.byTitle.get(titleKey(b.title, b.author)) || undefined + : undefined); + return match ?? mi; +} + // ebook covers: Hardcover's book cover when the companion has one, else what SeerrNG gave export function useEbookCovers< T extends { id: string; posterPath?: string | null }, diff --git a/lumen/src/pages/Books.tsx b/lumen/src/pages/Books.tsx index cc37143..5b32689 100644 --- a/lumen/src/pages/Books.tsx +++ b/lumen/src/pages/Books.tsx @@ -1,8 +1,13 @@ import { Link, useSearchParams } from 'react-router-dom'; -import { bookMediaForFormat, MediaStatus, type BookResult } from '../api'; +import { MediaStatus, type BookResult } from '../api'; import { BookCard, GridSkeleton, MoreOnScroll } from '../components/cards'; import { Avatar } from '../components/Shell'; -import { useEbookCovers, usePaged } from '../hooks'; +import { + bookMediaWithLibrary, + useEbookCovers, + useOwnedBooks, + usePaged, +} from '../hooks'; import { Icon } from '../icons'; import { usePrefs } from '../settings'; @@ -18,13 +23,18 @@ export default function Books({ format }: { format: 'ebook' | 'audiobook' }) { : `/discover/books?format=${format}` ); const audio = format === 'audiobook'; + // the popular feed doesn't say which books are owned: "Not yet" checks the owned list + const library = useOwnedBooks(format); + const waiting = show === 'not' && !library; const have = (b: BookResult) => - bookMediaForFormat(b.mediaInfo, format)?.status === MediaStatus.AVAILABLE; - const raw = list.items - .map((b) => ({ ...b, bookFormat: format })) - .filter((b) => - show === 'have' ? have(b) : show === 'not' ? !have(b) : true - ); + bookMediaWithLibrary(b, format, library)?.status === MediaStatus.AVAILABLE; + const raw = waiting + ? [] + : list.items + .map((b) => ({ ...b, bookFormat: format })) + .filter((b) => + show === 'have' ? have(b) : show === 'not' ? !have(b) : true + ); const withCovers = useEbookCovers(raw); const items = audio ? raw : withCovers; const upd = (v: string) => { @@ -94,6 +104,7 @@ export default function Books({ format }: { format: 'ebook' | 'audiobook' }) {
)} {!list.loading && + !waiting && !list.error && list.page > 0 && items.length === 0 && ( @@ -103,7 +114,7 @@ export default function Books({ format }: { format: 'ebook' | 'audiobook' }) { : 'No books match this view.'}
)} - {list.loading && !items.length && list.page === 0 && ( + {(waiting || (list.loading && list.page === 0)) && !items.length && (
diff --git a/lumen/tests/browser/book-owned.spec.ts b/lumen/tests/browser/book-owned.spec.ts new file mode 100644 index 0000000..160f8a8 --- /dev/null +++ b/lumen/tests/browser/book-owned.spec.ts @@ -0,0 +1,277 @@ +import { expect, test, type Page } from '@playwright/test'; + +// Shaped like SeerrNG 3.72.31: the catalogue feeds carry no mediaInfo, the owned list does. +const ownedAudio = 'bookshelf:1:NDI4Mjkw'; +const ownedAudioWork = 'bookshelf:1:aGM6NDU5NzQ0'; +const audioOnlyEbookOwned = 'bookshelf:1:MjYwNDY4'; +const paged = (results: unknown[]) => ({ + page: 1, + totalPages: 1, + totalResults: results.length, + results, +}); +const card = ( + id: string, + title: string, + author: string, + extra: Record = {} +) => ({ + id, + mediaType: 'book', + title, + author, + posterPath: 'https://covers.invalid/cover.jpg', + ...extra, +}); +const audioFeed = [ + card( + ownedAudio, + 'A Court of Thorns and Roses (10th Anniversary Recording)', + 'Sarah J. Maas', + { provider: 'bookshelf', bookFormat: 'audiobook' } + ), + // the same Hardcover work under another spelling of its id + card( + 'bookshelf:1:NDU5NzQ0', + 'A Court of Frost and Starlight (10th Anniversary Recording)', + 'Sarah J. Maas', + { provider: 'bookshelf', bookFormat: 'audiobook' } + ), + card('bookshelf:1:OTk5OTk5', 'Not Owned Recording', 'Writer Two', { + provider: 'bookshelf', + bookFormat: 'audiobook', + }), + // only the ebook of this one is in the library + card(audioOnlyEbookOwned, 'Wool', 'Hugh Howey', { + provider: 'bookshelf', + bookFormat: 'audiobook', + }), +]; +const ebookFeed = [ + card('OL15358691W', 'Throne of Glass', 'Sarah J. Maas', { + provider: 'openlibrary', + bookFormat: 'ebook', + }), + card('OL17860744W', 'Project Hail Mary', 'Andy Weir', { + provider: 'openlibrary', + bookFormat: 'ebook', + }), + card('OL66554W', 'Pride and Prejudice', 'Jane Austen', { + provider: 'openlibrary', + bookFormat: 'ebook', + }), + // same title, different author: not the owned book + card('OL1W', 'Wool', 'Someone Else', { + provider: 'openlibrary', + bookFormat: 'ebook', + }), +]; +const audioMedia = { + status: 5, + availableEbookServiceIds: [0], + availableAudiobookServiceIds: [1], + audiobookLibraryServiceId: 1, + audiobookLibraryItemId: 'abs-item', + hasPlexAudiobookFile: false, +}; +const ebookMedia = { + status: 5, + availableEbookServiceIds: [0], + availableAudiobookServiceIds: null, + hasPlexAudiobookFile: false, +}; +const library = { + audiobook: [ + card( + ownedAudio, + 'A Court of Thorns and Roses (10th Anniversary Recording)', + 'Sarah J. Maas', + { mediaInfo: audioMedia } + ), + card( + ownedAudioWork, + 'A Court of Frost and Starlight (10th Anniversary Recording)', + 'Sarah J. Maas', + { mediaInfo: audioMedia } + ), + ], + ebook: [ + card('bookshelf:0:Mzk2Mjk', 'Throne of Glass', 'Sarah J. Maas', { + mediaInfo: ebookMedia, + }), + card('bookshelf:0:NDI3NTc4', 'Project Hail Mary: A Novel', 'Andy Weir', { + mediaInfo: ebookMedia, + }), + card('bookshelf:0:MjYwNDY4', 'Wool', 'Hugh Howey', { + mediaInfo: ebookMedia, + }), + ], +}; + +async function fixtures(page: Page) { + const calls: string[] = []; + await page.route('https://**/*', (r) => r.abort()); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/seerr-classic/**', (r) => + r.fulfill({ status: 404, body: '' }) + ); + await page.route('**/api/v1/**', (route) => { + const url = new URL(route.request().url()); + const path = url.pathname.slice(7); + calls.push(path + url.search); + const format = + url.searchParams.get('format') === 'audiobook' ? 'audiobook' : 'ebook'; + if (path === '/auth/me') + return route.fulfill({ + json: { id: 1, displayName: 'viewer', permissions: 2 }, + }); + if (path === '/settings/public') + return route.fulfill({ + json: { + mediaServerType: 1, + booksEnabled: true, + ebookServiceEnabled: true, + audiobookServiceEnabled: true, + enabledMediaCategories: { ebook: true, audiobook: true }, + }, + }); + if (path === '/request') return route.fulfill({ json: paged([]) }); + if (path === '/discover/books') { + const q = (url.searchParams.get('query') || '').toLowerCase(); + const feed = format === 'audiobook' ? audioFeed : ebookFeed; + return route.fulfill({ + json: paged( + q ? feed.filter((b) => b.title.toLowerCase().includes(q)) : feed + ), + }); + } + if (path === '/book/library') + return route.fulfill({ json: paged(library[format]) }); + if (path.startsWith('/discover/genreslider/')) + return route.fulfill({ json: [] }); + if (path.startsWith('/discover/') || path === '/search') + return route.fulfill({ json: paged([]) }); + return route.fulfill({ json: {} }); + }); + return calls; +} + +const poster = (page: Page, title: string) => + page.locator('.poster').filter({ + has: page.getByRole('link', { name: title, exact: true }), + }); +const expectOwned = async (page: Page, title: string, owned: boolean) => { + const glyph = poster(page, title).locator('.glyph.ok'); + if (owned) { + await expect(glyph).toHaveCount(1); + await expect(glyph).toHaveAttribute('title', 'In library'); + } else await expect(glyph).toHaveCount(0); +}; + +test('audiobook cards show the books already in the library', async ({ + page, +}) => { + const calls = await fixtures(page); + await page.goto('/audiobooks'); + await expect(poster(page, 'Not Owned Recording')).toHaveCount(1); + await expectOwned( + page, + 'A Court of Thorns and Roses (10th Anniversary Recording)', + true + ); + await expectOwned( + page, + 'A Court of Frost and Starlight (10th Anniversary Recording)', + true + ); + await expectOwned(page, 'Not Owned Recording', false); + // owning the ebook is not owning the audiobook + await expectOwned(page, 'Wool', false); + + await page.getByRole('button', { name: 'Not yet', exact: true }).click(); + await expect(poster(page, 'Not Owned Recording')).toHaveCount(1); + await expect(poster(page, 'Wool')).toHaveCount(1); + await expect(page.locator('.grid .poster')).toHaveCount(2); + + await page.getByRole('button', { name: 'In library', exact: true }).click(); + await expect(page.locator('.grid .poster')).toHaveCount(2); + await expect(page.locator('.grid .poster .glyph.ok')).toHaveCount(2); + // the cards and the filters share the owned list instead of asking once per card (a + // sign-in landing mid-read can make the cache ask a second time) + expect( + calls.filter((c) => c.startsWith('/book/library?format=audiobook')).length + ).toBeLessThanOrEqual(2); +}); + +test('ebook cards from Open Library match owned books by title and author', async ({ + page, +}) => { + await fixtures(page); + await page.goto('/books'); + await expect(poster(page, 'Pride and Prejudice')).toHaveCount(1); + await expectOwned(page, 'Throne of Glass', true); + await expectOwned(page, 'Project Hail Mary', true); + await expectOwned(page, 'Pride and Prejudice', false); + await expectOwned(page, 'Wool', false); + + await page.getByRole('button', { name: 'Not yet', exact: true }).click(); + await expect(page.locator('.grid .poster')).toHaveCount(2); + await expect(poster(page, 'Pride and Prejudice')).toHaveCount(1); + await expect(poster(page, 'Wool')).toHaveCount(1); + + await page.getByRole('button', { name: 'In library', exact: true }).click(); + await expect(page.locator('.grid .poster')).toHaveCount(3); +}); + +test('Discover rows and search results mark owned books', async ({ page }) => { + await fixtures(page); + await page.goto('/'); + await expect( + page.getByRole('heading', { name: 'Popular audiobooks' }) + ).toBeVisible(); + await expectOwned( + page, + 'A Court of Thorns and Roses (10th Anniversary Recording)', + true + ); + await expectOwned(page, 'Not Owned Recording', false); + await expectOwned(page, 'Throne of Glass', true); + await expectOwned(page, 'Pride and Prejudice', false); + + await page.goto('/search?f=audio&q=court'); + await expectOwned( + page, + 'A Court of Thorns and Roses (10th Anniversary Recording)', + true + ); + await page.goto('/search?f=ebook&q=throne'); + await expectOwned(page, 'Throne of Glass', true); +}); + +test('a book SeerrNG already marks keeps its state when the owned list is unavailable', async ({ + page, +}) => { + await fixtures(page); + await page.route('**/api/v1/book/library?**', (route) => + route.fulfill({ + status: 403, + json: { message: 'Media category is disabled.' }, + }) + ); + await page.route('**/api/v1/discover/books?**', (route) => + route.fulfill({ + json: paged([ + card('bookshelf:1:MTIz', 'Marked By Server', 'Writer', { + mediaInfo: audioMedia, + }), + card('bookshelf:1:NDU2', 'Unmarked', 'Writer'), + ]), + }) + ); + await page.goto('/audiobooks'); + await expectOwned(page, 'Marked By Server', true); + await expectOwned(page, 'Unmarked', false); + await page.getByRole('button', { name: 'Not yet', exact: true }).click(); + await expect(page.locator('.grid .poster')).toHaveCount(1); + await expect(poster(page, 'Unmarked')).toHaveCount(1); +}); From ac8c74c49d8fd841d1ecb2177eb09ab705105eb8 Mon Sep 17 00:00:00 2001 From: Ncbudd Date: Thu, 12 Nov 2026 11:56:43 -0500 Subject: [PATCH 4/4] [SeerrNG #658] requested series lock their seasons; match upstream PR branch Brings lumen/ in line with snapetech-org/seerrng#658 at bde3a0d8. --- lumen/src/api.ts | 11 +- lumen/src/components/cards.tsx | 47 ++++- lumen/src/hooks.ts | 81 +++++---- lumen/src/pages/Books.tsx | 103 ++++++++--- lumen/src/pages/Requests.tsx | 50 ++++-- lumen/src/pages/TvDetail.tsx | 90 +++++++++- lumen/tests/browser/book-description.spec.ts | 0 lumen/tests/browser/book-owned.spec.ts | 170 +++++++++++++++--- .../tests/browser/coming-soon-titles.spec.ts | 0 .../browser/discover-feature-meta.spec.ts | 0 lumen/tests/browser/narrations.spec.ts | 0 lumen/tests/browser/narrow-short-nav.spec.ts | 0 lumen/tests/browser/quick-focus.spec.ts | 0 lumen/tests/browser/quick-sheet-short.spec.ts | 0 lumen/tests/browser/release-dates.spec.ts | 0 lumen/tests/browser/requests-editions.spec.ts | 0 .../browser/requests-narrow-actions.spec.ts | 0 lumen/tests/browser/requests-pages.spec.ts | 52 ++++-- lumen/tests/browser/touch-all-chip.spec.ts | 0 lumen/tests/browser/tv-request-state.spec.ts | 133 ++++++++++++++ 20 files changed, 610 insertions(+), 127 deletions(-) mode change 100644 => 100755 lumen/tests/browser/book-description.spec.ts mode change 100644 => 100755 lumen/tests/browser/coming-soon-titles.spec.ts mode change 100644 => 100755 lumen/tests/browser/discover-feature-meta.spec.ts mode change 100644 => 100755 lumen/tests/browser/narrations.spec.ts mode change 100644 => 100755 lumen/tests/browser/narrow-short-nav.spec.ts mode change 100644 => 100755 lumen/tests/browser/quick-focus.spec.ts mode change 100644 => 100755 lumen/tests/browser/quick-sheet-short.spec.ts mode change 100644 => 100755 lumen/tests/browser/release-dates.spec.ts mode change 100644 => 100755 lumen/tests/browser/requests-editions.spec.ts mode change 100644 => 100755 lumen/tests/browser/requests-narrow-actions.spec.ts mode change 100644 => 100755 lumen/tests/browser/touch-all-chip.spec.ts create mode 100755 lumen/tests/browser/tv-request-state.spec.ts diff --git a/lumen/src/api.ts b/lumen/src/api.ts index 2979a5e..e4c69b6 100644 --- a/lumen/src/api.ts +++ b/lumen/src/api.ts @@ -62,7 +62,15 @@ export const MediaStatus = { } as const; export interface MediaInfo { - requests?: { id: number; status: number; bookFormat?: string }[]; + requests?: { + id: number; + status: number; + bookFormat?: string; + is4k?: boolean; + createdAt?: string; + requestedBy?: { displayName?: string }; + seasons?: { seasonNumber: number; status?: number }[]; + }[]; hasPlexAudiobookFile?: boolean; availableEbookServiceIds?: number[] | null; availableAudiobookServiceIds?: number[] | null; @@ -98,6 +106,7 @@ export interface TitleResult { export interface BookResult { id: string; mediaType: 'book'; + provider?: 'openlibrary' | 'bookshelf'; title: string; author?: string; posterPath?: string | null; diff --git a/lumen/src/components/cards.tsx b/lumen/src/components/cards.tsx index daa8d8e..164dc4e 100644 --- a/lumen/src/components/cards.tsx +++ b/lumen/src/components/cards.tsx @@ -356,14 +356,37 @@ export function BookCard({ b }: { b: BookResult }) { const format = audio ? 'audiobook' : 'ebook'; const library = useOwnedBooks(format); const known = states?.get(b.id)?.[format]; - const [s0, markRequested] = useRequested( - statusOf(bookMediaWithLibrary(b, format, library), true) - ); + const media = bookMediaWithLibrary(b, format, library); + const libraryStatus = statusOf(media, true); + const [s0, markRequested] = useRequested(libraryStatus); + const ownershipUnknown = + b.provider === 'openlibrary' && + !!library?.complete && + !media && + !known && + !s0; const s = s0 || + libraryStatus || (known ? { k: 'wait' as const, word: 'Requested', ico: 'clock' as const } - : null); + : !library?.complete + ? { + k: 'wait' as const, + word: library?.unavailable + ? 'Library check unavailable' + : library + ? 'Library check incomplete' + : 'Checking library', + ico: 'clock' as const, + } + : ownershipUnknown + ? { + k: 'wait' as const, + word: 'Library status unknown', + ico: 'clock' as const, + } + : null); // narrator choice off (admin or personal setting) = one straight request, no picker const straight = !audio || !prefs.narratorChoice; const go = async () => { @@ -395,9 +418,13 @@ export function BookCard({ b }: { b: BookResult }) { /> - {prefs.hoverRequest && !s && straight && ( + {prefs.hoverRequest && (!s || ownershipUnknown) && straight && ( @@ -437,7 +464,7 @@ export function BookCard({ b }: { b: BookResult }) { )} - ) : s ? ( + ) : s && !ownershipUnknown ? ( ) : ( ; byWork: Map; - // null: two different owned books share the title and author, so neither is matched - byTitle: Map; + complete: boolean; + unavailable: boolean; }; // SeerrNG pages the owned list 50 at a time; past this the rest is left to the server const MAX_LIBRARY_PAGES = 20; +const LIBRARY_PAGE_SIZE = 50; const owned = new Map }>(); -// Open Library and the manager spell titles differently; compare letters and digits only, -// without the subtitle, a parenthesised edition note or a trailing "Book 4" -const titleKey = (title?: string, author?: string) => { - const norm = (x: string) => - x - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/&/g, ' and '); - if (!title || !author) return ''; - const t = norm(title) - .split(':')[0] - .replace(/\([^)]*\)|\[[^\]]*\]/g, ' ') - .replace(/,?\s*(?:book|volume|vol\.?)\s*\d+\s*$/, '') - .replace(/[^a-z0-9]+/g, ' ') - .trim(); - const a = norm(author).replace(/[^a-z0-9]+/g, ''); - return t && a ? `${t}|${a}` : ''; -}; -const isOpenLibraryWork = (id: string) => /^OL[1-9]\d*W$/.test(id); // The Hardcover work number of a Bookshelf id, for matching owned rows only: rows from // older installs carry the bare number, newer ones "hc:", so accept both spellings const ownedWorkKey = (id: string): string | null => { @@ -365,17 +346,41 @@ const ownedWorkKey = (id: string): string | null => { }; async function fetchOwnedBooks(format: BookFormat): Promise { const rows: BookResult[] = []; + let complete = false; for (let page = 1; page <= MAX_LIBRARY_PAGES; page++) { - const d = await cachedApi>>( + const d = await cachedApi< + Partial> & { totalResults?: number } + >( `/book/library?format=${format}&page=${page}` ); - rows.push(...(Array.isArray(d?.results) ? d.results : [])); - if (!d || page >= (d.totalPages || 1)) break; + if (!d || !Array.isArray(d.results)) { + throw new Error('Invalid book library response'); + } + if ( + typeof d.totalPages !== 'number' || + !Number.isSafeInteger(d.totalPages) || + d.totalPages < 1 || + d.page !== page || + typeof d.totalResults !== 'number' || + !Number.isSafeInteger(d.totalResults) || + d.totalResults < 0 || + d.totalPages !== + Math.max(1, Math.ceil(d.totalResults / LIBRARY_PAGE_SIZE)) + ) { + throw new Error('Invalid book library pagination'); + } + rows.push(...d.results); + const totalPages = d.totalPages; + if (page >= totalPages) { + complete = true; + break; + } } const out: OwnedBooks = { byId: new Map(), byWork: new Map(), - byTitle: new Map(), + complete, + unavailable: false, }; for (const b of rows) { const mi = bookMediaForFormat(b.mediaInfo, format); @@ -383,10 +388,6 @@ async function fetchOwnedBooks(format: BookFormat): Promise { out.byId.set(b.id, mi); const work = ownedWorkKey(b.id); if (work) out.byWork.set(work, mi); - const key = titleKey(b.title, b.author); - if (!key) continue; - const seen = out.byTitle.get(key); - out.byTitle.set(key, seen === undefined || seen === mi ? mi : null); } return out; } @@ -397,7 +398,12 @@ export function loadOwnedBooks(format: BookFormat): Promise { // no access to the category, or the library is down: nothing is marked, and the next // look asks again if (owned.get(format)?.p === p) owned.delete(format); - return { byId: new Map(), byWork: new Map(), byTitle: new Map() }; + return { + byId: new Map(), + byWork: new Map(), + complete: false, + unavailable: true, + }; }); owned.set(format, { at: Date.now(), p }); return p; @@ -418,11 +424,11 @@ export function useOwnedBooks(format: BookFormat | null) { }, [format]); return m; } -// The book's state in one format: SeerrNG's own answer when it says the book is there, -// else the matching owned-list row (same book id, same Hardcover work, or for an Open -// Library work an unambiguous title and author) +// The book's state in one format: SeerrNG's resolved answer when present, else the +// matching owned-list row by exact identifier or Hardcover work identity. Open Library +// catalogue entries must be resolved by the server; title similarity can conflate editions. export function bookMediaWithLibrary( - b: Pick, + b: Pick, format: BookFormat, lib: OwnedBooks | null ): MediaInfo | undefined { @@ -431,10 +437,7 @@ export function bookMediaWithLibrary( const work = ownedWorkKey(b.id); const match = lib.byId.get(b.id) ?? - (work ? lib.byWork.get(work) : undefined) ?? - (isOpenLibraryWork(b.id) - ? lib.byTitle.get(titleKey(b.title, b.author)) || undefined - : undefined); + (work ? lib.byWork.get(work) : undefined); return match ?? mi; } diff --git a/lumen/src/pages/Books.tsx b/lumen/src/pages/Books.tsx index 5b32689..d479b9d 100644 --- a/lumen/src/pages/Books.tsx +++ b/lumen/src/pages/Books.tsx @@ -26,17 +26,37 @@ export default function Books({ format }: { format: 'ebook' | 'audiobook' }) { // the popular feed doesn't say which books are owned: "Not yet" checks the owned list const library = useOwnedBooks(format); const waiting = show === 'not' && !library; + const incompleteLibrary = + show === 'not' && !!library && !library.complete; + const mediaFor = (b: BookResult) => + bookMediaWithLibrary(b, format, library); + const identityUnknown = (b: BookResult) => + b.provider === 'openlibrary' && !!library?.complete && !mediaFor(b); const have = (b: BookResult) => - bookMediaWithLibrary(b, format, library)?.status === MediaStatus.AVAILABLE; - const raw = waiting - ? [] - : list.items - .map((b) => ({ ...b, bookFormat: format })) - .filter((b) => - show === 'have' ? have(b) : show === 'not' ? !have(b) : true - ); + mediaFor(b)?.status === MediaStatus.AVAILABLE; + const unknownCount = + show === 'not' && library?.complete + ? list.items.filter(identityUnknown).length + : 0; + const raw = + waiting || incompleteLibrary + ? [] + : list.items + .map((b) => ({ ...b, bookFormat: format })) + .filter((b) => + show === 'have' + ? have(b) + : show === 'not' + ? !identityUnknown(b) && !!mediaFor(b) && !have(b) + : true + ); const withCovers = useEbookCovers(raw); const items = audio ? raw : withCovers; + const hiddenOnlyPage = + show === 'not' && + unknownCount > 0 && + items.length === 0 && + !list.done; const upd = (v: string) => { const n = new URLSearchParams(p); if (v === 'all') n.delete('show'); @@ -103,8 +123,28 @@ export default function Books({ format }: { format: 'ebook' | 'audiobook' }) { {list.error}
)} + {incompleteLibrary && ( +
+ + {library?.unavailable + ? 'Library Check Unavailable' + : 'Library Check Incomplete'} + + {library?.unavailable + ? 'Refresh the page to try the library check again.' + : 'The library exceeds the check limit. Not yet results are hidden to avoid mislabeling owned books.'} +
+ )} + {unknownCount > 0 && !incompleteLibrary && ( +
+ Some books on this page are hidden because their library status + could not be confirmed. Switch to All to review them. +
+ )} {!list.loading && !waiting && + !incompleteLibrary && + unknownCount === 0 && !list.error && list.page > 0 && items.length === 0 && ( @@ -114,23 +154,46 @@ export default function Books({ format }: { format: 'ebook' | 'audiobook' }) { : 'No books match this view.'}
)} - {(waiting || (list.loading && list.page === 0)) && !items.length && ( -
- -
- )} + {((waiting && !library) || (list.loading && list.page === 0)) && + !items.length && ( +
+ +
+ )}
{items.map((b) => ( ))}
- + {hiddenOnlyPage ? ( +
+ {list.error && list.retry ? ( + <> + Couldn’t load more.{' '} + + + ) : list.loading ? ( + 'Loading more…' + ) : ( + + )} +
+ ) : ( + + )} ); diff --git a/lumen/src/pages/Requests.tsx b/lumen/src/pages/Requests.tsx index 02b3083..9768aa9 100644 --- a/lumen/src/pages/Requests.tsx +++ b/lumen/src/pages/Requests.tsx @@ -449,21 +449,51 @@ async function startedDownloads(query = '') { (first.pageInfo?.pages || 1) > 1 ? (await page(100)).results : []; return first.results.concat(more).filter((r) => progress(r) > 0); } -// All of one person's requests, newest first, a page of the list at a time (one call each), so -// the tab counts are the real totals and nothing falls out of every tab. Row detail lookups stay -// limited separately (describeAll). +// All of one person's requests, newest first, so the tab counts are the real totals and nothing +// falls out of every tab. Keep each request page small for status accuracy, with bounded +// concurrency so large histories don't issue hundreds of sequential round trips. const OWN_PAGE = 15; const OWN_MAX = 2000; // a safety stop, not a limit anyone should meet +const OWN_PAGE_CONCURRENCY = 4; async function ownRequests(userId: number): Promise { - const out: RequestItem[] = []; - for (let skip = 0; skip < OWN_MAX; skip += OWN_PAGE) { - const d = await requestList( + const page = (skip: number) => + requestList( `/request?take=${OWN_PAGE}&skip=${skip}&filter=all&sort=added&requestedBy=${userId}` ); - out.push(...d.results); - const total = d.pageInfo?.results; - if (d.results.length < OWN_PAGE) break; - if (total !== undefined && out.length >= total) break; + const first = await page(0); + const out = [...first.results]; + let total = first.pageInfo?.results; + let skip = OWN_PAGE; + while ( + first.results.length === OWN_PAGE && + out.length < OWN_MAX && + (total === undefined || out.length < total) + ) { + const remaining = Math.min(OWN_MAX, total ?? OWN_MAX) - out.length; + const pageCount = Math.min( + OWN_PAGE_CONCURRENCY, + Math.ceil(remaining / OWN_PAGE) + ); + const pages = await Promise.all( + Array.from({ length: pageCount }, (_, index) => + page(skip + index * OWN_PAGE) + ) + ); + let finished = false; + for (const result of pages) { + out.push(...result.results.slice(0, OWN_MAX - out.length)); + total = result.pageInfo?.results ?? total; + if ( + result.results.length < OWN_PAGE || + out.length >= OWN_MAX || + (total !== undefined && out.length >= total) + ) { + finished = true; + break; + } + } + skip += pages.length * OWN_PAGE; + if (finished) break; } return out; } diff --git a/lumen/src/pages/TvDetail.tsx b/lumen/src/pages/TvDetail.tsx index 6c9613f..a2fca71 100644 --- a/lumen/src/pages/TvDetail.tsx +++ b/lumen/src/pages/TvDetail.tsx @@ -30,11 +30,11 @@ import { } from '../components/detail'; import { ServiceNote } from '../components/extra'; import { useToast } from '../components/toast'; +import { friendlyError } from '../errors'; import { useApi, usePublicSettings } from '../hooks'; import { Icon } from '../icons'; -import { usePrefs } from '../settings'; -import { friendlyError } from '../errors'; import { scrollBehavior } from '../motion'; +import { usePrefs } from '../settings'; type SeasonState = 'have' | 'part' | 'asked' | 'none'; const stateOf = (status?: number): SeasonState => @@ -46,6 +46,21 @@ const stateOf = (status?: number): SeasonState => ? 'asked' : 'none'; +// request status: 1 waiting for approval, 2 approved (3 declined, 4 failed, 5 completed) +const live = (status?: number) => status === 1 || status === 2; + +const seasonList = (n: number[]) => { + const a = [...n].sort((x, y) => x - y); + const runs: string[] = []; + for (let i = 0; i < a.length;) { + let j = i; + while (j + 1 < a.length && a[j + 1] === a[j] + 1) j++; + runs.push(j > i ? `${a[i]}–${a[j]}` : `${a[i]}`); + i = j + 1; + } + return (a.length === 1 ? 'Season ' : 'Seasons ') + runs.join(', '); +}; + export default function TvDetail() { const serverName = useMediaServerName(); const { id } = useParams(); @@ -69,6 +84,16 @@ export default function TvDetail() { const m = new Map(); for (const s of t?.mediaInfo?.seasons || []) m.set(s.seasonNumber, stateOf(opts.is4k ? s.status4k : s.status)); + // SeerrNG only fills in mediaInfo.seasons once Sonarr has synced them, so a fresh request + // shows up in the requests alone: its seasons count as asked until then + for (const r of t?.mediaInfo?.requests || []) + if (!!r.is4k === !!opts.is4k && live(r.status)) + for (const s of r.seasons || []) + if ( + live(s.status ?? r.status) && + (m.get(s.seasonNumber) ?? 'none') === 'none' + ) + m.set(s.seasonNumber, 'asked'); return m; }, [t, opts.is4k]); const missing = seasons @@ -85,6 +110,9 @@ export default function TvDetail() { (s) => stat.get(s.seasonNumber) === 'part' ).length; const watchable = have + part > 0; + const asked = seasons.filter( + (s) => stat.get(s.seasonNumber) === 'asked' + ).length; // partial requests off on the server = it's the whole show or nothing const partialOk = pub?.partialRequestsEnabled !== false; @@ -120,8 +148,8 @@ export default function TvDetail() { : n === missing.length && n > 1 ? missing.length === seasons.length ? 'Request entire series' - : `Request ${n} missing seasons` - : `Request ${n} season${n === 1 ? '' : 's'}`; + : `Request ${n} ${asked ? 'more' : 'missing'} seasons` + : `Request ${n}${asked > 0 ? ' more' : ''} season${n === 1 ? '' : 's'}`; const toggle = (sn: number) => { if (!partialOk) return; setSel((x) => { @@ -166,13 +194,29 @@ export default function TvDetail() { : part ? `Partly on ${serverName}` : ''; + // the series' own state, as the poster glyph says it (downloading x%, requested, ...) + const mine = statusOf( + opts.is4k && t.mediaInfo + ? { + ...t.mediaInfo, + status: t.mediaInfo.status4k ?? 1, + status4k: undefined, + downloadStatus: t.mediaInfo.downloadStatus4k, + } + : t.mediaInfo, + false, + serverName + ); + const asks = (t.mediaInfo?.requests || []).filter( + (r) => !!r.is4k === !!opts.is4k && live(r.status) + ); const main = missing.length ? ( - ) : ( + ) : watchable ? ( Watch on {serverName} + ) : ( + ); - const asked = seasons.filter( - (s) => stat.get(s.seasonNumber) === 'asked' - ).length; - return (
)} + {asks.length > 0 && ( +
    + {asks.map((r) => { + const ss = (r.seasons || []) + .filter((x) => live(x.status ?? r.status)) + .map((x) => x.seasonNumber); + return ( +
  • + {r.status === 1 + ? 'Waiting for approval' + : mine?.k === 'dl' + ? mine.word + : 'Approved'} + {' · requested by '} + {r.requestedBy?.displayName || 'someone'} + {r.createdAt ? ' on ' + shortDate(r.createdAt) : ''} + {ss.length + ? ' · ' + + (ss.length === seasons.length + ? 'Entire series' + : seasonList(ss)) + : ''} +
  • + ); + })} +
+ )} {(missing.length > 0 || (pub?.series4kEnabled && canRequest4k(user?.permissions, 'tv') && diff --git a/lumen/tests/browser/book-description.spec.ts b/lumen/tests/browser/book-description.spec.ts old mode 100644 new mode 100755 diff --git a/lumen/tests/browser/book-owned.spec.ts b/lumen/tests/browser/book-owned.spec.ts index 160f8a8..7a3c6bf 100644 --- a/lumen/tests/browser/book-owned.spec.ts +++ b/lumen/tests/browser/book-owned.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; -// Shaped like SeerrNG 3.72.31: the catalogue feeds carry no mediaInfo, the owned list does. +// Shaped like SeerrNG 3.72.31: catalogues may carry server-resolved mediaInfo, while the +// owned list carries format-specific library state. const ownedAudio = 'bookshelf:1:NDI4Mjkw'; const ownedAudioWork = 'bookshelf:1:aGM6NDU5NzQ0'; const audioOnlyEbookOwned = 'bookshelf:1:MjYwNDY4'; @@ -23,6 +24,33 @@ const card = ( posterPath: 'https://covers.invalid/cover.jpg', ...extra, }); +const audioMedia = { + status: 5, + availableEbookServiceIds: [0], + availableAudiobookServiceIds: [1], + audiobookLibraryServiceId: 1, + audiobookLibraryItemId: 'abs-item', + hasPlexAudiobookFile: false, +}; +const ebookMedia = { + status: 5, + availableEbookServiceIds: [0], + availableAudiobookServiceIds: null, + hasPlexAudiobookFile: false, +}; +const audiobookOnlyMedia = { + status: 5, + availableEbookServiceIds: null, + availableAudiobookServiceIds: [1], + hasPlexAudiobookFile: false, +}; +// The catalogue knows these works through an ebook, but there is no audiobook file. +const ebookOnlyMedia = { + status: 5, + availableEbookServiceIds: [0], + availableAudiobookServiceIds: null, + hasPlexAudiobookFile: false, +}; const audioFeed = [ card( ownedAudio, @@ -40,21 +68,25 @@ const audioFeed = [ card('bookshelf:1:OTk5OTk5', 'Not Owned Recording', 'Writer Two', { provider: 'bookshelf', bookFormat: 'audiobook', + mediaInfo: ebookOnlyMedia, }), // only the ebook of this one is in the library card(audioOnlyEbookOwned, 'Wool', 'Hugh Howey', { provider: 'bookshelf', bookFormat: 'audiobook', + mediaInfo: ebookOnlyMedia, }), ]; const ebookFeed = [ card('OL15358691W', 'Throne of Glass', 'Sarah J. Maas', { provider: 'openlibrary', bookFormat: 'ebook', + mediaInfo: ebookMedia, }), card('OL17860744W', 'Project Hail Mary', 'Andy Weir', { provider: 'openlibrary', bookFormat: 'ebook', + mediaInfo: ebookMedia, }), card('OL66554W', 'Pride and Prejudice', 'Jane Austen', { provider: 'openlibrary', @@ -64,22 +96,15 @@ const ebookFeed = [ card('OL1W', 'Wool', 'Someone Else', { provider: 'openlibrary', bookFormat: 'ebook', + mediaInfo: audiobookOnlyMedia, + }), + // A similar title and same author is a different Open Library work and must not inherit + // the owned state from Project Hail Mary. + card('OL2W', 'Project Hail Mary: Illustrated Edition', 'Andy Weir', { + provider: 'openlibrary', + bookFormat: 'ebook', }), ]; -const audioMedia = { - status: 5, - availableEbookServiceIds: [0], - availableAudiobookServiceIds: [1], - audiobookLibraryServiceId: 1, - audiobookLibraryItemId: 'abs-item', - hasPlexAudiobookFile: false, -}; -const ebookMedia = { - status: 5, - availableEbookServiceIds: [0], - availableAudiobookServiceIds: null, - hasPlexAudiobookFile: false, -}; const library = { audiobook: [ card( @@ -108,7 +133,10 @@ const library = { ], }; -async function fixtures(page: Page) { +async function fixtures( + page: Page, + options: { libraryStatus?: number; libraryTotalPages?: number } = {} +) { const calls: string[] = []; await page.route('https://**/*', (r) => r.abort()); await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); @@ -145,8 +173,24 @@ async function fixtures(page: Page) { ), }); } - if (path === '/book/library') - return route.fulfill({ json: paged(library[format]) }); + if (path === '/book/library') { + if (options.libraryStatus && options.libraryStatus !== 200) + return route.fulfill({ + status: options.libraryStatus, + json: { message: 'Library unavailable' }, + }); + return route.fulfill({ + json: { + ...paged(library[format]), + page: Number(url.searchParams.get('page') || 1), + totalPages: options.libraryTotalPages ?? 1, + totalResults: + options.libraryTotalPages === undefined + ? library[format].length + : Math.max(0, (options.libraryTotalPages - 1) * 50 + 1), + }, + }); + } if (path.startsWith('/discover/genreslider/')) return route.fulfill({ json: [] }); if (path.startsWith('/discover/') || path === '/search') @@ -170,7 +214,7 @@ const expectOwned = async (page: Page, title: string, owned: boolean) => { test('audiobook cards show the books already in the library', async ({ page, -}) => { +}, info) => { const calls = await fixtures(page); await page.goto('/audiobooks'); await expect(poster(page, 'Not Owned Recording')).toHaveCount(1); @@ -187,11 +231,19 @@ test('audiobook cards show the books already in the library', async ({ await expectOwned(page, 'Not Owned Recording', false); // owning the ebook is not owning the audiobook await expectOwned(page, 'Wool', false); + await page.screenshot({ + path: info.outputPath('audiobooks-all.png'), + fullPage: true, + }); await page.getByRole('button', { name: 'Not yet', exact: true }).click(); await expect(poster(page, 'Not Owned Recording')).toHaveCount(1); await expect(poster(page, 'Wool')).toHaveCount(1); await expect(page.locator('.grid .poster')).toHaveCount(2); + await page.screenshot({ + path: info.outputPath('audiobooks-not-yet.png'), + fullPage: true, + }); await page.getByRole('button', { name: 'In library', exact: true }).click(); await expect(page.locator('.grid .poster')).toHaveCount(2); @@ -203,9 +255,9 @@ test('audiobook cards show the books already in the library', async ({ ).toBeLessThanOrEqual(2); }); -test('ebook cards from Open Library match owned books by title and author', async ({ +test('ebook cards use server-resolved Open Library identity without title guesses', async ({ page, -}) => { +}, info) => { await fixtures(page); await page.goto('/books'); await expect(poster(page, 'Pride and Prejudice')).toHaveCount(1); @@ -213,11 +265,38 @@ test('ebook cards from Open Library match owned books by title and author', asyn await expectOwned(page, 'Project Hail Mary', true); await expectOwned(page, 'Pride and Prejudice', false); await expectOwned(page, 'Wool', false); + await expectOwned(page, 'Project Hail Mary: Illustrated Edition', false); + await expect( + poster(page, 'Pride and Prejudice').locator('.glyph.wait') + ).toHaveAttribute('title', 'Library status unknown'); + const unknownBook = poster(page, 'Pride and Prejudice'); + if (info.project.name === 'desktop') { + await unknownBook.hover(); + await expect( + unknownBook.getByRole('button', { name: 'Request anyway' }) + ).toBeVisible(); + } else { + await expect( + unknownBook.getByRole('button', { + name: 'Request Pride and Prejudice anyway; library status is unknown', + }) + ).toBeVisible(); + } + await page.screenshot({ + path: info.outputPath('ebooks-all.png'), + fullPage: true, + }); await page.getByRole('button', { name: 'Not yet', exact: true }).click(); - await expect(page.locator('.grid .poster')).toHaveCount(2); - await expect(poster(page, 'Pride and Prejudice')).toHaveCount(1); + await expect(page.locator('.note[role="status"]')).toContainText( + 'library status could not be confirmed' + ); + await expect(page.locator('.grid .poster')).toHaveCount(1); await expect(poster(page, 'Wool')).toHaveCount(1); + await page.screenshot({ + path: info.outputPath('ebooks-not-yet.png'), + fullPage: true, + }); await page.getByRole('button', { name: 'In library', exact: true }).click(); await expect(page.locator('.grid .poster')).toHaveCount(3); @@ -248,7 +327,44 @@ test('Discover rows and search results mark owned books', async ({ page }) => { await expectOwned(page, 'Throne of Glass', true); }); -test('a book SeerrNG already marks keeps its state when the owned list is unavailable', async ({ +test('Not yet hides results when the library check fails', async ({ page }) => { + await fixtures(page, { libraryStatus: 503 }); + await page.goto('/books?show=not'); + + await expect(page.getByRole('alert')).toContainText( + 'Library Check Unavailable' + ); + await expect(page.locator('.grid .poster')).toHaveCount(0); +}); + +test('Not yet hides results when the library exceeds the bounded scan', async ({ + page, +}) => { + const calls = await fixtures(page, { libraryTotalPages: 21 }); + await page.goto('/books?show=not'); + + await expect(page.getByRole('alert')).toContainText( + 'Library Check Incomplete' + ); + await expect(page.locator('.grid .poster')).toHaveCount(0); + expect( + calls.filter((call) => call.startsWith('/discover/books')) + ).toHaveLength(1); +}); + +test('Not yet hides results when library pagination is malformed', async ({ + page, +}) => { + await fixtures(page, { libraryTotalPages: 0 }); + await page.goto('/books?show=not'); + + await expect(page.getByRole('alert')).toContainText( + 'Library Check Unavailable' + ); + await expect(page.locator('.grid .poster')).toHaveCount(0); +}); + +test('server status stays visible while Not yet hides unverified books when the library is unavailable', async ({ page, }) => { await fixtures(page); @@ -272,6 +388,8 @@ test('a book SeerrNG already marks keeps its state when the owned list is unavai await expectOwned(page, 'Marked By Server', true); await expectOwned(page, 'Unmarked', false); await page.getByRole('button', { name: 'Not yet', exact: true }).click(); - await expect(page.locator('.grid .poster')).toHaveCount(1); - await expect(poster(page, 'Unmarked')).toHaveCount(1); + await expect(page.getByRole('alert')).toContainText( + 'Library Check Unavailable' + ); + await expect(page.locator('.grid .poster')).toHaveCount(0); }); diff --git a/lumen/tests/browser/coming-soon-titles.spec.ts b/lumen/tests/browser/coming-soon-titles.spec.ts old mode 100644 new mode 100755 diff --git a/lumen/tests/browser/discover-feature-meta.spec.ts b/lumen/tests/browser/discover-feature-meta.spec.ts old mode 100644 new mode 100755 diff --git a/lumen/tests/browser/narrations.spec.ts b/lumen/tests/browser/narrations.spec.ts old mode 100644 new mode 100755 diff --git a/lumen/tests/browser/narrow-short-nav.spec.ts b/lumen/tests/browser/narrow-short-nav.spec.ts old mode 100644 new mode 100755 diff --git a/lumen/tests/browser/quick-focus.spec.ts b/lumen/tests/browser/quick-focus.spec.ts old mode 100644 new mode 100755 diff --git a/lumen/tests/browser/quick-sheet-short.spec.ts b/lumen/tests/browser/quick-sheet-short.spec.ts old mode 100644 new mode 100755 diff --git a/lumen/tests/browser/release-dates.spec.ts b/lumen/tests/browser/release-dates.spec.ts old mode 100644 new mode 100755 diff --git a/lumen/tests/browser/requests-editions.spec.ts b/lumen/tests/browser/requests-editions.spec.ts old mode 100644 new mode 100755 diff --git a/lumen/tests/browser/requests-narrow-actions.spec.ts b/lumen/tests/browser/requests-narrow-actions.spec.ts old mode 100644 new mode 100755 diff --git a/lumen/tests/browser/requests-pages.spec.ts b/lumen/tests/browser/requests-pages.spec.ts index 60e5dca..daa330a 100644 --- a/lumen/tests/browser/requests-pages.spec.ts +++ b/lumen/tests/browser/requests-pages.spec.ts @@ -4,6 +4,8 @@ import { expect, test, type Page } from '@playwright/test'; // so request lists are fetched in pages small enough that every title keeps its real status. async function fixture(page: Page, count = 40) { const takes: number[] = []; + let activeOwnerPages = 0; + let maxConcurrentOwnerPages = 0; const all = Array.from({ length: count }, (_, i) => ({ id: i + 1, type: 'movie', @@ -16,7 +18,7 @@ async function fixture(page: Page, count = 40) { await page.route('https://**/*', (r) => r.abort()); await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); await page.route('**/seerr-classic/**', (r) => r.fulfill({ status: 404 })); - await page.route('**/api/v1/**', (r) => { + await page.route('**/api/v1/**', async (r) => { const url = new URL(r.request().url()); const path = url.pathname.slice(7); if (path === '/auth/me') @@ -31,15 +33,29 @@ async function fixture(page: Page, count = 40) { takes.push(take); const filter = url.searchParams.get('filter'); const list = filter === 'pending' || filter === 'processing' ? [] : all; - return r.fulfill({ - json: { - pageInfo: { - pages: Math.ceil(list.length / take), - results: list.length, + const ownerPage = + filter === 'all' && url.searchParams.get('requestedBy') === '1'; + if (ownerPage) { + activeOwnerPages++; + maxConcurrentOwnerPages = Math.max( + maxConcurrentOwnerPages, + activeOwnerPages + ); + } + try { + await new Promise((resolve) => setTimeout(resolve, 10)); + return await r.fulfill({ + json: { + pageInfo: { + pages: Math.ceil(list.length / take), + results: list.length, + }, + results: list.slice(skip, skip + take), }, - results: list.slice(skip, skip + take), - }, - }); + }); + } finally { + if (ownerPage) activeOwnerPages--; + } } if (path.startsWith('/movie/')) return r.fulfill({ @@ -47,14 +63,14 @@ async function fixture(page: Page, count = 40) { }); return r.fulfill({ json: {} }); }); - return takes; + return { takes, maxConcurrentOwnerPages: () => maxConcurrentOwnerPages }; } for (const everyone of [false, true]) { test(`${everyone ? 'Everyone' : 'My requests'} asks for small pages so availability stays verified`, async ({ page, }) => { - const takes = await fixture(page); + const { takes } = await fixture(page); await page.goto(everyone ? '/requests?tab=everyone' : '/requests?tab=all'); await expect(page.locator('.rq').first()).toBeVisible(); await expect @@ -68,8 +84,16 @@ for (const everyone of [false, true]) { test('My requests loads every page so the counts are the real totals', async ({ page, }) => { - await fixture(page, 47); + const { maxConcurrentOwnerPages } = await fixture(page, 100); await page.goto('/requests?tab=all'); - await expect(page.getByRole('button', { name: /^All\s*47$/ })).toBeVisible(); - await expect(page.locator('.rq')).toHaveCount(47); + await expect(page.getByRole('button', { name: /^All\s*100$/ })).toBeVisible(); + await expect(page.locator('.rq')).toHaveCount(100); + await expect(page.locator('.rq').first().getByRole('link')).toHaveText( + 'Title 1000' + ); + await expect(page.locator('.rq').last().getByRole('link')).toHaveText( + 'Title 1099' + ); + expect(maxConcurrentOwnerPages()).toBeGreaterThan(1); + expect(maxConcurrentOwnerPages()).toBeLessThanOrEqual(4); }); diff --git a/lumen/tests/browser/touch-all-chip.spec.ts b/lumen/tests/browser/touch-all-chip.spec.ts old mode 100644 new mode 100755 diff --git a/lumen/tests/browser/tv-request-state.spec.ts b/lumen/tests/browser/tv-request-state.spec.ts new file mode 100755 index 0000000..4b47953 --- /dev/null +++ b/lumen/tests/browser/tv-request-state.spec.ts @@ -0,0 +1,133 @@ +import { expect, test, type Page } from '@playwright/test'; + +// shaped like the live responses: right after a request SeerrNG returns the request (with its +// seasons) but no mediaInfo.seasons yet (those arrive once Sonarr has synced) +const seasons = [ + { + seasonNumber: 0, + episodeCount: 36, + name: 'Specials', + airDate: '2022-09-03', + }, + { seasonNumber: 1, episodeCount: 8, name: 'Season 1', airDate: '2022-09-01' }, + { seasonNumber: 2, episodeCount: 8, name: 'Season 2', airDate: '2024-08-29' }, + { seasonNumber: 3, episodeCount: 8, name: 'Season 3', airDate: '2026-11-11' }, +]; +const req = (nums: number[], status = 2) => ({ + id: 444, + status, + is4k: false, + createdAt: '2026-11-12T05:20:57.000Z', + requestedBy: { id: 1, displayName: 'alex' }, + seasons: nums.map((n) => ({ seasonNumber: n, status })), +}); + +async function show( + page: Page, + mediaInfo: Record, + posts: string[] = [] +) { + await page.route('https://**/*', (r) => r.abort()); + await page.route('**/lumen-config.json', (r) => r.fulfill({ json: {} })); + await page.route('**/seerr-classic/**', (r) => r.fulfill({ status: 404 })); + await page.route('**/api/v1/**', (r) => { + const path = new URL(r.request().url()).pathname.slice(7); + if (r.request().method() !== 'GET') posts.push(path); + if (path === '/auth/me') + return r.fulfill({ + json: { id: 1, displayName: 'alex', permissions: 2 }, + }); + if (path === '/settings/public') return r.fulfill({ json: {} }); + if (path === '/tv/84773') + return r.fulfill({ + json: { + id: 84773, + name: 'The Rings of Power', + overview: 'Rings.', + genres: [], + seasons, + numberOfSeasons: 3, + firstAirDate: '2022-09-01', + mediaInfo, + }, + }); + return r.fulfill({ json: { results: [] } }); + }); + await page.goto('/tv/84773'); +} + +test('a fully requested series shows Requested and its request, not the request button', async ({ + page, +}) => { + await show(page, { + id: 2074, + status: 3, + status4k: 1, + downloadStatus: [], + requests: [req([1, 2, 3])], + seasons: [], + }); + await expect( + page.getByRole('button', { name: 'Requested', exact: true }) + ).toBeVisible(); + await expect( + page.getByRole('button', { name: /^Request(?!ed)/ }) + ).toHaveCount(0); + await expect(page.locator('.rinfo')).toContainText( + 'Approved · requested by alex on' + ); + await expect(page.locator('.rinfo')).toContainText('Entire series'); + const rows = page.locator('.srow'); + await expect(rows).toHaveCount(3); + for (let i = 0; i < 3; i++) { + await expect(rows.nth(i)).toHaveClass(/locked/); + await expect(rows.nth(i)).toContainText('Requested'); + } +}); + +test('a partly requested series keeps the other seasons requestable', async ({ + page, +}) => { + const posts: string[] = []; + await show( + page, + { + id: 2074, + status: 3, + downloadStatus: [], + requests: [req([1])], + seasons: [], + }, + posts + ); + await expect(page.locator('.rinfo')).toContainText('Season 1'); + const rows = page.locator('.srow'); + await expect(rows.nth(0)).toHaveClass(/locked/); + await expect(rows.nth(1)).not.toHaveClass(/locked/); + await page.getByRole('button', { name: 'All missing (2)' }).click(); + await expect(rows.nth(1)).toHaveAttribute('aria-checked', 'true'); + await expect( + page.getByRole('button', { name: 'Request 2 more seasons' }) + ).toBeEnabled(); + expect(posts).toEqual([]); +}); + +test('a fully available series says so and offers no request', async ({ + page, +}) => { + await show(page, { + id: 2074, + status: 5, + mediaUrl: + 'https://app.plex.tv/desktop#!/server/x/details?key=%2Flibrary%2Fmetadata%2F1', + downloadStatus: [], + requests: [], + seasons: [1, 2, 3].map((n) => ({ seasonNumber: n, status: 5 })), + }); + await expect(page.getByRole('link', { name: /Watch on Plex/ })).toBeVisible(); + await expect( + page.getByRole('button', { name: /^Request(?!ed)/ }) + ).toHaveCount(0); + await expect(page.locator('.srow').first()).toContainText('On Plex'); + await expect(page.locator('.rinfo')).toHaveCount(0); +});