From 77c3b298abe5aec2e801f41919cb138f689e39d7 Mon Sep 17 00:00:00 2001 From: Ncbudd <209378361+ncbudd@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:37:25 -0500 Subject: [PATCH] [SeerrNG #607] Request in 4K from posters and Advanced options --- lumen/src/components/cards.tsx | 12 +- lumen/src/components/detail.tsx | 44 +++-- lumen/src/components/requestSheet.tsx | 99 ++++++++--- lumen/src/pages/MovieDetail.tsx | 39 +++-- lumen/src/pages/Soon.tsx | 10 +- lumen/src/pages/TvDetail.tsx | 13 +- lumen/tests/browser/admin-options.spec.ts | 204 ++++++++++++++++++++++ 7 files changed, 366 insertions(+), 55 deletions(-) diff --git a/lumen/src/components/cards.tsx b/lumen/src/components/cards.tsx index 42db420..b51e72b 100644 --- a/lumen/src/components/cards.tsx +++ b/lumen/src/components/cards.tsx @@ -173,9 +173,11 @@ export function TitleCard({ it, meta }: { it: TitleResult; meta?: string }) { toast(e instanceof Error ? e.message : 'Request failed'); } }; - const quick = useQuickRequest(it, go, () => { + const quick = useQuickRequest(it, go, (is4k) => { markRequested(); - toast(`Requested ${titleOf(it)}${tv ? ' · entire series' : ''}`); + toast( + `Requested ${titleOf(it)}${is4k ? ' in 4K' : ''}${tv ? ' · entire series' : ''}` + ); }); let qb: ReactNode; const plex = plexUrl(it.mediaInfo); @@ -394,9 +396,11 @@ export function FeatCard({ toast(e instanceof Error ? e.message : 'Request failed'); } }; - const quick = useQuickRequest(it, go, () => { + const quick = useQuickRequest(it, go, (is4k) => { setDone(true); - toast(`Requested ${titleOf(it)}${tv ? ' · entire series' : ''}`); + toast( + `Requested ${titleOf(it)}${is4k ? ' in 4K' : ''}${tv ? ' · entire series' : ''}` + ); }); return (
diff --git a/lumen/src/components/detail.tsx b/lumen/src/components/detail.tsx index f9bd88e..b63db8d 100644 --- a/lumen/src/components/detail.tsx +++ b/lumen/src/components/detail.tsx @@ -161,6 +161,9 @@ interface ServerDetail { // who may pick the Radarr/Sonarr server, quality and folder for a request export const canChooseRequestOptions = (permissions?: number) => ((permissions ?? 0) & MANAGE) !== 0; +// who may request in 4K: Admin, Request 4K, or Request 4K Movies / Request 4K Series +export const canRequest4k = (permissions: number | undefined, kind: 'movie' | 'tv') => + ((permissions ?? 0) & (2 | 1024 | (kind === 'movie' ? 2048 : 4096))) !== 0; // who may make a request in someone else's name: SeerrNG wants Manage Users and Manage // Requests together (or Admin) export const canRequestAs = (permissions?: number) => { @@ -179,9 +182,12 @@ export function AdvancedOptions({ inline = false, onReady, initial, + allow4k = false, }: { kind: 'movie' | 'tv'; is4k: boolean; + // also list the 4K servers (for people who may request 4K); picking one makes it a 4K request + allow4k?: boolean; onChange: (o: RequestOptions) => void; // changing an existing request: start from its server, quality, folder and requester initial?: RequestOptions; @@ -210,16 +216,19 @@ export function AdvancedOptions({ if (!open || servers) return; api(`/service/${svc}`) .then((s) => { - const mine = s.filter((x) => x.is4k === is4k); + const mine = allow4k + ? [...s].sort((a, b) => Number(a.is4k) - Number(b.is4k)) + : s.filter((x) => x.is4k === is4k); setServers(mine); const d = mine.find((x) => x.id === initial?.serverId) || - mine.find((x) => x.isDefault) || + mine.find((x) => x.isDefault && x.is4k === is4k) || + mine.find((x) => x.is4k === is4k) || mine[0]; if (d) setO((x) => ({ ...x, serverId: d.id })); }) .catch((e) => setErr(e.message)); - }, [open, servers, svc, is4k]); + }, [open, servers, svc, is4k, allow4k]); useEffect(() => { if (o.serverId == null) return; let live = true; @@ -261,19 +270,21 @@ export function AdvancedOptions({ live = false; }; }, [o.serverId, svc]); - useEffect( - () => - onChange( - open - ? // a new request is the admin's own unless someone else is picked; a request being - // changed always says whose it is (picking "Me" moves it to the admin) - asUser && (asUser !== user?.id || initial?.userId !== undefined) - ? { ...o, userId: asUser } - : o - : {} - ), - [o, open, asUser, user?.id, initial?.userId, onChange] - ); + // with 4K servers listed, the chosen server decides whether this is a 4K request + const picked4k = allow4k + ? servers?.find((x) => x.id === o.serverId)?.is4k + : undefined; + useEffect(() => { + if (!open) return onChange({}); + const base = picked4k === undefined ? o : { ...o, is4k: picked4k }; + onChange( + // a new request is the admin's own unless someone else is picked; a request being + // changed always says whose it is (picking "Me" moves it to the admin) + asUser && (asUser !== user?.id || initial?.userId !== undefined) + ? { ...base, userId: asUser } + : base + ); + }, [o, open, asUser, user?.id, initial?.userId, onChange, picked4k]); useEffect(() => { onReady?.( !!detail && @@ -318,6 +329,7 @@ export function AdvancedOptions({ {servers.map((s) => ( ))} diff --git a/lumen/src/components/requestSheet.tsx b/lumen/src/components/requestSheet.tsx index 47745b3..09d23ee 100644 --- a/lumen/src/components/requestSheet.tsx +++ b/lumen/src/components/requestSheet.tsx @@ -9,8 +9,13 @@ import { type TitleResult, } from '../api'; import { useAuth } from '../auth'; +import { usePublicSettings } from '../hooks'; import { usePrefs } from '../settings'; -import { AdvancedOptions, canChooseRequestOptions } from './detail'; +import { + AdvancedOptions, + canChooseRequestOptions, + canRequest4k, +} from './detail'; // Poster quick requests are one click for most people. People who may choose the // Radarr/Sonarr server, quality and folder get this sheet first (unless the @@ -21,6 +26,16 @@ export function useAsksRequestOptions() { return prefs.requestOptions && canChooseRequestOptions(user?.permissions); } +// People who may request in 4K pick 1080p or 4K first (when the server has 4K for that kind). +export function useOffers4k(kind: 'movie' | 'tv') { + const { user } = useAuth(); + const pub = usePublicSettings(); + return ( + !!(kind === 'movie' ? pub?.movie4kEnabled : pub?.series4kEnabled) && + canRequest4k(user?.permissions, kind) + ); +} + // a just-closed sheet is about to undo its history step (one sheet is open at a time) let pendingBack = false; @@ -69,24 +84,31 @@ export function RequestSheet({ }: { it: TitleResult; onClose: () => void; - onDone: () => void; // request sent + onDone: (is4k: boolean) => void; // request sent }) { const tv = it.mediaType === 'tv'; + const asks = useAsksRequestOptions(); + const offers4k = useOffers4k(tv ? 'tv' : 'movie'); + const [fourK, setFourK] = useState(false); const [opts, setOpts] = useState({}); const [busy, setBusy] = useState(false); const [ready, setReady] = useState(false); const [err, setErr] = useState(''); const onOpts = useCallback((o: RequestOptions) => setOpts(o), []); useBackCloses(onClose); + // without server choices there's nothing to wait for + const canSend = asks ? ready : true; const send = async () => { - if (busy || !ready) return; + if (busy || !canSend) return; setBusy(true); setErr(''); + const base = asks ? opts : {}; + const body = offers4k && fourK ? { ...base, is4k: true } : base; try { await (tv - ? requestSeries(it.id, 'all', opts) - : requestMovie(it.id, opts)); - onDone(); + ? requestSeries(it.id, 'all', body) + : requestMovie(it.id, body)); + onDone(!!body.is4k); } catch (e) { setErr(e instanceof Error ? e.message : 'Request failed'); setBusy(false); @@ -110,17 +132,44 @@ export function RequestSheet({

Request {titleOf(it)}

- {tv ? 'Entire series · ' : ''}Choose where it goes + {tv ? 'Entire series · ' : ''} + {asks ? 'Choose where it goes' : 'Choose the quality'}
- + {offers4k && ( +
+ {( + [ + [false, '1080p'], + [true, '4K'], + ] as [boolean, string][] + ).map(([v, t]) => ( + + ))} +
+ )} + {asks && ( + // keyed by quality: the 4K choice lists the 4K servers with their own defaults + + )} {err && (

{err} @@ -129,10 +178,14 @@ export function RequestSheet({

); + // offered whether or not the HD copy is here or requested: they're separate files const fourK = - pub?.movie4kEnabled && - can4k && - s?.k === 'ok' && - !(s4k && s4k >= MediaStatus.PENDING) ? ( + pub?.movie4kEnabled && can4k && !has4k ? (
{missing.length > 0 && ( - + )}
diff --git a/lumen/tests/browser/admin-options.spec.ts b/lumen/tests/browser/admin-options.spec.ts index 81f3d26..74bea11 100644 --- a/lumen/tests/browser/admin-options.spec.ts +++ b/lumen/tests/browser/admin-options.spec.ts @@ -69,6 +69,7 @@ async function fixtures( json = [ { id: 0, name: 'Radarr HD', is4k: false, isDefault: true }, { id: 1, name: 'Radarr Kids', is4k: false, isDefault: false }, + { id: 2, name: 'Radarr UHD', is4k: true, isDefault: true }, ]; else if (/^\/service\/radarr\/\d$/.test(path)) json = { @@ -1277,6 +1278,209 @@ test('request managers without Manage Users get no Request as', async ({ expect(asked).toEqual([]); }); +test('Request 4K is offered before the HD copy exists, and goes to the 4K server', async ({ + page, +}) => { + const { posts } = await fixtures(page, { permissions: 2 }); + await page.route('**/api/v1/settings/public', (route) => + route.fulfill({ + json: { + applicationTitle: 'Seerr', + mediaServerType: 2, + mediaServerLogin: true, + localLogin: true, + enabledMediaCategories: {}, + movie4kEnabled: true, + }, + }) + ); + let status4k: number | undefined; + await page.route('**/api/v1/movie/302', (route) => + route.fulfill({ + json: { + ...details301, + id: 302, + title: 'Fresh Pick', + mediaInfo: status4k ? { id: 2, status: 1, status4k } : undefined, + }, + }) + ); + await page.route('**/api/v1/movie/302/recommendations', (route) => + route.fulfill({ json: paged([]) }) + ); + await page.goto('/movie/302'); + await expect(page.locator('.dtitle h1')).toHaveText('Fresh Pick'); + const fourK = page + .getByRole('button', { name: 'Request 4K' }) + .filter({ visible: true }) + .first(); + await expect( + page.getByRole('button', { name: 'Request', exact: true }) + ).toBeVisible(); + await expect(fourK).toBeVisible(); + // the admin's Advanced options belong to the HD server; the 4K request must not reuse them + await page.getByText('Advanced (admins)').click(); + await expect(page.getByLabel('Folder')).toBeVisible(); + status4k = 2; + await fourK.click(); + await expect + .poll(() => posts.filter((p) => p.path === '/request').length) + .toBe(1); + const body = posts.find((p) => p.path === '/request')!.body; + expect(body).toMatchObject({ mediaType: 'movie', mediaId: 302, is4k: true }); + expect(body.serverId).toBeUndefined(); + expect(body.profileId).toBeUndefined(); + expect(body.rootFolder).toBeUndefined(); + // once requested, the button goes and the facts say so + await expect( + page.getByRole('button', { name: 'Request 4K' }).filter({ visible: true }) + ).toHaveCount(0); + await expect(page.locator('aside')).toContainText('Requested'); +}); + +const with4k = (page: Page) => + page.route('**/api/v1/settings/public', (route) => + route.fulfill({ + json: { + applicationTitle: 'Seerr', + mediaServerType: 2, + mediaServerLogin: true, + localLogin: true, + enabledMediaCategories: {}, + movie4kEnabled: true, + }, + }) + ); + +test('poster request asks 1080p or 4K for people who may request 4K', async ({ + page, +}) => { + // Request + Request 4K, no Manage Requests: no server choices, just the quality + const { posts } = await fixtures(page, { permissions: 32 | 1024 }); + await with4k(page); + await page.goto('/'); + await quickRequest(page, 'Fresh Pick'); + const sheet = page.getByRole('dialog', { name: 'Request Fresh Pick' }); + const quality = sheet.getByRole('group', { name: 'Quality' }); + await expect(quality.getByRole('button', { name: '1080p' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect(sheet.getByLabel('Server')).toHaveCount(0); + await quality.getByRole('button', { name: '4K' }).click(); + await sheet.getByRole('button', { name: 'Request in 4K' }).click(); + await expect + .poll(() => posts.filter((p) => p.path === '/request').length) + .toBe(1); + const body = posts.find((p) => p.path === '/request')!.body; + expect(body).toMatchObject({ mediaType: 'movie', mediaId: 302, is4k: true }); + expect(body.serverId).toBeUndefined(); + await expect(sheet).toHaveCount(0); +}); + +test('poster request stays one tap for people without 4K', async ({ page }) => { + const { posts } = await fixtures(page, { permissions: 32 }); + await with4k(page); + await page.goto('/'); + await quickRequest(page, 'Fresh Pick'); + await expect + .poll(() => posts.filter((p) => p.path === '/request').length) + .toBe(1); + const body = posts.find((p) => p.path === '/request')!.body; + expect(body).toMatchObject({ mediaId: 302 }); + expect(body.is4k).toBeFalsy(); + await expect(page.getByRole('dialog')).toHaveCount(0); +}); + +test('an admin choosing 4K on a poster gets the 4K servers', async ({ + page, +}) => { + const { posts } = await fixtures(page, { permissions: 2 }); + await with4k(page); + await page.goto('/'); + await quickRequest(page, 'Fresh Pick'); + const sheet = page.getByRole('dialog', { name: 'Request Fresh Pick' }); + await expect(sheet.getByLabel('Server')).toContainText('Radarr HD'); + await expect(sheet.getByLabel('Server')).not.toContainText('Radarr UHD'); + await sheet + .getByRole('group', { name: 'Quality' }) + .getByRole('button', { name: '4K' }) + .click(); + await expect(sheet.getByLabel('Server')).toContainText('Radarr UHD'); + await expect(sheet.getByLabel('Server')).not.toContainText('Radarr HD'); + await expect(sheet.getByLabel('Folder')).toBeVisible(); + await sheet.getByRole('button', { name: 'Request in 4K' }).click(); + await expect + .poll(() => posts.filter((p) => p.path === '/request').length) + .toBe(1); + expect(posts.find((p) => p.path === '/request')!.body).toMatchObject({ + is4k: true, + serverId: 2, + }); +}); + +test('Advanced options on a movie list the 4K server, and picking it requests 4K', async ({ + page, +}) => { + const { posts } = await fixtures(page, { permissions: 2 }); + await with4k(page); + await page.route('**/api/v1/movie/302', (route) => + route.fulfill({ + json: { ...details301, id: 302, title: 'Fresh Pick', mediaInfo: undefined }, + }) + ); + await page.route('**/api/v1/movie/302/recommendations', (route) => + route.fulfill({ json: paged([]) }) + ); + await page.goto('/movie/302'); + await page.getByText('Advanced (admins)').click(); + const server = page.getByLabel('Server'); + await expect(server).toContainText('Radarr UHD'); + await expect(server.locator('option[value="2"]')).toHaveText( + 'Radarr UHD · 4K (default)' + ); + await server.selectOption('2'); + await expect(page.getByLabel('Folder')).toBeVisible(); + await page.getByRole('button', { name: 'Request', exact: true }).click(); + await expect + .poll(() => posts.filter((p) => p.path === '/request').length) + .toBe(1); + expect(posts.find((p) => p.path === '/request')!.body).toMatchObject({ + is4k: true, + serverId: 2, + }); +}); + +test('Request 4K stays hidden from people without 4K permission', async ({ + page, +}) => { + await fixtures(page, { permissions: 32 }); + await page.route('**/api/v1/settings/public', (route) => + route.fulfill({ + json: { + applicationTitle: 'Seerr', + mediaServerType: 2, + localLogin: true, + enabledMediaCategories: {}, + movie4kEnabled: true, + }, + }) + ); + await page.route('**/api/v1/movie/302', (route) => + route.fulfill({ + json: { ...details301, id: 302, title: 'Fresh Pick', mediaInfo: undefined }, + }) + ); + await page.route('**/api/v1/movie/302/recommendations', (route) => + route.fulfill({ json: paged([]) }) + ); + await page.goto('/movie/302'); + await expect( + page.getByRole('button', { name: 'Request', exact: true }) + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Request 4K' })).toHaveCount(0); +}); + test('a title in the library shows the check on its page too', async ({ page, }) => {