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
15 changes: 12 additions & 3 deletions lumen/src/extras.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export function useExtras() {
music: svc.music,
comic: svc.comics,
magazine: svc.magazines,
game: svc.software,
// consoles can come from ROMarrNG alone; each category is checked below
game: svc.software || svc.romarr,
sports: svc.sports,
adult: true,
};
Expand All @@ -125,6 +126,8 @@ export function useExtras() {
? !!on.adult && !!pub?.adultEnabled
: !!on[k]);
const [working, setWorking] = useState<Set<Extra> | null>(null);
// the game categories whose catalogue answers (PC games need QuestarrNG, consoles may not)
const [gamesWorking, setGamesWorking] = useState<GameCategory[] | null>(null);
const key =
EXTRAS.map((e) => (enabled(e.key) ? e.key : '')).join(',') +
games.join(',');
Expand All @@ -139,7 +142,10 @@ export function useExtras() {
games.map((c) =>
probe(`/request/software/catalog/popular?category=${c}&limit=1`)
)
).then((r) => r.some(Boolean))
).then((r) => {
if (live) setGamesWorking(games.filter((_, i) => r[i]));
return r.some(Boolean);
})
: PROBE[k]
? probe(PROBE[k]!)
: Promise.resolve(true)
Expand All @@ -153,7 +159,10 @@ export function useExtras() {
return {
ready: !!pub,
enabled,
games,
// until the checks answer, list none (so Games doesn't load a dead category first)
games: !enabled('game')
? games
: games.filter((c) => gamesWorking?.includes(c)),
list: EXTRAS.filter((e) => enabled(e.key) && working?.has(e.key)),
};
}
Expand Down
4 changes: 3 additions & 1 deletion lumen/src/mediaServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ export interface Services {
comics: boolean;
magazines: boolean;
sports: boolean;
software: boolean;
software: boolean; // QuestarrNG: PC games, and consoles unless ROMarrNG serves them
romarr: boolean; // ROMarrNG (RomM): retro and modern consoles
}
export function resolveServices(pub: Record<string, unknown> | null): Services {
const has = (k: string) => pub?.[k] !== false;
Expand All @@ -190,6 +191,7 @@ export function resolveServices(pub: Record<string, unknown> | null): Services {
magazines: has('magazinesEnabled'),
sports: has('sportsEnabled'),
software: has('softwareEnabled'),
romarr: pub?.romarrEnabled === true,
};
}
let currentServices: Services = resolveServices(null);
Expand Down
40 changes: 35 additions & 5 deletions lumen/src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ import { BrandMark } from '../components/brand';
import { mediaServer, services, site } from '../mediaServer';

type Mode = 'start' | 'server' | 'local' | 'quick';
type Method = 'server' | 'local' | 'quick';

// The way this device last signed in successfully. The next sign-in opens straight to it,
// so a family member picks Jellyfin (or email) once and then just types their password.
const LAST_KEY = 'lumen-last-sign-in';
export function lastSignIn(): Method | null {
try {
const v = localStorage.getItem(LAST_KEY);
return v === 'server' || v === 'local' || v === 'quick' ? v : null;
} catch {
return null;
}
}
function rememberSignIn(m: Method) {
try {
localStorage.setItem(LAST_KEY, m);
} catch {
// private window or blocked storage: the choice just isn't remembered
}
}

// SeerrNG answers sign-in failures with codes like INVALID_CREDENTIALS; say it in words
export function friendly(e: unknown, serverName: string): string {
Expand Down Expand Up @@ -46,12 +66,19 @@ export default function Login() {
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
// nothing to choose between: open the only form straight away
// The admin's form setting wins; otherwise open the way this device last signed in.
// (Quick Connect only remembers the start page: a code shouldn't be asked for unprompted.)
const last = lastSignIn();
const [mode, setMode] = useState<Mode>(
family
? 'server'
: !server.serverLogin && server.localLogin
? 'local'
: 'start'
: last === 'server' && passwordForm && server.serverLogin
? 'server'
: last === 'local' && server.localLogin
? 'local'
: 'start'
);
const home: Mode = family ? 'server' : 'start';
const [code, setCode] = useState('');
Expand All @@ -66,13 +93,14 @@ export default function Login() {
[]
);

const run = async (fn: () => Promise<unknown>) => {
const run = async (fn: () => Promise<unknown>, method?: Method) => {
if (active.current) return;
active.current = true;
setBusy(true);
setErr('');
try {
await fn();
if (method) rememberSignIn(method);
await refresh();
} catch (e) {
if (!(e instanceof QuickConnectCancelled))
Expand All @@ -92,7 +120,8 @@ export default function Login() {
email: f.get('email'),
password: f.get('password'),
}),
})
}),
'local'
);
};
const jellyfin = (e: FormEvent<HTMLFormElement>) => {
Expand All @@ -102,7 +131,8 @@ export default function Login() {
signInWithJellyfin(
String(f.get('username') || ''),
String(f.get('password') || '')
)
),
'server'
);
};
const quick = () => {
Expand All @@ -127,7 +157,7 @@ export default function Login() {
if (runId === quickRun.current) setMode(home);
throw e;
}
});
}, 'quick');
};
const cancelQuick = () => {
quickRun.current++;
Expand Down
3 changes: 2 additions & 1 deletion lumen/src/pages/LumenSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,8 @@ export default function LumenSettings() {
<b>Sign-in page</b>
<small>
Jellyfin and Emby: show the username and password boxes straight
away, without naming the media server
away, without naming the media server. With buttons, each device
opens the way it last signed in.
</small>
</span>
<select
Expand Down
72 changes: 72 additions & 0 deletions lumen/tests/browser/admin-options.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,78 @@ test('a stale game page cannot overwrite loading or errors after a new search',
).toHaveCount(0);
});

