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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions lumen/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -98,6 +106,7 @@ export interface TitleResult {
export interface BookResult {
id: string;
mediaType: 'book';
provider?: 'openlibrary' | 'bookshelf';
title: string;
author?: string;
posterPath?: string | null;
Expand Down Expand Up @@ -205,10 +214,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) ||
Expand Down Expand Up @@ -483,6 +498,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;
Expand All @@ -502,6 +518,7 @@ export interface RequestItem {
is4k: boolean;
createdAt: string;
bookFormat?: string;
preferredEditionId?: string | null;
seasons: { seasonNumber: number }[];
requestedBy: { id: number; displayName: string };
media: MediaInfo & {
Expand Down
50 changes: 31 additions & 19 deletions lumen/src/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<User> | null = null;
const sharedMe = () =>
(meInFlight ??= api<User>('/auth/me').finally(() => {
meInFlight = null;
}));

export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null | undefined>(undefined);
const [unreachable, setUnreachable] = useState(false);
Expand All @@ -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<User>('/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<User>('/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 {
Expand All @@ -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 (
<Ctx.Provider value={{ user, unreachable, refresh, signOut }}>
<Fragment key={user?.id ?? 'signed-out'}>{children}</Fragment>
Expand Down
98 changes: 78 additions & 20 deletions lumen/src/components/cards.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import {
bookMediaForFormat,
englishAudioEdition,
hardcoverWorkId,
mediaLink,
Expand All @@ -18,13 +17,18 @@ import {
type TitleResult,
} from '../api';
import { useMediaServerName } from '../auth';
import { dropBookStates, useBookStates } from '../hooks';
import { friendlyError } from '../errors';
import {
bookMediaWithLibrary,
dropBookStates,
useBookStates,
useOwnedBooks,
} 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) {
Expand Down Expand Up @@ -183,8 +187,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) {
Expand Down Expand Up @@ -235,6 +246,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<HTMLButtonElement>(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')
Expand Down Expand Up @@ -269,7 +292,12 @@ export function TitleCard({ it, meta }: { it: TitleResult; meta?: string }) {
else
qb = (
<>
<button className="qbtn go" disabled={busy} onClick={request}>
<button
ref={goRef}
className="qbtn go"
disabled={busy}
onClick={request}
>
<Icon name="plus" size="sm" />
{unreleased ? 'Request early' : 'Request'}
</button>
Expand All @@ -286,7 +314,7 @@ export function TitleCard({ it, meta }: { it: TitleResult; meta?: string }) {
</>
);
return (
<div className="poster">
<div className={hold ? 'poster hold' : 'poster'}>
<div className="art">
<Cover
src={it.posterPath ? tmdbImg(it.posterPath) : null}
Expand Down Expand Up @@ -325,18 +353,40 @@ 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 [s0, markRequested] = useRequested(
statusOf(
bookMediaForFormat(b.mediaInfo, audio ? 'audiobook' : 'ebook'),
true
)
);
const format = audio ? 'audiobook' : 'ebook';
const library = useOwnedBooks(format);
const known = states?.get(b.id)?.[format];
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 () => {
Expand Down Expand Up @@ -368,9 +418,13 @@ export function BookCard({ b }: { b: BookResult }) {
/>
<Glyph s={s} />
<Link className="hit" to={href} aria-label={b.title} />
{prefs.hoverRequest && !s && straight && (
{prefs.hoverRequest && (!s || ownershipUnknown) && straight && (
<TouchQuick
label={`Request ${b.title} (${audio ? 'audiobook' : 'ebook'})`}
label={
ownershipUnknown
? `Request ${b.title} anyway; library status is unknown`
: `Request ${b.title} (${audio ? 'audiobook' : 'ebook'})`
}
onClick={request}
busy={busy}
/>
Expand Down Expand Up @@ -410,15 +464,19 @@ export function BookCard({ b }: { b: BookResult }) {
</Link>
)}
</>
) : s ? (
) : s && !ownershipUnknown ? (
<button className="qbtn done" disabled>
<Icon name={s.ico} size="sm" />
{s.word}
</button>
) : straight ? (
<button className="qbtn go" disabled={busy} onClick={request}>
<Icon name="plus" size="sm" />
{audio ? 'Request audiobook' : 'Request ebook'}
{ownershipUnknown
? 'Request anyway'
: audio
? 'Request audiobook'
: 'Request ebook'}
</button>
) : (
<Link
Expand Down
Loading
Loading