Skip to content
Open
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
12 changes: 8 additions & 4 deletions lumen/src/components/cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<div className="feat">
Expand Down
44 changes: 28 additions & 16 deletions lumen/src/components/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;
Expand Down Expand Up @@ -210,16 +216,19 @@ export function AdvancedOptions({
if (!open || servers) return;
api<Server[]>(`/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;
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -318,6 +329,7 @@ export function AdvancedOptions({
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
{allow4k && s.is4k && !/4k/i.test(s.name) ? ' · 4K' : ''}
{s.isDefault ? ' (default)' : ''}
</option>
))}
Expand Down
99 changes: 77 additions & 22 deletions lumen/src/components/requestSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand Down Expand Up @@ -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<RequestOptions>({});
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);
Expand All @@ -110,17 +132,44 @@ export function RequestSheet({
<div>
<h3>Request {titleOf(it)}</h3>
<span className="sub">
{tv ? 'Entire series · ' : ''}Choose where it goes
{tv ? 'Entire series · ' : ''}
{asks ? 'Choose where it goes' : 'Choose the quality'}
</span>
</div>
</div>
<AdvancedOptions
kind={tv ? 'tv' : 'movie'}
is4k={false}
onChange={onOpts}
inline
onReady={setReady}
/>
{offers4k && (
<div className="seg" role="group" aria-label="Quality">
{(
[
[false, '1080p'],
[true, '4K'],
] as [boolean, string][]
).map(([v, t]) => (
<button
key={t}
type="button"
aria-pressed={fourK === v}
onClick={() => {
setFourK(v);
setReady(false);
}}
>
{t}
</button>
))}
</div>
)}
{asks && (
// keyed by quality: the 4K choice lists the 4K servers with their own defaults
<AdvancedOptions
key={fourK ? '4k' : 'hd'}
kind={tv ? 'tv' : 'movie'}
is4k={offers4k && fourK}
onChange={onOpts}
inline
onReady={setReady}
/>
)}
{err && (
<p className="note" role="alert">
{err}
Expand All @@ -129,10 +178,14 @@ export function RequestSheet({
<div className="pk-foot">
<button
className="btn btn-primary"
disabled={busy || !ready}
disabled={busy || !canSend}
onClick={send}
>
{busy ? 'Requesting…' : 'Request'}
{busy
? 'Requesting…'
: offers4k && fourK
? 'Request in 4K'
: 'Request'}
</button>
<button className="btn btn-tonal" onClick={onClose}>
Cancel
Expand All @@ -145,14 +198,16 @@ export function RequestSheet({
);
}

// Wraps a card's one-click request: people who may choose options get the sheet,
// everyone else keeps the one-click request. `done` runs after the sheet's request.
// Wraps a card's one-click request: people who may choose options or request 4K get the
// sheet, everyone else keeps the one-click request. `done` runs after the sheet's request.
export function useQuickRequest(
it: TitleResult,
oneClick: () => void | Promise<void>,
done: () => void
done: (is4k: boolean) => void
) {
const asks = useAsksRequestOptions();
const choosesOptions = useAsksRequestOptions();
const offers4k = useOffers4k(it.mediaType === 'tv' ? 'tv' : 'movie');
const asks = choosesOptions || offers4k;
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return {
Expand All @@ -164,9 +219,9 @@ export function useQuickRequest(
<RequestSheet
it={it}
onClose={close}
onDone={() => {
onDone={(is4k) => {
setOpen(false);
done();
done(is4k);
}}
/>
) : null,
Expand Down
39 changes: 29 additions & 10 deletions lumen/src/pages/MovieDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import {
ActionBar,
AdvancedOptions,
canRequest4k,
DetailTop,
Facts,
fmtRun,
Expand All @@ -43,8 +44,7 @@ export default function MovieDetail() {
const like = useApi<Paged<TitleResult>>(`/movie/${id}/recommendations`).data;
const pub = usePublicSettings();
const { user } = useAuth();
// ADMIN | REQUEST_4K | REQUEST_4K_MOVIE
const can4k = !!user && (user.permissions & (2 | 1024 | 2048)) !== 0;
const can4k = !!user && canRequest4k(user.permissions, 'movie');
const toast = useToast();
const [opts, setOpts] = useState<RequestOptions>({});
const [busy, setBusy] = useState(false);
Expand All @@ -70,10 +70,22 @@ export default function MovieDetail() {
const trailer = m.relatedVideos?.find(
(v) => v.type === 'Trailer' && v.site === 'YouTube'
);
const request = async (is4k = false) => {
// a 4K copy is in hand or on its way (requested, downloading, partly or fully there)
const has4k =
!!s4k && s4k >= MediaStatus.PENDING && s4k <= MediaStatus.AVAILABLE;
const request = async (force4k = false) => {
// Advanced options may have picked a 4K server; the Request 4K button always means 4K
const is4k = force4k || !!opts.is4k;
setBusy(true);
try {
await requestMovie(m.id, { ...opts, is4k });
// the Request 4K button ignores options chosen for an HD server and keeps only who
// it's for, so SeerrNG uses the 4K server's own defaults
await requestMovie(
m.id,
force4k && !opts.is4k
? { is4k: true, userId: opts.userId }
: { ...opts, is4k }
);
toast(`Requested ${m.title}${is4k ? ' in 4K' : ''}`);
dropCache(`/movie/${m.id}`);
reload();
Expand Down Expand Up @@ -108,11 +120,9 @@ export default function MovieDetail() {
{unreleased ? 'Request · downloads on release' : 'Request'}
</button>
);
// 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 ? (
<button
className="btn btn-quiet"
disabled={busy}
Expand Down Expand Up @@ -180,7 +190,14 @@ export default function MovieDetail() {
{fourK}
{trailerBtn}
</div>
{!s && <AdvancedOptions kind="movie" is4k={false} onChange={onOpts} />}
{!s && (
<AdvancedOptions
kind="movie"
is4k={false}
allow4k={!!pub?.movie4kEnabled && can4k && !has4k}
onChange={onOpts}
/>
)}
<div className="dcols">
<div style={{ display: 'grid', gap: 22 }}>
{m.tagline && <p className="tagline">{m.tagline}</p>}
Expand Down Expand Up @@ -214,7 +231,9 @@ export default function MovieDetail() {
pub?.movie4kEnabled
? s4k === MediaStatus.AVAILABLE
? `On ${serverName}`
: 'Not yet'
: has4k
? 'Requested'
: 'Not yet'
: '',
],
]}
Expand Down
Loading
Loading