test('Games shows with only ROMarrNG, and only the consoles it serves', async ({
page,
}) => {
await fixtures(page);
await page.route('**/api/v1/settings/public', (route) =>
route.fulfill({
json: {
applicationTitle: 'SeerrNG',
enabledMediaCategories: { game: true, retro: true, modern: true },
softwareEnabled: false,
romarrEnabled: true,
mediaServerType: 2,
localLogin: true,
},
})
);
const asked: string[] = [];
await page.route('**/api/v1/request/software/catalog/**', (route) => {
const url = new URL(route.request().url());
asked.push(url.search);
// PC games need QuestarrNG, which isn't connected
if (url.searchParams.get('category') === 'game')
return route.fulfill({
status: 503,
json: { error: 'QuestarrNG is not configured.' },
});
return route.fulfill({
json: {
results:
url.searchParams.get('limit') === '1'
? []
: [
{
id: '77',
catalogProvider: 'igdb',
catalogId: '77',
title: `Console pick ${url.searchParams.get('category')}`,
summary: '',
coverUrl: '',
releaseDate: '2026-01-01',
platforms: [],
genres: [],
},
],
nextCursor: null,
},
});
});
await page.goto('/');
await expect(
page.getByRole('link', { name: 'Games', exact: true }).first()
).toBeVisible();
await page.goto('/games');
const platform = page.getByRole('group', { name: 'Platform' });
await expect(
platform.getByRole('button', { name: 'Retro consoles' })
).toBeVisible();
await expect(
platform.getByRole('button', { name: 'Modern consoles' })
).toBeVisible();
await expect(platform.getByRole('button', { name: 'PC games' })).toHaveCount(
0
);
await expect(
page.getByRole('link', { name: 'Console pick retro', exact: true })
).toBeVisible();
// the dead PC games list is only ever probed, never loaded
expect(
asked.filter((q) => q.includes('category=game') && !q.includes('limit=1'))
).toEqual([]);
});

test('Discover reuses saved rows when reopened, and asks again after a request', async ({
page,
}) => {
Expand Down
62 changes: 61 additions & 1 deletion lumen/tests/browser/jellyfin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async function fixtures(
if (path === '/auth/me') {
status = signedIn ? 200 : 401;
body = signedIn ? { id: 1, displayName: 'jf user', permissions: 2 } : {};
} else if (path === '/auth/jellyfin') {
} else if (path === '/auth/jellyfin' || path === '/auth/local') {
signedIn = true;
} else if (path === '/auth/jellyfin/quickconnect/initiate') {
body = { code: '123456', secret: 'a'.repeat(64) };
Expand Down Expand Up @@ -142,6 +142,66 @@ test('Jellyfin password sign-in and Jellyfin wording', async ({ page }) => {
expect(errors).toEqual([]);
});

test('the next sign-in opens the way this device last signed in', async ({
page,
}) => {
await fixtures(page, { serverType: 2 });
await page.goto('/');
await page.getByRole('button', { name: 'Continue with Jellyfin' }).click();
await page.getByLabel('Jellyfin username').fill('alice');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Sign in with Jellyfin' }).click();
await expect(
page.getByRole('heading', { name: 'Discover', exact: true })
).toBeVisible();

// signed out again (fresh fixtures): straight to the Jellyfin boxes, other ways still there
await fixtures(page, { serverType: 2 });
await page.goto('/');
await expect(page.getByLabel('Jellyfin username')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Continue with Jellyfin' })
).toHaveCount(0);
await page
.getByRole('button', { name: 'Use an email and password instead' })
.click();
await page.getByLabel('Email').fill('alice@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(
page.getByRole('heading', { name: 'Discover', exact: true })
).toBeVisible();

// now email it is, with a way back to Jellyfin
await fixtures(page, { serverType: 2 });
await page.goto('/');
await expect(page.getByLabel('Email')).toBeVisible();
await page
.getByRole('button', { name: 'Use your Jellyfin account instead' })
.click();
await expect(
page.getByRole('button', { name: 'Continue with Jellyfin' })
).toBeVisible();
});

test('a failed sign-in is not remembered', async ({ page }) => {
const { calls } = await fixtures(page, { serverType: 2 });
await page.route('**/api/v1/auth/jellyfin', (route) =>
route.fulfill({ status: 401, json: { message: 'INVALID_CREDENTIALS' } })
);
await page.goto('/');
await page.getByRole('button', { name: 'Continue with Jellyfin' }).click();
await page.getByLabel('Jellyfin username').fill('alice');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Sign in with Jellyfin' }).click();
await expect(page.getByRole('alert')).toContainText('isn’t right');
await page.goto('/');
await expect(
page.getByRole('button', { name: 'Continue with Jellyfin' })
).toBeVisible();
expect(calls.length).toBeGreaterThan(0);
});

test('Jellyfin Quick Connect sign-in', async ({ page }) => {
const { calls } = await fixtures(page, {
serverType: 2,
Expand Down
Loading