From 6c5edc77cfa18a00f4f01a18042333987c686f92 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:13:01 +0300 Subject: [PATCH 01/48] feat: add standalone desktop runtime foundations --- launcher.js | 2 + server/api/externalapi.ts | 10 +- server/entity/JobExecutionState.ts | 28 +++ server/index.ts | 186 ++++++++++++++++-- server/interfaces/api/settingsInterfaces.ts | 4 + server/job/execution.ts | 63 ++++++ server/job/schedule.ts | 47 ++++- server/lib/cache.ts | 22 ++- server/lib/cacheStore.ts | 145 ++++++++++++++ server/lib/desktopRuntime.ts | 68 +++++++ server/lib/desktopState.ts | 7 + server/lib/imageproxy.ts | 116 ++++++++++- .../1786200000000-CreateJobExecutionState.ts | 13 ++ .../1786200000000-CreateJobExecutionState.ts | 13 ++ server/routes/index.ts | 7 + server/routes/settings/index.ts | 84 +++++--- 16 files changed, 751 insertions(+), 64 deletions(-) create mode 100644 launcher.js create mode 100644 server/entity/JobExecutionState.ts create mode 100644 server/job/execution.ts create mode 100644 server/lib/cacheStore.ts create mode 100644 server/lib/desktopRuntime.ts create mode 100644 server/lib/desktopState.ts create mode 100644 server/migration/postgres/1786200000000-CreateJobExecutionState.ts create mode 100644 server/migration/sqlite/1786200000000-CreateJobExecutionState.ts diff --git a/launcher.js b/launcher.js new file mode 100644 index 0000000000..1d52cf971b --- /dev/null +++ b/launcher.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import('./dist/index.js'); diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 4bb2358a23..a063481e6a 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -1,8 +1,8 @@ +import type { CacheStore } from '@server/lib/cacheStore'; import { proxyRequestInterceptor } from '@server/utils/customProxyAgent'; import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import axios from 'axios'; import rateLimit from 'axios-rate-limit'; -import type NodeCache from 'node-cache'; // 5 minute default TTL (in seconds) const DEFAULT_TTL = 300; @@ -11,7 +11,7 @@ const DEFAULT_TTL = 300; const DEFAULT_ROLLING_BUFFER = 10000; export interface ExternalAPIOptions { - nodeCache?: NodeCache; + nodeCache?: CacheStore; headers?: Record; timeout?: number; rateLimit?: { @@ -23,7 +23,7 @@ export interface ExternalAPIOptions { class ExternalAPI { protected axios: AxiosInstance; private baseUrl: string; - private cache?: NodeCache; + private cache?: CacheStore; constructor( baseUrl: string, @@ -171,7 +171,7 @@ class ExternalAPI { const cacheKey = this.serializeCacheKey(endpoint, { ...options, }); - this.cache?.del(cacheKey); + this.cache?.delete(cacheKey); } protected removeCacheByEndpointPrefix(endpoint: string): void { @@ -181,7 +181,7 @@ class ExternalAPI { const prefix = `${this.baseUrl}${endpoint}`; for (const key of this.cache.keys()) { if (key.startsWith(prefix)) { - this.cache.del(key); + this.cache.delete(key); } } } diff --git a/server/entity/JobExecutionState.ts b/server/entity/JobExecutionState.ts new file mode 100644 index 0000000000..8db30cba56 --- /dev/null +++ b/server/entity/JobExecutionState.ts @@ -0,0 +1,28 @@ +import { DbAwareColumn } from '@server/utils/DbColumnHelper'; +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +/** Durable scheduler history used for desktop catch-up and retry decisions. */ +@Entity() +export class JobExecutionState { + @PrimaryColumn({ type: 'varchar', length: 128 }) + public jobId: string; + + @DbAwareColumn({ type: 'datetime', nullable: true }) + public lastStartedAt?: Date | null; + + @DbAwareColumn({ type: 'datetime', nullable: true }) + public lastSucceededAt?: Date | null; + + @DbAwareColumn({ type: 'datetime', nullable: true }) + public lastFailedAt?: Date | null; + + @Column({ type: 'varchar', length: 512, nullable: true }) + public lastFailureSummary?: string | null; + + @Column({ type: 'integer', default: 0 }) + public consecutiveFailures: number; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} diff --git a/server/index.ts b/server/index.ts index 434cbc77f2..893cf7f543 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,8 +5,14 @@ import DiscoverSlider from '@server/entity/DiscoverSlider'; import { Session } from '@server/entity/Session'; import { User } from '@server/entity/User'; import { initI18n } from '@server/i18n'; -import { startJobs } from '@server/job/schedule'; +import { startJobs, stopJobs } from '@server/job/schedule'; import { assertSupportedDatabaseSchema } from '@server/lib/db/schemaGuard'; +import { + DESKTOP_SCHEMA_EXIT_CODE, + acquireDesktopLock, + desktopError, +} from '@server/lib/desktopRuntime'; +import { setDesktopPlaybackActive } from '@server/lib/desktopState'; import notificationManager from '@server/lib/notifications'; import DiscordAgent from '@server/lib/notifications/agents/discord'; import EmailAgent from '@server/lib/notifications/agents/email'; @@ -41,6 +47,7 @@ import * as OpenApiValidator from 'express-openapi-validator'; import type { Store } from 'express-session'; import session from 'express-session'; import fs from 'fs/promises'; +import type { Server as HttpServer } from 'http'; import yaml from 'js-yaml'; import next from 'next'; import path from 'path'; @@ -52,6 +59,82 @@ logger.info(`Starting Seerr version ${getAppVersion()}`); const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); const handle = app.getRequestHandler(); +const desktopRuntime = process.env.FORESEERR_RUNTIME === 'desktop'; +let managedServer: HttpServer | undefined; +let releaseDesktopLock: (() => Promise) | undefined; +let stopping = false; +let desktopOrigin = ''; + +const stopManagedRuntime = async (deadlineMs = 10_000): Promise => { + if (stopping) return; + stopping = true; + stopJobs(); + const server = managedServer; + if (server) { + await new Promise((resolve) => { + const timeout = setTimeout(() => { + server.closeAllConnections(); + resolve(); + }, deadlineMs); + server.close(() => { + clearTimeout(timeout); + resolve(); + }); + }); + } + if (dataSource.isInitialized) { + if (!isPgsql) { + await dataSource + .query('PRAGMA wal_checkpoint(TRUNCATE)') + .catch(() => undefined); + } + await dataSource.destroy(); + } + await releaseDesktopLock?.(); + releaseDesktopLock = undefined; +}; + +const requestManagedShutdown = (deadlineMs = 10_000): void => { + void stopManagedRuntime(deadlineMs).finally(() => process.exit(0)); +}; + +if (desktopRuntime) { + process.stdin.setEncoding('utf8'); + let input = ''; + process.stdin.on('data', (chunk: string) => { + input += chunk; + const lines = input.split('\n'); + input = lines.pop() ?? ''; + for (const line of lines) { + try { + const message = JSON.parse(line) as { + type?: string; + deadlineMs?: number; + playbackActive?: boolean; + }; + if (message.type === 'shutdown') { + requestManagedShutdown(message.deadlineMs); + } else if ( + message.type === 'runtime-state' && + typeof message.playbackActive === 'boolean' + ) { + setDesktopPlaybackActive(message.playbackActive); + } else { + logger.warn('Ignoring unknown desktop control message', { + label: 'Desktop', + }); + } + } catch { + logger.warn('Ignoring malformed desktop control message', { + label: 'Desktop', + }); + } + } + }); + process.stdin.on('end', () => requestManagedShutdown()); + process.once('SIGTERM', () => requestManagedShutdown()); + process.once('SIGINT', () => requestManagedShutdown()); +} if (!appDataPermissions()) { logger.error( @@ -62,6 +145,9 @@ if (!appDataPermissions()) { app .prepare() .then(async () => { + if (desktopRuntime) { + releaseDesktopLock = await acquireDesktopLock(); + } // Run Overseerr to Seerr migration await checkOverseerrMerge(); @@ -84,7 +170,19 @@ app // Foreseerr version instead of silently running against an unknown // schema. Checked unconditionally (not just in production) since a // downgraded dev/synchronize install could still point at such a DB. - await assertSupportedDatabaseSchema(dbConnection); + try { + await assertSupportedDatabaseSchema(dbConnection); + } catch (error) { + if (desktopRuntime) { + await releaseDesktopLock?.(); + releaseDesktopLock = undefined; + throw desktopError( + `Desktop database schema is incompatible: ${(error as Error).message}`, + DESKTOP_SCHEMA_EXIT_CODE + ); + } + throw error; + } // Load Settings const settings = await getSettings().load(); @@ -162,9 +260,37 @@ app await DiscoverSlider.bootstrapSliders(); const server = express(); - if (settings.network.trustProxy) { + if (!desktopRuntime && settings.network.trustProxy) { server.enable('trust proxy'); } + if (desktopRuntime) { + server.use((req, res, next) => { + const unsafeMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes( + req.method + ); + const nativeTicket = req.path === '/api/v1/desktop/auth-tickets/redeem'; + if ( + (stopping && req.path !== '/api/v1/status') || + !desktopOrigin || + req.headers.host !== desktopOrigin.replace('http://', '') || + req.headers['x-forwarded-host'] || + req.headers.forwarded || + req.originalUrl.startsWith('http://') || + req.originalUrl.startsWith('https://') || + (unsafeMethod && + !nativeTicket && + req.headers.origin !== desktopOrigin) + ) { + res.status(stopping ? 503 : 403).json({ + message: stopping + ? 'Foreseerr is shutting down' + : 'Invalid local desktop request', + }); + return; + } + next(); + }); + } server.use(cookieParser()); server.use(express.json()); server.use(express.urlencoded({ extended: true })); @@ -186,13 +312,13 @@ app next(); } }); - if (settings.network.csrfProtection) { + if (desktopRuntime || settings.network.csrfProtection) { server.use( csurf({ cookie: { httpOnly: true, - sameSite: true, - secure: !dev, + sameSite: desktopRuntime ? 'strict' : true, + secure: desktopRuntime ? false : !dev, key: '_csrf', path: '/', }, @@ -204,8 +330,8 @@ app ); server.use((req, res, next) => { res.cookie('XSRF-TOKEN', req.csrfToken(), { - sameSite: true, - secure: !dev, + sameSite: desktopRuntime ? 'strict' : true, + secure: desktopRuntime ? false : !dev, }); next(); }); @@ -222,8 +348,11 @@ app cookie: { maxAge: 1000 * 60 * 60 * 24 * 30, httpOnly: true, - sameSite: settings.network.csrfProtection ? 'strict' : 'lax', - secure: 'auto', + sameSite: + desktopRuntime || settings.network.csrfProtection + ? 'strict' + : 'lax', + secure: desktopRuntime ? false : 'auto', }, store: new TypeormStore({ cleanupLimit: 2, @@ -288,9 +417,13 @@ app } ); - const port = Number(process.env.PORT) || 5055; - const host = process.env.HOST; - let httpServer; + const configuredPort = Number(process.env.PORT); + const port = + Number.isInteger(configuredPort) && configuredPort >= 0 + ? configuredPort + : 5055; + const host = desktopRuntime ? '127.0.0.1' : process.env.HOST; + let httpServer: HttpServer; if (host) { httpServer = server.listen(port, host, () => { logger.info(`Server ready on ${host} port ${port}`, { @@ -311,8 +444,31 @@ app }); process.exit(1); }); + managedServer = httpServer; + if (desktopRuntime) { + httpServer.on('listening', () => { + const address = httpServer.address(); + if ( + typeof address !== 'object' || + !address || + address.address !== '127.0.0.1' + ) { + logger.error('Desktop runtime did not bind exact loopback', { + label: 'Desktop', + }); + requestManagedShutdown(); + return; + } + desktopOrigin = `http://127.0.0.1:${address.port}`; + process.stdout.write( + `FORESEERR_DESKTOP_READY ${JSON.stringify({ protocolVersion: 1, pid: process.pid, origin: desktopOrigin, foreseerrVersion: getAppVersion(), commit: process.env.FORESEERR_COMMIT ?? 'unknown', schemaVersion: 0 })}\n` + ); + }); + } }) - .catch((err) => { + .catch(async (err) => { logger.error(err.stack); - process.exit(1); + await releaseDesktopLock?.(); + releaseDesktopLock = undefined; + process.exit(err.exitCode ?? 1); }); diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 1b9aa73763..b3fb2ea992 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -92,4 +92,8 @@ export interface StatusResponse { updateAvailable?: boolean; commitsBehind?: number; restartRequired: boolean; + runtime?: 'desktop'; + managed?: boolean; + jobsStarted?: boolean; + stopping?: boolean; } diff --git a/server/job/execution.ts b/server/job/execution.ts new file mode 100644 index 0000000000..d7f26c124f --- /dev/null +++ b/server/job/execution.ts @@ -0,0 +1,63 @@ +import { getRepository } from '@server/datasource'; +import { JobExecutionState } from '@server/entity/JobExecutionState'; +import logger from '@server/logger'; + +const activeJobs = new Set(); +let activeHeavyJobs = 0; +let activeLightJobs = 0; + +export type ManagedJobWeight = 'heavy' | 'light'; + +const summarizeFailure = (error: unknown): string => + String(error instanceof Error ? error.message : error) + .replace( + /(?:token|authorization|cookie|password)=?[^\s,;]+/gi, + '[redacted]' + ) + .slice(0, 512); + +/** Run scheduled and manual work through one non-overlapping, persisted path. */ +export const executeManagedJob = async ( + id: string, + weight: ManagedJobWeight, + run: (signal: AbortSignal) => Promise +): Promise => { + if ( + activeJobs.has(id) || + (weight === 'heavy' && activeHeavyJobs >= 1) || + (weight === 'light' && activeLightJobs >= 2) + ) { + return false; + } + activeJobs.add(id); + if (weight === 'heavy') activeHeavyJobs += 1; + else activeLightJobs += 1; + const repository = getRepository(JobExecutionState); + const existing = await repository.findOne({ where: { jobId: id } }); + const state = existing ?? new JobExecutionState({ jobId: id }); + state.lastStartedAt = new Date(); + await repository.save(state); + const controller = new AbortController(); + try { + await run(controller.signal); + state.lastSucceededAt = new Date(); + state.consecutiveFailures = 0; + state.lastFailureSummary = null; + await repository.save(state); + return true; + } catch (error) { + state.lastFailedAt = new Date(); + state.lastFailureSummary = summarizeFailure(error); + state.consecutiveFailures += 1; + await repository.save(state); + logger.error(`Managed job failed: ${id}`, { + label: 'Jobs', + message: state.lastFailureSummary, + }); + return false; + } finally { + activeJobs.delete(id); + if (weight === 'heavy') activeHeavyJobs -= 1; + else activeLightJobs -= 1; + } +}; diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 2b82470491..b10750c3b3 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -2,6 +2,7 @@ import { MediaServerType } from '@server/constants/server'; import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor'; import episodeRequestSync from '@server/job/episodeRequestSync'; import availabilitySync from '@server/lib/availabilitySync'; +import { isDesktopPlaybackActive } from '@server/lib/desktopState'; import downloadTracker from '@server/lib/downloadtracker'; import ImageProxy from '@server/lib/imageproxy'; import refreshToken from '@server/lib/refreshToken'; @@ -32,7 +33,23 @@ interface ScheduledJob { export const scheduledJobs: ScheduledJob[] = []; +const runHeavy = (name: string, run: () => void): void => { + if (isDesktopPlaybackActive()) { + logger.info( + `Deferring heavy job while desktop playback is active: ${name}`, + { + label: 'Jobs', + } + ); + return; + } + run(); +}; + export const startJobs = (): void => { + if (scheduledJobs.length > 0) { + return; + } const jobs = getSettings().jobs; const mediaServerType = getSettings().main.mediaServerType; @@ -50,7 +67,7 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Plex Recently Added Scan', { label: 'Jobs', }); - plexRecentScanner.run(); + runHeavy('Plex Recently Added Scan', () => plexRecentScanner.run()); } ), running: () => plexRecentScanner.status().running, @@ -68,7 +85,7 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Plex Full Library Scan', { label: 'Jobs', }); - plexFullScanner.run(); + runHeavy('Plex Full Library Scan', () => plexFullScanner.run()); }), running: () => plexFullScanner.status().running, cancelFn: () => plexFullScanner.cancel(), @@ -124,7 +141,9 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Jellyfin Recently Added Scan', { label: 'Jobs', }); - jellyfinRecentScanner.run(); + runHeavy('Jellyfin Recently Added Scan', () => + jellyfinRecentScanner.run() + ); } ), running: () => jellyfinRecentScanner.status().running, @@ -142,7 +161,7 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Jellyfin Full Scan', { label: 'Jobs', }); - jellyfinFullScanner.run(); + runHeavy('Jellyfin Full Scan', () => jellyfinFullScanner.run()); }), running: () => jellyfinFullScanner.status().running, cancelFn: () => jellyfinFullScanner.cancel(), @@ -159,7 +178,7 @@ export const startJobs = (): void => { cronSchedule: jobs['radarr-scan'].schedule, job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => { logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' }); - radarrScanner.run(); + runHeavy('Radarr Scan', () => radarrScanner.run()); }), running: () => radarrScanner.status().running, cancelFn: () => radarrScanner.cancel(), @@ -205,7 +224,7 @@ export const startJobs = (): void => { cronSchedule: jobs['sonarr-scan'].schedule, job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => { logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' }); - sonarrScanner.run(); + runHeavy('Sonarr Scan', () => sonarrScanner.run()); }), running: () => sonarrScanner.status().running, cancelFn: () => sonarrScanner.cancel(), @@ -222,7 +241,7 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Media Availability Sync', { label: 'Jobs', }); - availabilitySync.run(); + runHeavy('Media Availability Sync', () => availabilitySync.run()); }), running: () => availabilitySync.running, cancelFn: () => availabilitySync.cancel(), @@ -274,6 +293,7 @@ export const startJobs = (): void => { // Clean users avatar image cache ImageProxy.clearCache('avatar'); + void ImageProxy.maintainCache(); }), }); @@ -287,7 +307,9 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Process Blocklisted Tags', { label: 'Jobs', }); - blocklistedTagsProcessor.run(); + runHeavy('Process Blocklisted Tags', () => + blocklistedTagsProcessor.run() + ); }), running: () => blocklistedTagsProcessor.status().running, cancelFn: () => blocklistedTagsProcessor.cancel(), @@ -295,3 +317,12 @@ export const startJobs = (): void => { logger.info('Scheduled jobs loaded', { label: 'Jobs' }); }; + +export const stopJobs = (): void => { + for (const scheduledJob of scheduledJobs) { + scheduledJob.job.cancel(); + scheduledJob.cancelFn?.(); + } + scheduledJobs.splice(0, scheduledJobs.length); + logger.info('Scheduled jobs stopped', { label: 'Jobs' }); +}; diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 4381571bd3..cf3b825944 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -1,4 +1,8 @@ -import NodeCache from 'node-cache'; +import { + CacheBudget, + WeightedLruCacheStore, + type CacheStore, +} from '@server/lib/cacheStore'; export type AvailableCacheIds = | 'tmdb' @@ -16,11 +20,11 @@ export type AvailableCacheIds = | 'anilist'; const DEFAULT_TTL = 300; -const DEFAULT_CHECK_PERIOD = 120; +const cacheBudget = new CacheBudget(); class Cache { public id: AvailableCacheIds; - public data: NodeCache; + public data: CacheStore; public name: string; constructor( @@ -30,18 +34,18 @@ class Cache { ) { this.id = id; this.name = name; - this.data = new NodeCache({ - stdTTL: options.stdTtl ?? DEFAULT_TTL, - checkperiod: options.checkPeriod ?? DEFAULT_CHECK_PERIOD, - }); + this.data = new WeightedLruCacheStore( + cacheBudget, + options.stdTtl ?? DEFAULT_TTL + ); } public getStats() { - return this.data.getStats(); + return this.data.stats(); } public flush(): void { - this.data.flushAll(); + this.data.flush(); } } diff --git a/server/lib/cacheStore.ts b/server/lib/cacheStore.ts new file mode 100644 index 0000000000..003dafd66d --- /dev/null +++ b/server/lib/cacheStore.ts @@ -0,0 +1,145 @@ +export interface CacheStats { + usedBytes: number; + limitBytes: number; + entries: number; + evictions: number; +} + +export interface CacheStore { + get(key: string): T | undefined; + set(key: string, value: T, ttlSeconds?: number): void; + delete(key: string): void; + keys(): string[]; + flush(): void; + stats(): CacheStats; + getTtl(key: string): number; +} + +type Entry = { + value: unknown; + size: number; + expiresAt: number; + accessedAt: number; +}; + +export class CacheBudget { + private usedBytes = 0; + private evictions = 0; + private readonly stores = new Set(); + constructor(readonly limitBytes = 256 * 1024 * 1024) {} + register(store: WeightedLruCacheStore): void { + this.stores.add(store); + } + add(bytes: number): void { + this.usedBytes += bytes; + } + remove(bytes: number): void { + this.usedBytes = Math.max(0, this.usedBytes - bytes); + } + evicted(): void { + this.evictions += 1; + } + ensureCapacity(): void { + while (this.usedBytes > this.limitBytes) { + const candidate = [...this.stores] + .flatMap((store) => store.oldest()) + .sort((a, b) => a.accessedAt - b.accessedAt)[0]; + if (!candidate) return; + candidate.store.evict(candidate.key); + } + } + stats(): CacheStats { + return { + usedBytes: this.usedBytes, + limitBytes: this.limitBytes, + entries: [...this.stores].reduce((sum, store) => sum + store.count(), 0), + evictions: this.evictions, + }; + } +} + +export class WeightedLruCacheStore implements CacheStore { + private entries = new Map(); + constructor( + private readonly budget: CacheBudget, + private readonly defaultTtl = 300 + ) { + budget.register(this); + } + get(key: string): T | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= Date.now()) { + this.evict(key, false); + return undefined; + } + entry.accessedAt = Date.now(); + return entry.value as T; + } + set(key: string, value: T, ttlSeconds = this.defaultTtl): void { + this.evict(key, false); + const entry = { + value, + size: estimateSize(value), + expiresAt: Date.now() + ttlSeconds * 1000, + accessedAt: Date.now(), + }; + this.entries.set(key, entry); + this.budget.add(entry.size); + this.removeExpired(); + this.budget.ensureCapacity(); + } + delete(key: string): void { + this.evict(key, false); + } + keys(): string[] { + this.removeExpired(); + return [...this.entries.keys()]; + } + flush(): void { + for (const key of this.entries.keys()) this.evict(key, false); + } + stats(): CacheStats { + return this.budget.stats(); + } + getTtl(key: string): number { + return this.entries.get(key)?.expiresAt ?? 0; + } + oldest(): { + store: WeightedLruCacheStore; + key: string; + accessedAt: number; + }[] { + this.removeExpired(); + return [...this.entries].map(([key, entry]) => ({ + store: this, + key, + accessedAt: entry.accessedAt, + })); + } + count(): number { + this.removeExpired(); + return this.entries.size; + } + evict(key: string, count = true): void { + const entry = this.entries.get(key); + if (!entry) return; + this.entries.delete(key); + this.budget.remove(entry.size); + if (count) this.budget.evicted(); + } + private removeExpired(): void { + for (const [key, entry] of this.entries) + if (entry.expiresAt <= Date.now()) this.evict(key, false); + } +} + +const estimateSize = (value: unknown): number => { + if (typeof value === 'string') return Buffer.byteLength(value); + if (Buffer.isBuffer(value)) return value.length; + try { + return Math.min(Buffer.byteLength(JSON.stringify(value)), 1024 * 1024); + } catch { + return 1024; + } +}; diff --git a/server/lib/desktopRuntime.ts b/server/lib/desktopRuntime.ts new file mode 100644 index 0000000000..10711d1e8d --- /dev/null +++ b/server/lib/desktopRuntime.ts @@ -0,0 +1,68 @@ +import fs from 'fs/promises'; +import path from 'path'; + +export const DESKTOP_LOCK_EXIT_CODE = 73; +export const DESKTOP_SCHEMA_EXIT_CODE = 74; + +export interface DesktopRuntimeError extends Error { + exitCode?: number; +} + +export const desktopError = ( + message: string, + exitCode: number +): DesktopRuntimeError => { + const error = new Error(message) as DesktopRuntimeError; + error.exitCode = exitCode; + return error; +}; + +export const acquireDesktopLock = async (): Promise<() => Promise> => { + const configDirectory = process.env.CONFIG_DIRECTORY; + if (!configDirectory) { + throw desktopError( + 'Desktop runtime requires CONFIG_DIRECTORY', + DESKTOP_LOCK_EXIT_CODE + ); + } + const lockPath = path.join(configDirectory, 'state', 'instance.lock'); + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + let handle: Awaited>; + try { + handle = await fs.open(lockPath, 'wx', 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + const pid = Number.parseInt( + (await fs.readFile(lockPath, 'utf8').catch(() => '')).trim(), + 10 + ); + let alive = Number.isInteger(pid) && pid > 0; + if (alive) { + try { + process.kill(pid, 0); + } catch { + alive = false; + } + } + if (alive) { + throw desktopError( + 'Another Foreseer Desktop instance owns this data directory', + DESKTOP_LOCK_EXIT_CODE + ); + } + await fs.unlink(lockPath).catch(() => undefined); + try { + handle = await fs.open(lockPath, 'wx', 0o600); + } catch { + throw desktopError( + 'Another Foreseer Desktop instance owns this data directory', + DESKTOP_LOCK_EXIT_CODE + ); + } + } + await handle.writeFile(`${process.pid}\n`); + return async () => { + await handle.close().catch(() => undefined); + await fs.unlink(lockPath).catch(() => undefined); + }; +}; diff --git a/server/lib/desktopState.ts b/server/lib/desktopState.ts new file mode 100644 index 0000000000..ecd0f1e715 --- /dev/null +++ b/server/lib/desktopState.ts @@ -0,0 +1,7 @@ +let playbackActive = false; + +export const setDesktopPlaybackActive = (active: boolean): void => { + playbackActive = active; +}; + +export const isDesktopPlaybackActive = (): boolean => playbackActive; diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 2dac7d9c5d..4fa3b69b97 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -20,9 +20,14 @@ type ImageResponse = { imageBuffer: Buffer; }; -const baseCacheDirectory = process.env.CONFIG_DIRECTORY - ? `${process.env.CONFIG_DIRECTORY}/cache/images` - : path.join(__dirname, '../../config/cache/images'); +const baseCacheDirectory = process.env.CACHE_DIRECTORY + ? `${process.env.CACHE_DIRECTORY}/images` + : process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/cache/images` + : path.join(__dirname, '../../config/cache/images'); +const IMAGE_CACHE_HIGH_WATER_BYTES = 1280 * 1024 * 1024; +const IMAGE_CACHE_TRIM_TARGET_BYTES = 1024 * 1024 * 1024; +let cleanupInProgress = false; /** Coerce Axios 1.18+ header values (string | number | boolean | string[]) to string. */ const headerToString = (value: unknown, fallback = ''): string => { @@ -39,6 +44,101 @@ const headerToString = (value: unknown, fallback = ''): string => { }; class ImageProxy { + public static async clearAll(): Promise { + await promises.rm(baseCacheDirectory, { recursive: true, force: true }); + } + + public static async getCombinedStats(): Promise<{ + usedBytes: number; + entries: number; + highWaterBytes: number; + trimTargetBytes: number; + }> { + const entries = await ImageProxy.listEntries(); + return { + usedBytes: entries.reduce((total, entry) => total + entry.size, 0), + entries: entries.length, + highWaterBytes: IMAGE_CACHE_HIGH_WATER_BYTES, + trimTargetBytes: IMAGE_CACHE_TRIM_TARGET_BYTES, + }; + } + + public static async maintainCache(): Promise { + if (cleanupInProgress) return; + cleanupInProgress = true; + try { + const entries = await ImageProxy.listEntries(true); + let usedBytes = entries.reduce((total, entry) => total + entry.size, 0); + if (usedBytes > IMAGE_CACHE_HIGH_WATER_BYTES) { + for (const entry of entries.sort( + (a, b) => a.accessedAt - b.accessedAt + )) { + if (usedBytes <= IMAGE_CACHE_TRIM_TARGET_BYTES) break; + await promises.rm(entry.directory, { recursive: true, force: true }); + usedBytes -= entry.size; + } + } + } catch (error) { + logger.warn('Image cache maintenance failed', { + label: 'Image Cache', + message: (error as Error).message, + }); + } finally { + cleanupInProgress = false; + } + } + + private static async listEntries( + removeInvalid = false + ): Promise<{ directory: string; size: number; accessedAt: number }[]> { + const result: { + directory: string; + size: number; + accessedAt: number; + }[] = []; + let groups: string[]; + try { + groups = await promises.readdir(baseCacheDirectory); + } catch { + return result; + } + for (const group of groups) { + let keys: string[]; + try { + keys = await promises.readdir(join(baseCacheDirectory, group)); + } catch { + continue; + } + for (const key of keys) { + const directory = join(baseCacheDirectory, group, key); + let files: string[]; + try { + files = await promises.readdir(directory); + } catch { + continue; + } + const file = files[0]; + const expiresAt = Number(file?.split('.')[1]); + if (!file || !Number.isFinite(expiresAt) || Date.now() > expiresAt) { + if (removeInvalid) + await promises.rm(directory, { recursive: true, force: true }); + continue; + } + try { + const stat = await promises.stat(join(directory, file)); + result.push({ + directory, + size: stat.size, + accessedAt: stat.atimeMs || stat.mtimeMs, + }); + } catch { + if (removeInvalid) + await promises.rm(directory, { recursive: true, force: true }); + } + } + } + return result; + } public static async clearCache(key: string) { let deletedImages = 0; const cacheDirectory = path.join(baseCacheDirectory, key); @@ -247,7 +347,14 @@ class ImageProxy { for (const file of files) { const [maxAgeSt, expireAtSt, etag, extension] = file.split('.'); - const buffer = await promises.readFile(join(directory, file)); + const cacheFile = join(directory, file); + const buffer = await promises.readFile(cacheFile); + const stat = await promises.stat(cacheFile); + if (Date.now() - stat.atimeMs > 60 * 60 * 1000) { + await promises + .utimes(cacheFile, new Date(), stat.mtime) + .catch(() => undefined); + } const expireAt = Number(expireAtSt); const maxAge = Number(maxAgeSt); @@ -305,6 +412,7 @@ class ImageProxy { buffer, etag ); + void ImageProxy.maintainCache(); return { meta: { diff --git a/server/migration/postgres/1786200000000-CreateJobExecutionState.ts b/server/migration/postgres/1786200000000-CreateJobExecutionState.ts new file mode 100644 index 0000000000..fffeab5a63 --- /dev/null +++ b/server/migration/postgres/1786200000000-CreateJobExecutionState.ts @@ -0,0 +1,13 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateJobExecutionState1786200000000 implements MigrationInterface { + name = 'CreateJobExecutionState1786200000000'; + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "job_execution_state" ("jobId" character varying(128) NOT NULL, "lastStartedAt" TIMESTAMP, "lastSucceededAt" TIMESTAMP, "lastFailedAt" TIMESTAMP, "lastFailureSummary" character varying(512), "consecutiveFailures" integer NOT NULL DEFAULT 0, CONSTRAINT "PK_job_execution_state" PRIMARY KEY ("jobId"))` + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "job_execution_state"`); + } +} diff --git a/server/migration/sqlite/1786200000000-CreateJobExecutionState.ts b/server/migration/sqlite/1786200000000-CreateJobExecutionState.ts new file mode 100644 index 0000000000..fbb243d94e --- /dev/null +++ b/server/migration/sqlite/1786200000000-CreateJobExecutionState.ts @@ -0,0 +1,13 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateJobExecutionState1786200000000 implements MigrationInterface { + name = 'CreateJobExecutionState1786200000000'; + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "job_execution_state" ("jobId" varchar(128) PRIMARY KEY NOT NULL, "lastStartedAt" datetime, "lastSucceededAt" datetime, "lastFailedAt" datetime, "lastFailureSummary" varchar(512), "consecutiveFailures" integer NOT NULL DEFAULT (0))` + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "job_execution_state"`); + } +} diff --git a/server/routes/index.ts b/server/routes/index.ts index 5eb17eca64..722431dcae 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -8,6 +8,7 @@ import type { import { getRepository } from '@server/datasource'; import DiscoverSlider from '@server/entity/DiscoverSlider'; import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces'; +import { scheduledJobs } from '@server/job/schedule'; import { createTmdbWithRegionLanguage } from '@server/lib/discover/tmdb'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; @@ -103,6 +104,12 @@ router.get('/status', async (req, res) => { commitTag: getCommitTag(), ...(checkUpdate && { updateAvailable, commitsBehind }), restartRequired: restartFlag.isSet(), + ...(process.env.FORESEERR_RUNTIME === 'desktop' && { + runtime: 'desktop', + managed: true, + jobsStarted: scheduledJobs.length > 0, + stopping: false, + }), }); }); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 1b4c59074f..265655e6b9 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1202,36 +1202,46 @@ settingsRoutes.post<{ jobId: JobId }>( } ); -settingsRoutes.get('/cache', async (_req, res) => { - const cacheManagerCaches = cacheManager.getAllCaches(); +settingsRoutes.get( + '/cache', + isAuthenticated(Permission.ADMIN), + async (_req, res) => { + const cacheManagerCaches = cacheManager.getAllCaches(); - const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({ - id: cache.id, - name: cache.name, - stats: cache.getStats(), - })); + const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({ + id: cache.id, + name: cache.name, + stats: cache.getStats(), + })); - const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); - const avatarImageCache = await ImageProxy.getImageStats('avatar'); + const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); + const avatarImageCache = await ImageProxy.getImageStats('avatar'); + const imageStats = await ImageProxy.getCombinedStats(); + const memoryStats = Object.values(cacheManagerCaches)[0]?.getStats(); - const stats: DnsStats | undefined = dnsCache?.getStats(); - const entries: DnsEntries | undefined = dnsCache?.getCacheEntries(); + const stats: DnsStats | undefined = dnsCache?.getStats(); + const entries: DnsEntries | undefined = dnsCache?.getCacheEntries(); - return res.status(200).json({ - apiCaches, - imageCache: { - tmdb: tmdbImageCache, - avatar: avatarImageCache, - }, - dnsCache: { - stats, - entries, - }, - }); -}); + return res.status(200).json({ + apiCaches, + imageCache: { + tmdb: tmdbImageCache, + avatar: avatarImageCache, + }, + memory: memoryStats, + images: imageStats, + browser: { limitBytes: 768 * 1024 * 1024 }, + dnsCache: { + stats, + entries, + }, + }); + } +); settingsRoutes.post<{ cacheId: AvailableCacheIds }>( '/cache/:cacheId/flush', + isAuthenticated(Permission.ADMIN), (req, res, next) => { const cache = cacheManager.getCache(req.params.cacheId); @@ -1244,6 +1254,34 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>( } ); +settingsRoutes.post( + '/cache/images/flush', + isAuthenticated(Permission.ADMIN), + async (_req, res, next) => { + try { + await ImageProxy.clearAll(); + return res.status(204).send(); + } catch (error) { + return next({ status: 500, message: (error as Error).message }); + } + } +); + +settingsRoutes.post( + '/cache/all/flush', + isAuthenticated(Permission.ADMIN), + async (_req, res, next) => { + try { + for (const cache of Object.values(cacheManager.getAllCaches())) + cache.flush(); + await ImageProxy.clearAll(); + return res.status(204).send(); + } catch (error) { + return next({ status: 500, message: (error as Error).message }); + } + } +); + settingsRoutes.post<{ dnsEntry: string }>( '/cache/dns/:dnsEntry/flush', (req, res, next) => { From 43c4ec3f652b12be37e080e58b66d3462edee31f Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:14:35 +0300 Subject: [PATCH 02/48] feat: record managed job cancellation --- server/entity/JobExecutionState.ts | 3 ++ server/job/execution.ts | 45 ++++++++++++++----- server/job/schedule.ts | 2 + .../1786200000000-CreateJobExecutionState.ts | 2 +- .../1786200000000-CreateJobExecutionState.ts | 2 +- 5 files changed, 41 insertions(+), 13 deletions(-) diff --git a/server/entity/JobExecutionState.ts b/server/entity/JobExecutionState.ts index 8db30cba56..051a551672 100644 --- a/server/entity/JobExecutionState.ts +++ b/server/entity/JobExecutionState.ts @@ -16,6 +16,9 @@ export class JobExecutionState { @DbAwareColumn({ type: 'datetime', nullable: true }) public lastFailedAt?: Date | null; + @DbAwareColumn({ type: 'datetime', nullable: true }) + public lastCancelledAt?: Date | null; + @Column({ type: 'varchar', length: 512, nullable: true }) public lastFailureSummary?: string | null; diff --git a/server/job/execution.ts b/server/job/execution.ts index d7f26c124f..c9112b0855 100644 --- a/server/job/execution.ts +++ b/server/job/execution.ts @@ -3,6 +3,7 @@ import { JobExecutionState } from '@server/entity/JobExecutionState'; import logger from '@server/logger'; const activeJobs = new Set(); +const activeControllers = new Map(); let activeHeavyJobs = 0; let activeLightJobs = 0; @@ -16,11 +17,18 @@ const summarizeFailure = (error: unknown): string => ) .slice(0, 512); +/** Signals managed work; callers also cancel their underlying scanner. */ +export const cancelManagedJobs = (): void => { + for (const controller of activeControllers.values()) { + controller.abort(); + } +}; + /** Run scheduled and manual work through one non-overlapping, persisted path. */ export const executeManagedJob = async ( id: string, weight: ManagedJobWeight, - run: (signal: AbortSignal) => Promise + run: (signal: AbortSignal) => Promise ): Promise => { if ( activeJobs.has(id) || @@ -32,31 +40,46 @@ export const executeManagedJob = async ( activeJobs.add(id); if (weight === 'heavy') activeHeavyJobs += 1; else activeLightJobs += 1; - const repository = getRepository(JobExecutionState); - const existing = await repository.findOne({ where: { jobId: id } }); - const state = existing ?? new JobExecutionState({ jobId: id }); - state.lastStartedAt = new Date(); - await repository.save(state); const controller = new AbortController(); + activeControllers.set(id, controller); + let state: JobExecutionState | undefined; try { + const repository = getRepository(JobExecutionState); + const existing = await repository.findOne({ where: { jobId: id } }); + state = existing ?? new JobExecutionState({ jobId: id }); + state.lastStartedAt = new Date(); + await repository.save(state); await run(controller.signal); + if (controller.signal.aborted) { + state.lastCancelledAt = new Date(); + await repository.save(state); + return false; + } state.lastSucceededAt = new Date(); state.consecutiveFailures = 0; state.lastFailureSummary = null; await repository.save(state); return true; } catch (error) { - state.lastFailedAt = new Date(); - state.lastFailureSummary = summarizeFailure(error); - state.consecutiveFailures += 1; - await repository.save(state); + if (state && controller.signal.aborted) { + state.lastCancelledAt = new Date(); + await getRepository(JobExecutionState).save(state); + return false; + } + if (state) { + state.lastFailedAt = new Date(); + state.lastFailureSummary = summarizeFailure(error); + state.consecutiveFailures += 1; + await getRepository(JobExecutionState).save(state); + } logger.error(`Managed job failed: ${id}`, { label: 'Jobs', - message: state.lastFailureSummary, + message: summarizeFailure(error), }); return false; } finally { activeJobs.delete(id); + activeControllers.delete(id); if (weight === 'heavy') activeHeavyJobs -= 1; else activeLightJobs -= 1; } diff --git a/server/job/schedule.ts b/server/job/schedule.ts index b10750c3b3..8f29daecd6 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,6 +1,7 @@ import { MediaServerType } from '@server/constants/server'; import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor'; import episodeRequestSync from '@server/job/episodeRequestSync'; +import { cancelManagedJobs } from '@server/job/execution'; import availabilitySync from '@server/lib/availabilitySync'; import { isDesktopPlaybackActive } from '@server/lib/desktopState'; import downloadTracker from '@server/lib/downloadtracker'; @@ -319,6 +320,7 @@ export const startJobs = (): void => { }; export const stopJobs = (): void => { + cancelManagedJobs(); for (const scheduledJob of scheduledJobs) { scheduledJob.job.cancel(); scheduledJob.cancelFn?.(); diff --git a/server/migration/postgres/1786200000000-CreateJobExecutionState.ts b/server/migration/postgres/1786200000000-CreateJobExecutionState.ts index fffeab5a63..a7ebc299ac 100644 --- a/server/migration/postgres/1786200000000-CreateJobExecutionState.ts +++ b/server/migration/postgres/1786200000000-CreateJobExecutionState.ts @@ -4,7 +4,7 @@ export class CreateJobExecutionState1786200000000 implements MigrationInterface name = 'CreateJobExecutionState1786200000000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "job_execution_state" ("jobId" character varying(128) NOT NULL, "lastStartedAt" TIMESTAMP, "lastSucceededAt" TIMESTAMP, "lastFailedAt" TIMESTAMP, "lastFailureSummary" character varying(512), "consecutiveFailures" integer NOT NULL DEFAULT 0, CONSTRAINT "PK_job_execution_state" PRIMARY KEY ("jobId"))` + `CREATE TABLE "job_execution_state" ("jobId" character varying(128) NOT NULL, "lastStartedAt" TIMESTAMP, "lastSucceededAt" TIMESTAMP, "lastFailedAt" TIMESTAMP, "lastCancelledAt" TIMESTAMP, "lastFailureSummary" character varying(512), "consecutiveFailures" integer NOT NULL DEFAULT 0, CONSTRAINT "PK_job_execution_state" PRIMARY KEY ("jobId"))` ); } public async down(queryRunner: QueryRunner): Promise { diff --git a/server/migration/sqlite/1786200000000-CreateJobExecutionState.ts b/server/migration/sqlite/1786200000000-CreateJobExecutionState.ts index fbb243d94e..bf3252f718 100644 --- a/server/migration/sqlite/1786200000000-CreateJobExecutionState.ts +++ b/server/migration/sqlite/1786200000000-CreateJobExecutionState.ts @@ -4,7 +4,7 @@ export class CreateJobExecutionState1786200000000 implements MigrationInterface name = 'CreateJobExecutionState1786200000000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "job_execution_state" ("jobId" varchar(128) PRIMARY KEY NOT NULL, "lastStartedAt" datetime, "lastSucceededAt" datetime, "lastFailedAt" datetime, "lastFailureSummary" varchar(512), "consecutiveFailures" integer NOT NULL DEFAULT (0))` + `CREATE TABLE "job_execution_state" ("jobId" varchar(128) PRIMARY KEY NOT NULL, "lastStartedAt" datetime, "lastSucceededAt" datetime, "lastFailedAt" datetime, "lastCancelledAt" datetime, "lastFailureSummary" varchar(512), "consecutiveFailures" integer NOT NULL DEFAULT (0))` ); } public async down(queryRunner: QueryRunner): Promise { From b98887aeb9cd58be9de1a4ed550f2cdbc9ac9765 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:15:24 +0300 Subject: [PATCH 03/48] feat: serialize scheduled heavy jobs --- server/job/schedule.ts | 53 +++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 8f29daecd6..df0add3f7d 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,7 +1,11 @@ import { MediaServerType } from '@server/constants/server'; import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor'; import episodeRequestSync from '@server/job/episodeRequestSync'; -import { cancelManagedJobs } from '@server/job/execution'; +import { + cancelManagedJobs, + executeManagedJob, + type ManagedJobWeight, +} from '@server/job/execution'; import availabilitySync from '@server/lib/availabilitySync'; import { isDesktopPlaybackActive } from '@server/lib/desktopState'; import downloadTracker from '@server/lib/downloadtracker'; @@ -34,8 +38,13 @@ interface ScheduledJob { export const scheduledJobs: ScheduledJob[] = []; -const runHeavy = (name: string, run: () => void): void => { - if (isDesktopPlaybackActive()) { +const runScheduledJob = ( + id: string, + weight: ManagedJobWeight, + name: string, + run: () => Promise +): void => { + if (weight === 'heavy' && isDesktopPlaybackActive()) { logger.info( `Deferring heavy job while desktop playback is active: ${name}`, { @@ -44,9 +53,17 @@ const runHeavy = (name: string, run: () => void): void => { ); return; } - run(); + void executeManagedJob(id, weight, () => run()).catch((error) => { + logger.error(`Failed to record scheduled job: ${name}`, { + label: 'Jobs', + message: error instanceof Error ? error.message : String(error), + }); + }); }; +const runHeavy = (id: string, name: string, run: () => Promise) => + runScheduledJob(id, 'heavy', name, run); + export const startJobs = (): void => { if (scheduledJobs.length > 0) { return; @@ -68,7 +85,9 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Plex Recently Added Scan', { label: 'Jobs', }); - runHeavy('Plex Recently Added Scan', () => plexRecentScanner.run()); + runHeavy('plex-recently-added-scan', 'Plex Recently Added Scan', () => + plexRecentScanner.run() + ); } ), running: () => plexRecentScanner.status().running, @@ -86,7 +105,9 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Plex Full Library Scan', { label: 'Jobs', }); - runHeavy('Plex Full Library Scan', () => plexFullScanner.run()); + runHeavy('plex-full-scan', 'Plex Full Library Scan', () => + plexFullScanner.run() + ); }), running: () => plexFullScanner.status().running, cancelFn: () => plexFullScanner.cancel(), @@ -142,8 +163,10 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Jellyfin Recently Added Scan', { label: 'Jobs', }); - runHeavy('Jellyfin Recently Added Scan', () => - jellyfinRecentScanner.run() + runHeavy( + 'jellyfin-recently-added-scan', + 'Jellyfin Recently Added Scan', + () => jellyfinRecentScanner.run() ); } ), @@ -162,7 +185,9 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Jellyfin Full Scan', { label: 'Jobs', }); - runHeavy('Jellyfin Full Scan', () => jellyfinFullScanner.run()); + runHeavy('jellyfin-full-scan', 'Jellyfin Full Scan', () => + jellyfinFullScanner.run() + ); }), running: () => jellyfinFullScanner.status().running, cancelFn: () => jellyfinFullScanner.cancel(), @@ -179,7 +204,7 @@ export const startJobs = (): void => { cronSchedule: jobs['radarr-scan'].schedule, job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => { logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' }); - runHeavy('Radarr Scan', () => radarrScanner.run()); + runHeavy('radarr-scan', 'Radarr Scan', () => radarrScanner.run()); }), running: () => radarrScanner.status().running, cancelFn: () => radarrScanner.cancel(), @@ -225,7 +250,7 @@ export const startJobs = (): void => { cronSchedule: jobs['sonarr-scan'].schedule, job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => { logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' }); - runHeavy('Sonarr Scan', () => sonarrScanner.run()); + runHeavy('sonarr-scan', 'Sonarr Scan', () => sonarrScanner.run()); }), running: () => sonarrScanner.status().running, cancelFn: () => sonarrScanner.cancel(), @@ -242,7 +267,9 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Media Availability Sync', { label: 'Jobs', }); - runHeavy('Media Availability Sync', () => availabilitySync.run()); + runHeavy('availability-sync', 'Media Availability Sync', () => + availabilitySync.run() + ); }), running: () => availabilitySync.running, cancelFn: () => availabilitySync.cancel(), @@ -308,7 +335,7 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Process Blocklisted Tags', { label: 'Jobs', }); - runHeavy('Process Blocklisted Tags', () => + runHeavy('process-blocklisted-tags', 'Process Blocklisted Tags', () => blocklistedTagsProcessor.run() ); }), From 28f0fb019cd5e1a85163d58ff82c0c727a2418ed Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:16:19 +0300 Subject: [PATCH 04/48] feat: track scheduled light jobs --- server/job/schedule.ts | 59 +++++++++++++++++++++++++---------- server/lib/downloadtracker.ts | 8 +++-- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/server/job/schedule.ts b/server/job/schedule.ts index df0add3f7d..ea2a287a11 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -123,7 +123,12 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Plex Refresh Token', { label: 'Jobs', }); - refreshToken.run(); + runScheduledJob( + 'plex-refresh-token', + 'light', + 'Plex Refresh Token', + () => refreshToken.run() + ); }), }); @@ -138,12 +143,12 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Plex Watchlist Sync', { label: 'Jobs', }); - watchlistSync.syncWatchlist().catch((e) => { - logger.error('Failed to sync watchlists', { - label: 'Plex Watchlist Sync', - errorMessage: e.message, - }); - }); + runScheduledJob( + 'plex-watchlist-sync', + 'light', + 'Plex Watchlist Sync', + () => watchlistSync.syncWatchlist() + ); }), }); } else if ( @@ -220,7 +225,12 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Episode Request Sync', { label: 'Jobs', }); - episodeRequestSync.run(); + runScheduledJob( + 'episode-request-sync', + 'light', + 'Episode Request Sync', + () => episodeRequestSync.run() + ); }), running: () => episodeRequestSync.running, cancelFn: () => episodeRequestSync.cancel(), @@ -236,7 +246,12 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Release Calendar Sync', { label: 'Jobs', }); - releaseCalendarSync.run(); + runScheduledJob( + 'release-calendar-sync', + 'light', + 'Release Calendar Sync', + () => releaseCalendarSync.run() + ); }), running: () => releaseCalendarSync.running, cancelFn: () => releaseCalendarSync.cancel(), @@ -286,7 +301,9 @@ export const startJobs = (): void => { logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs', }); - downloadTracker.updateDownloads(); + runScheduledJob('download-sync', 'light', 'Download Sync', () => + downloadTracker.updateDownloads() + ); }), }); @@ -301,7 +318,12 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Download Sync Reset', { label: 'Jobs', }); - downloadTracker.resetDownloadTracker(); + runScheduledJob( + 'download-sync-reset', + 'light', + 'Download Sync Reset', + () => downloadTracker.resetDownloadTracker() + ); }), }); @@ -317,11 +339,16 @@ export const startJobs = (): void => { label: 'Jobs', }); // Clean TMDB image cache - ImageProxy.clearCache('tmdb'); - - // Clean users avatar image cache - ImageProxy.clearCache('avatar'); - void ImageProxy.maintainCache(); + runScheduledJob( + 'image-cache-cleanup', + 'light', + 'Image Cache Cleanup', + async () => { + ImageProxy.clearCache('tmdb'); + ImageProxy.clearCache('avatar'); + await ImageProxy.maintainCache(); + } + ); }), }); diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts index f160f6c89d..9a890e5143 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -59,9 +59,11 @@ class DownloadTracker { this.sonarrServers = {}; } - public updateDownloads() { - this.updateRadarrDownloads(); - this.updateSonarrDownloads(); + public async updateDownloads(): Promise { + await Promise.all([ + this.updateRadarrDownloads(), + this.updateSonarrDownloads(), + ]); } private async updateRadarrDownloads() { From a4c080a5e97159599f585b825904376f176db55e Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:18:00 +0300 Subject: [PATCH 05/48] test: cover shared cache budget eviction --- server/lib/cacheStore.test.ts | 44 +++++++++++++++++++++++++++++++++++ server/lib/cacheStore.ts | 4 ++++ 2 files changed, 48 insertions(+) create mode 100644 server/lib/cacheStore.test.ts diff --git a/server/lib/cacheStore.test.ts b/server/lib/cacheStore.test.ts new file mode 100644 index 0000000000..050128d667 --- /dev/null +++ b/server/lib/cacheStore.test.ts @@ -0,0 +1,44 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { CacheBudget, WeightedLruCacheStore } from './cacheStore'; + +describe('WeightedLruCacheStore', () => { + it('enforces one shared byte budget across provider stores', () => { + const budget = new CacheBudget(5); + const first = new WeightedLruCacheStore(budget); + const second = new WeightedLruCacheStore(budget); + + first.set('first', 'four'); + second.set('second', 'four'); + + assert.equal(first.get('first'), undefined); + assert.equal(second.get('second'), 'four'); + assert.equal(budget.stats().usedBytes, 4); + assert.equal(budget.stats().evictions, 1); + }); + + it('removes expired values before evicting live values', () => { + const budget = new CacheBudget(5); + const first = new WeightedLruCacheStore(budget); + const second = new WeightedLruCacheStore(budget); + + first.set('expired', 'four', -1); + second.set('live', 'four'); + + assert.equal(first.get('expired'), undefined); + assert.equal(second.get('live'), 'four'); + assert.equal(budget.stats().evictions, 0); + }); + + it('counts buffers without serializing them', () => { + const budget = new CacheBudget(8); + const store = new WeightedLruCacheStore(budget); + const payload = Buffer.from('bytes'); + + store.set('buffer', payload); + + assert.equal(store.get('buffer'), payload); + assert.equal(budget.stats().usedBytes, payload.length); + }); +}); diff --git a/server/lib/cacheStore.ts b/server/lib/cacheStore.ts index 003dafd66d..2473611c3e 100644 --- a/server/lib/cacheStore.ts +++ b/server/lib/cacheStore.ts @@ -41,6 +41,10 @@ export class CacheBudget { } ensureCapacity(): void { while (this.usedBytes > this.limitBytes) { + // Prune expiry across every provider before selecting an LRU victim. + // An expired item must never cause a live response to be discarded. + for (const store of this.stores) store.oldest(); + if (this.usedBytes <= this.limitBytes) return; const candidate = [...this.stores] .flatMap((store) => store.oldest()) .sort((a, b) => a.accessedAt - b.accessedAt)[0]; From 50a1451c136f4ae5a601d8ca6a9c0a04fe942360 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:18:44 +0300 Subject: [PATCH 06/48] feat: derive image cache limits from desktop budget --- server/lib/imageproxy.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 4fa3b69b97..4b3ac5bd17 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -25,8 +25,17 @@ const baseCacheDirectory = process.env.CACHE_DIRECTORY : process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/cache/images` : path.join(__dirname, '../../config/cache/images'); -const IMAGE_CACHE_HIGH_WATER_BYTES = 1280 * 1024 * 1024; -const IMAGE_CACHE_TRIM_TARGET_BYTES = 1024 * 1024 * 1024; +const DEFAULT_TRANSIENT_CACHE_BYTES = 2 * 1024 * 1024 * 1024; +const configuredTransientCacheBytes = Number( + process.env.FORESEER_CACHE_LIMIT_BYTES ?? DEFAULT_TRANSIENT_CACHE_BYTES +); +const transientCacheBytes = Number.isFinite(configuredTransientCacheBytes) + ? Math.max(configuredTransientCacheBytes, 128 * 1024 * 1024) + : DEFAULT_TRANSIENT_CACHE_BYTES; +// The combined desktop budget reserves 62.5% for images and 37.5% for CEF. +// Cleanup returns images to 50% of the combined budget to avoid thrashing. +const IMAGE_CACHE_HIGH_WATER_BYTES = Math.floor(transientCacheBytes * 0.625); +const IMAGE_CACHE_TRIM_TARGET_BYTES = Math.floor(transientCacheBytes * 0.5); let cleanupInProgress = false; /** Coerce Axios 1.18+ header values (string | number | boolean | string[]) to string. */ From 0972b307c7cbfd2f8e78b9b0396b85d4a47f5866 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:22:50 +0300 Subject: [PATCH 07/48] build: pin cron parser for scheduler catch-up --- package.json | 1 + pnpm-lock.yaml | 61 +++----------------------------------------------- 2 files changed, 4 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index 8eb4bd0064..781165b7c2 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "cookie-parser": "1.4.7", "copy-to-clipboard": "4.0.0", "country-flag-icons": "1.6.16", + "cron-parser": "4.9.0", "cronstrue": "3.14.0", "dns-caching": "^0.2.9", "email-templates": "13.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62a1faee89..c4e170176f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: country-flag-icons: specifier: 1.6.16 version: 1.6.16 + cron-parser: + specifier: 4.9.0 + version: 4.9.0 cronstrue: specifier: 3.14.0 version: 3.14.0 @@ -1541,209 +1544,177 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm64@1.3.0': resolution: {integrity: sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.3.0': resolution: {integrity: sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.3.0': resolution: {integrity: sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.3.0': resolution: {integrity: sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.3.0': resolution: {integrity: sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.3.0': resolution: {integrity: sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-arm64@1.3.0': resolution: {integrity: sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.3.0': resolution: {integrity: sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm64@0.35.0': resolution: {integrity: sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.35.0': resolution: {integrity: sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.35.0': resolution: {integrity: sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.35.0': resolution: {integrity: sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.35.0': resolution: {integrity: sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.35.0': resolution: {integrity: sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-arm64@0.35.0': resolution: {integrity: sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.35.0': resolution: {integrity: sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2136,28 +2107,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.2.11': resolution: {integrity: sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.2.11': resolution: {integrity: sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.2.11': resolution: {integrity: sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.2.11': resolution: {integrity: sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==} @@ -2640,28 +2607,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.6.5': resolution: {integrity: sha512-jyCKqoX50Fg8rJUQqh4u5PqnE7nqYKXHjVH2WcYr114/MU21zlsI+YL6aOQU1XP8bJQ2gPQ1rnlnGJdEHiKS/w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.6.5': resolution: {integrity: sha512-G6HmUn/RRIlXC0YYFfBz2qh6OZkHS/KUPkhoG4X9ADcgWXXjOFh6JrefwsYj8VBAJEnr5iewzjNfj+nztwHaeA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.6.5': resolution: {integrity: sha512-AQpBjBnelQDSbeTJA50AXdS6+CP66LsXIMNTwhPSgUfE7Bx1ggZV11Fsi4Q5SGcs6a8Qw1cuYKN57ZfZC5QOuA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.6.5': resolution: {integrity: sha512-MZTWM8kUwS30pVrtbzSGEXtek46aXNb/mT9D6rsS7NvOuv2w+qZhjR1rzf4LNbbn5f8VnR4Nac1WIOYZmfC5ng==} @@ -3110,109 +3073,91 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} From fa05c6b9b38a712b764e49a7fce4e8f41671c519 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:23:17 +0300 Subject: [PATCH 08/48] feat: add cron catch-up eligibility policy --- server/job/catchup.test.ts | 42 ++++++++++++++++++++++++++++++++++++++ server/job/catchup.ts | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 server/job/catchup.test.ts create mode 100644 server/job/catchup.ts diff --git a/server/job/catchup.test.ts b/server/job/catchup.test.ts new file mode 100644 index 0000000000..070a8cff7f --- /dev/null +++ b/server/job/catchup.test.ts @@ -0,0 +1,42 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { canRunLaunchCatchUp, hasMissedOccurrence } from './catchup'; + +describe('launch catch-up', () => { + const now = new Date('2026-08-22T12:00:00Z'); + + it('coalesces any number of missed cron occurrences into one decision', () => { + assert.equal( + hasMissedOccurrence('* * * * *', new Date('2026-08-22T11:00:00Z'), now), + true + ); + }); + + it('honors light and heavy failure backoff windows', () => { + assert.equal( + canRunLaunchCatchUp( + '* * * * *', + 'light', + { + lastSucceededAt: new Date('2026-08-22T10:00:00Z'), + lastFailedAt: new Date('2026-08-22T11:45:00Z'), + }, + now + ), + false + ); + assert.equal( + canRunLaunchCatchUp( + '* * * * *', + 'heavy', + { + lastSucceededAt: new Date('2026-08-22T01:00:00Z'), + lastFailedAt: new Date('2026-08-22T07:00:00Z'), + }, + now + ), + false + ); + }); +}); diff --git a/server/job/catchup.ts b/server/job/catchup.ts new file mode 100644 index 0000000000..dbb56f8985 --- /dev/null +++ b/server/job/catchup.ts @@ -0,0 +1,39 @@ +import { parseExpression } from 'cron-parser'; + +export type CatchUpWeight = 'heavy' | 'light'; + +export interface CatchUpState { + lastSucceededAt?: Date | null; + lastFailedAt?: Date | null; +} + +/** Returns whether one or more cron occurrences were missed. They coalesce. */ +export const hasMissedOccurrence = ( + schedule: string, + lastSucceededAt: Date | null | undefined, + now = new Date() +): boolean => { + if (!lastSucceededAt || lastSucceededAt >= now) return false; + try { + const iterator = parseExpression(schedule, { + currentDate: lastSucceededAt, + endDate: now, + }); + iterator.next(); + return true; + } catch { + return false; + } +}; + +export const canRunLaunchCatchUp = ( + schedule: string, + weight: CatchUpWeight, + state: CatchUpState, + now = new Date() +): boolean => { + if (!hasMissedOccurrence(schedule, state.lastSucceededAt, now)) return false; + if (!state.lastFailedAt) return true; + const delay = weight === 'heavy' ? 6 * 60 * 60_000 : 30 * 60_000; + return now.getTime() - state.lastFailedAt.getTime() >= delay; +}; From bd50b053ac893a14a6307c25a98145cb8306f0e5 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:26:22 +0300 Subject: [PATCH 09/48] feat: maintain image cache on server startup --- server/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/index.ts b/server/index.ts index 893cf7f543..fed1acd952 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,6 +13,7 @@ import { desktopError, } from '@server/lib/desktopRuntime'; import { setDesktopPlaybackActive } from '@server/lib/desktopState'; +import ImageProxy from '@server/lib/imageproxy'; import notificationManager from '@server/lib/notifications'; import DiscordAgent from '@server/lib/notifications/agents/discord'; import EmailAgent from '@server/lib/notifications/agents/email'; @@ -259,6 +260,11 @@ app // Bootstrap Discovery Sliders await DiscoverSlider.bootstrapSliders(); + // Prune expired and malformed transient image entries before accepting + // requests. Failures are contained inside the cache layer and must never + // prevent the durable application runtime from starting. + await ImageProxy.maintainCache(); + const server = express(); if (!desktopRuntime && settings.network.trustProxy) { server.enable('trust proxy'); From 616ee903145469fe9214b7fa2050abae5a646b11 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:28:10 +0300 Subject: [PATCH 10/48] feat: queue coalesced desktop job catch-up --- server/index.ts | 3 ++- server/job/schedule.ts | 43 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/server/index.ts b/server/index.ts index fed1acd952..bc729f4dc8 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,7 +5,7 @@ import DiscoverSlider from '@server/entity/DiscoverSlider'; import { Session } from '@server/entity/Session'; import { User } from '@server/entity/User'; import { initI18n } from '@server/i18n'; -import { startJobs, stopJobs } from '@server/job/schedule'; +import { startDesktopCatchUp, startJobs, stopJobs } from '@server/job/schedule'; import { assertSupportedDatabaseSchema } from '@server/lib/db/schemaGuard'; import { DESKTOP_SCHEMA_EXIT_CODE, @@ -248,6 +248,7 @@ app const totalUsers = await userRepository.count(); if (totalUsers > 0) { startJobs(); + startDesktopCatchUp(); } else { logger.info( `Skipping starting the scheduled jobs as we have no Plex/Jellyfin/Emby servers setup yet`, diff --git a/server/job/schedule.ts b/server/job/schedule.ts index ea2a287a11..0a1632ebd5 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,5 +1,8 @@ import { MediaServerType } from '@server/constants/server'; +import { getRepository } from '@server/datasource'; +import { JobExecutionState } from '@server/entity/JobExecutionState'; import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor'; +import { canRunLaunchCatchUp } from '@server/job/catchup'; import episodeRequestSync from '@server/job/episodeRequestSync'; import { cancelManagedJobs, @@ -38,6 +41,17 @@ interface ScheduledJob { export const scheduledJobs: ScheduledJob[] = []; +const heavyJobIds = new Set([ + 'plex-recently-added-scan', + 'plex-full-scan', + 'jellyfin-recently-added-scan', + 'jellyfin-full-scan', + 'radarr-scan', + 'sonarr-scan', + 'availability-sync', + 'process-blocklisted-tags', +]); + const runScheduledJob = ( id: string, weight: ManagedJobWeight, @@ -373,6 +387,35 @@ export const startJobs = (): void => { logger.info('Scheduled jobs loaded', { label: 'Jobs' }); }; +/** Queue one coalesced desktop catch-up pass after the UI has settled. */ +export const startDesktopCatchUp = (): void => { + if (process.env.FORESEERR_RUNTIME !== 'desktop') return; + setTimeout(() => { + void (async () => { + const repository = getRepository(JobExecutionState); + for (const job of scheduledJobs) { + // Download tracker reset has dedicated once-per-launch semantics. + if (job.id === 'download-sync-reset') continue; + const state = await repository.findOne({ where: { jobId: job.id } }); + const weight: ManagedJobWeight = heavyJobIds.has(job.id) + ? 'heavy' + : 'light'; + if (canRunLaunchCatchUp(job.cronSchedule, weight, state ?? {})) { + logger.info(`Running desktop catch-up: ${job.name}`, { + label: 'Jobs', + }); + job.job.invoke(); + } + } + })().catch((error) => { + logger.warn('Desktop catch-up evaluation failed', { + label: 'Jobs', + message: error instanceof Error ? error.message : String(error), + }); + }); + }, 30_000); +}; + export const stopJobs = (): void => { cancelManagedJobs(); for (const scheduledJob of scheduledJobs) { From 9b9cfe592cb96d55d114ed565c1b288fbba780d2 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:28:29 +0300 Subject: [PATCH 11/48] feat: resume desktop catch-up after playback --- server/index.ts | 1 + server/job/schedule.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/server/index.ts b/server/index.ts index bc729f4dc8..28387eb868 100644 --- a/server/index.ts +++ b/server/index.ts @@ -120,6 +120,7 @@ if (desktopRuntime) { typeof message.playbackActive === 'boolean' ) { setDesktopPlaybackActive(message.playbackActive); + if (!message.playbackActive) startDesktopCatchUp(); } else { logger.warn('Ignoring unknown desktop control message', { label: 'Desktop', diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 0a1632ebd5..5f85e144c4 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -51,6 +51,7 @@ const heavyJobIds = new Set([ 'availability-sync', 'process-blocklisted-tags', ]); +let desktopCatchUpTimer: NodeJS.Timeout | undefined; const runScheduledJob = ( id: string, @@ -390,7 +391,9 @@ export const startJobs = (): void => { /** Queue one coalesced desktop catch-up pass after the UI has settled. */ export const startDesktopCatchUp = (): void => { if (process.env.FORESEERR_RUNTIME !== 'desktop') return; - setTimeout(() => { + if (desktopCatchUpTimer) return; + desktopCatchUpTimer = setTimeout(() => { + desktopCatchUpTimer = undefined; void (async () => { const repository = getRepository(JobExecutionState); for (const job of scheduledJobs) { From bd88c061bfffb76972115b26d0a8a3e32395b278 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:29:47 +0300 Subject: [PATCH 12/48] fix: report managed runtime shutdown state --- server/index.ts | 6 +++++- server/lib/desktopState.ts | 7 +++++++ server/routes/index.ts | 3 ++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/server/index.ts b/server/index.ts index 28387eb868..22b251bae9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -12,7 +12,10 @@ import { acquireDesktopLock, desktopError, } from '@server/lib/desktopRuntime'; -import { setDesktopPlaybackActive } from '@server/lib/desktopState'; +import { + setDesktopPlaybackActive, + setDesktopStopping, +} from '@server/lib/desktopState'; import ImageProxy from '@server/lib/imageproxy'; import notificationManager from '@server/lib/notifications'; import DiscordAgent from '@server/lib/notifications/agents/discord'; @@ -69,6 +72,7 @@ let desktopOrigin = ''; const stopManagedRuntime = async (deadlineMs = 10_000): Promise => { if (stopping) return; stopping = true; + setDesktopStopping(true); stopJobs(); const server = managedServer; if (server) { diff --git a/server/lib/desktopState.ts b/server/lib/desktopState.ts index ecd0f1e715..4c84ca50d7 100644 --- a/server/lib/desktopState.ts +++ b/server/lib/desktopState.ts @@ -1,7 +1,14 @@ let playbackActive = false; +let stopping = false; export const setDesktopPlaybackActive = (active: boolean): void => { playbackActive = active; }; export const isDesktopPlaybackActive = (): boolean => playbackActive; + +export const setDesktopStopping = (value: boolean): void => { + stopping = value; +}; + +export const isDesktopStopping = (): boolean => stopping; diff --git a/server/routes/index.ts b/server/routes/index.ts index 722431dcae..e6b61e5a97 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -9,6 +9,7 @@ import { getRepository } from '@server/datasource'; import DiscoverSlider from '@server/entity/DiscoverSlider'; import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces'; import { scheduledJobs } from '@server/job/schedule'; +import { isDesktopStopping } from '@server/lib/desktopState'; import { createTmdbWithRegionLanguage } from '@server/lib/discover/tmdb'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; @@ -108,7 +109,7 @@ router.get('/status', async (req, res) => { runtime: 'desktop', managed: true, jobsStarted: scheduledJobs.length > 0, - stopping: false, + stopping: isDesktopStopping(), }), }); }); From d764ba45776cc057276bdb478caf7be4a9465ede Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:34:38 +0300 Subject: [PATCH 13/48] refactor: expose managed Foreseerr startup runtime --- server/index.ts | 595 ++++++++++++++++++++++++------------------------ 1 file changed, 302 insertions(+), 293 deletions(-) diff --git a/server/index.ts b/server/index.ts index 22b251bae9..fdccfa8c67 100644 --- a/server/index.ts +++ b/server/index.ts @@ -69,6 +69,12 @@ let releaseDesktopLock: (() => Promise) | undefined; let stopping = false; let desktopOrigin = ''; +export interface ForeseerrRuntime { + origin: string; + server: HttpServer; + stop(options?: { deadlineMs?: number }): Promise; +} + const stopManagedRuntime = async (deadlineMs = 10_000): Promise => { if (stopping) return; stopping = true; @@ -148,339 +154,342 @@ if (!appDataPermissions()) { ); } -app - .prepare() - .then(async () => { - if (desktopRuntime) { - releaseDesktopLock = await acquireDesktopLock(); - } - // Run Overseerr to Seerr migration - await checkOverseerrMerge(); +export const startForeseerr = async (): Promise => { + await app.prepare(); + if (desktopRuntime) { + releaseDesktopLock = await acquireDesktopLock(); + } + // Run Overseerr to Seerr migration + await checkOverseerrMerge(); - const dbConnection = dataSource.isInitialized - ? dataSource - : await dataSource.initialize(); + const dbConnection = dataSource.isInitialized + ? dataSource + : await dataSource.initialize(); - // Run migrations in production - if (process.env.NODE_ENV === 'production') { - if (isPgsql) { - await dbConnection.runMigrations(); - } else { - await dbConnection.query('PRAGMA foreign_keys=OFF'); - await dbConnection.runMigrations(); - await dbConnection.query('PRAGMA foreign_keys=ON'); - } + // Run migrations in production + if (process.env.NODE_ENV === 'production') { + if (isPgsql) { + await dbConnection.runMigrations(); + } else { + await dbConnection.query('PRAGMA foreign_keys=OFF'); + await dbConnection.runMigrations(); + await dbConnection.query('PRAGMA foreign_keys=ON'); } + } - // Refuse to start against a database migrated by a newer, unrecognized - // Foreseerr version instead of silently running against an unknown - // schema. Checked unconditionally (not just in production) since a - // downgraded dev/synchronize install could still point at such a DB. - try { - await assertSupportedDatabaseSchema(dbConnection); - } catch (error) { - if (desktopRuntime) { - await releaseDesktopLock?.(); - releaseDesktopLock = undefined; - throw desktopError( - `Desktop database schema is incompatible: ${(error as Error).message}`, - DESKTOP_SCHEMA_EXIT_CODE - ); - } - throw error; + // Refuse to start against a database migrated by a newer, unrecognized + // Foreseerr version instead of silently running against an unknown + // schema. Checked unconditionally (not just in production) since a + // downgraded dev/synchronize install could still point at such a DB. + try { + await assertSupportedDatabaseSchema(dbConnection); + } catch (error) { + if (desktopRuntime) { + await releaseDesktopLock?.(); + releaseDesktopLock = undefined; + throw desktopError( + `Desktop database schema is incompatible: ${(error as Error).message}`, + DESKTOP_SCHEMA_EXIT_CODE + ); } + throw error; + } - // Load Settings - const settings = await getSettings().load(); - restartFlag.initializeSettings(settings); + // Load Settings + const settings = await getSettings().load(); + restartFlag.initializeSettings(settings); - initI18n(); + initI18n(); - setForceIpv4First(settings.network.forceIpv4First); + setForceIpv4First(settings.network.forceIpv4First); - // Add DNS caching - if (settings.network.dnsCache?.enabled) { - initializeDnsCache({ - forceMinTtl: settings.network.dnsCache.forceMinTtl, - forceMaxTtl: settings.network.dnsCache.forceMaxTtl, - }); - } + // Add DNS caching + if (settings.network.dnsCache?.enabled) { + initializeDnsCache({ + forceMinTtl: settings.network.dnsCache.forceMinTtl, + forceMaxTtl: settings.network.dnsCache.forceMaxTtl, + }); + } - // Register HTTP proxy - if (settings.network.proxy.enabled) { - await createCustomProxyAgent( - settings.network.proxy, - settings.network.forceIpv4First - ); - } + // Register HTTP proxy + if (settings.network.proxy.enabled) { + await createCustomProxyAgent( + settings.network.proxy, + settings.network.forceIpv4First + ); + } - // Migrate library types - if ( - settings.plex.libraries.length > 1 && - !settings.plex.libraries[0].type - ) { - const userRepository = getRepository(User); - const admin = await userRepository.findOne({ - select: { id: true, plexToken: true }, - where: { id: 1 }, - }); + // Migrate library types + if (settings.plex.libraries.length > 1 && !settings.plex.libraries[0].type) { + const userRepository = getRepository(User); + const admin = await userRepository.findOne({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); - if (admin) { - logger.info('Migrating Plex libraries to include media type', { - label: 'Settings', - }); + if (admin) { + logger.info('Migrating Plex libraries to include media type', { + label: 'Settings', + }); - const plexapi = new PlexAPI({ plexToken: admin.plexToken }); - await plexapi.syncLibraries(); - } + const plexapi = new PlexAPI({ plexToken: admin.plexToken }); + await plexapi.syncLibraries(); } + } - // Register Notification Agents - notificationManager.registerAgents([ - new DiscordAgent(), - new EmailAgent(), - new GotifyAgent(), - new NtfyAgent(), - new PushbulletAgent(), - new PushoverAgent(), - new SlackAgent(), - new TelegramAgent(), - new WebhookAgent(), - new WebPushAgent(), - ]); + // Register Notification Agents + notificationManager.registerAgents([ + new DiscordAgent(), + new EmailAgent(), + new GotifyAgent(), + new NtfyAgent(), + new PushbulletAgent(), + new PushoverAgent(), + new SlackAgent(), + new TelegramAgent(), + new WebhookAgent(), + new WebPushAgent(), + ]); - const userRepository = getRepository(User); - const totalUsers = await userRepository.count(); - if (totalUsers > 0) { - startJobs(); - startDesktopCatchUp(); - } else { - logger.info( - `Skipping starting the scheduled jobs as we have no Plex/Jellyfin/Emby servers setup yet`, - { - label: 'Server', - } - ); - } + const userRepository = getRepository(User); + const totalUsers = await userRepository.count(); + if (totalUsers > 0) { + startJobs(); + startDesktopCatchUp(); + } else { + logger.info( + `Skipping starting the scheduled jobs as we have no Plex/Jellyfin/Emby servers setup yet`, + { + label: 'Server', + } + ); + } - // Bootstrap Discovery Sliders - await DiscoverSlider.bootstrapSliders(); + // Bootstrap Discovery Sliders + await DiscoverSlider.bootstrapSliders(); - // Prune expired and malformed transient image entries before accepting - // requests. Failures are contained inside the cache layer and must never - // prevent the durable application runtime from starting. - await ImageProxy.maintainCache(); + // Prune expired and malformed transient image entries before accepting + // requests. Failures are contained inside the cache layer and must never + // prevent the durable application runtime from starting. + await ImageProxy.maintainCache(); - const server = express(); - if (!desktopRuntime && settings.network.trustProxy) { - server.enable('trust proxy'); - } - if (desktopRuntime) { - server.use((req, res, next) => { - const unsafeMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes( - req.method - ); - const nativeTicket = req.path === '/api/v1/desktop/auth-tickets/redeem'; - if ( - (stopping && req.path !== '/api/v1/status') || - !desktopOrigin || - req.headers.host !== desktopOrigin.replace('http://', '') || - req.headers['x-forwarded-host'] || - req.headers.forwarded || - req.originalUrl.startsWith('http://') || - req.originalUrl.startsWith('https://') || - (unsafeMethod && - !nativeTicket && - req.headers.origin !== desktopOrigin) - ) { - res.status(stopping ? 503 : 403).json({ - message: stopping - ? 'Foreseerr is shutting down' - : 'Invalid local desktop request', - }); - return; - } - next(); - }); - } - server.use(cookieParser()); - server.use(express.json()); - server.use(express.urlencoded({ extended: true })); - server.use((req, _res, next) => { - try { - const descriptor = Object.getOwnPropertyDescriptor(req, 'ip'); - if (descriptor?.writable === true) { - Object.defineProperty(req, 'ip', { - ...descriptor, - value: getClientIp(req) ?? '', - }); - } - } catch (e) { - logger.error('Failed to attach the ip to the request', { - label: 'Middleware', - message: (e as Error).message, + const server = express(); + if (!desktopRuntime && settings.network.trustProxy) { + server.enable('trust proxy'); + } + if (desktopRuntime) { + server.use((req, res, next) => { + const unsafeMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes( + req.method + ); + const nativeTicket = req.path === '/api/v1/desktop/auth-tickets/redeem'; + if ( + (stopping && req.path !== '/api/v1/status') || + !desktopOrigin || + req.headers.host !== desktopOrigin.replace('http://', '') || + req.headers['x-forwarded-host'] || + req.headers.forwarded || + req.originalUrl.startsWith('http://') || + req.originalUrl.startsWith('https://') || + (unsafeMethod && !nativeTicket && req.headers.origin !== desktopOrigin) + ) { + res.status(stopping ? 503 : 403).json({ + message: stopping + ? 'Foreseerr is shutting down' + : 'Invalid local desktop request', }); - } finally { - next(); + return; } + next(); }); - if (desktopRuntime || settings.network.csrfProtection) { - server.use( - csurf({ - cookie: { - httpOnly: true, - sameSite: desktopRuntime ? 'strict' : true, - secure: desktopRuntime ? false : !dev, - key: '_csrf', - path: '/', - }, - // Native hosts redeem with ticket+verifier and no browser cookies. - ignoreRequest: (req) => - req.method === 'POST' && - req.path === '/api/v1/desktop/auth-tickets/redeem', - }) - ); - server.use((req, res, next) => { - res.cookie('XSRF-TOKEN', req.csrfToken(), { - sameSite: desktopRuntime ? 'strict' : true, - secure: desktopRuntime ? false : !dev, + } + server.use(cookieParser()); + server.use(express.json()); + server.use(express.urlencoded({ extended: true })); + server.use((req, _res, next) => { + try { + const descriptor = Object.getOwnPropertyDescriptor(req, 'ip'); + if (descriptor?.writable === true) { + Object.defineProperty(req, 'ip', { + ...descriptor, + value: getClientIp(req) ?? '', }); - next(); + } + } catch (e) { + logger.error('Failed to attach the ip to the request', { + label: 'Middleware', + message: (e as Error).message, }); + } finally { + next(); } - - // Set up sessions - const sessionRespository = getRepository(Session); + }); + if (desktopRuntime || settings.network.csrfProtection) { server.use( - '/api', - session({ - secret: settings.sessionSecret, - resave: false, - saveUninitialized: false, + csurf({ cookie: { - maxAge: 1000 * 60 * 60 * 24 * 30, httpOnly: true, - sameSite: - desktopRuntime || settings.network.csrfProtection - ? 'strict' - : 'lax', - secure: desktopRuntime ? false : 'auto', + sameSite: desktopRuntime ? 'strict' : true, + secure: desktopRuntime ? false : !dev, + key: '_csrf', + path: '/', }, - store: new TypeormStore({ - cleanupLimit: 2, - ttl: 60 * 60 * 24 * 30, - }).connect(sessionRespository) as Store, - }) - ); - const apiSpecContent = await fs.readFile(API_SPEC_PATH, 'utf-8'); - const apiDocs = yaml.load(apiSpecContent) as Record; - server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs)); - server.use( - OpenApiValidator.middleware({ - apiSpec: API_SPEC_PATH, - validateRequests: true, + // Native hosts redeem with ticket+verifier and no browser cookies. + ignoreRequest: (req) => + req.method === 'POST' && + req.path === '/api/v1/desktop/auth-tickets/redeem', }) ); - /** - * This is a workaround to convert dates to strings before they are validated by - * OpenAPI validator. Otherwise, they are treated as objects instead of strings - * and response validation will fail - */ - server.use((_req, res, next) => { - const original = res.json; - res.json = function jsonp(json) { - return original.call(this, JSON.parse(JSON.stringify(json))); - }; + server.use((req, res, next) => { + res.cookie('XSRF-TOKEN', req.csrfToken(), { + sameSite: desktopRuntime ? 'strict' : true, + secure: desktopRuntime ? false : !dev, + }); next(); }); - server.use('/api/v1', routes); + } - // Do not set cookies so CDNs can cache them - server.use('/imageproxy', clearCookies, imageproxy); - server.use('/avatarproxy', clearCookies, avatarproxy); + // Set up sessions + const sessionRespository = getRepository(Session); + server.use( + '/api', + session({ + secret: settings.sessionSecret, + resave: false, + saveUninitialized: false, + cookie: { + maxAge: 1000 * 60 * 60 * 24 * 30, + httpOnly: true, + sameSite: + desktopRuntime || settings.network.csrfProtection ? 'strict' : 'lax', + secure: desktopRuntime ? false : 'auto', + }, + store: new TypeormStore({ + cleanupLimit: 2, + ttl: 60 * 60 * 24 * 30, + }).connect(sessionRespository) as Store, + }) + ); + const apiSpecContent = await fs.readFile(API_SPEC_PATH, 'utf-8'); + const apiDocs = yaml.load(apiSpecContent) as Record; + server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs)); + server.use( + OpenApiValidator.middleware({ + apiSpec: API_SPEC_PATH, + validateRequests: true, + }) + ); + /** + * This is a workaround to convert dates to strings before they are validated by + * OpenAPI validator. Otherwise, they are treated as objects instead of strings + * and response validation will fail + */ + server.use((_req, res, next) => { + const original = res.json; + res.json = function jsonp(json) { + return original.call(this, JSON.parse(JSON.stringify(json))); + }; + next(); + }); + server.use('/api/v1', routes); - server.get('*path', (req, res) => handle(req, res)); - server.use( - ( - err: { - status: number; - message: string; - errors: string[]; - retryAfter?: number; - }, - _req: Request, - res: Response, - // We must provide a next function for the function signature here even though its not used - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _next: NextFunction - ) => { - // format error - if ( - err.status === 429 && - typeof err.retryAfter === 'number' && - Number.isFinite(err.retryAfter) - ) { - res.setHeader('Retry-After', String(Math.ceil(err.retryAfter))); - } - res.status(err.status || 500).json({ - message: err.message, - errors: err.errors, - }); - } - ); + // Do not set cookies so CDNs can cache them + server.use('/imageproxy', clearCookies, imageproxy); + server.use('/avatarproxy', clearCookies, avatarproxy); - const configuredPort = Number(process.env.PORT); - const port = - Number.isInteger(configuredPort) && configuredPort >= 0 - ? configuredPort - : 5055; - const host = desktopRuntime ? '127.0.0.1' : process.env.HOST; - let httpServer: HttpServer; - if (host) { - httpServer = server.listen(port, host, () => { - logger.info(`Server ready on ${host} port ${port}`, { - label: 'Server', - }); - }); - } else { - httpServer = server.listen(port, () => { - logger.info(`Server ready on port ${port}`, { - label: 'Server', - }); + server.get('*path', (req, res) => handle(req, res)); + server.use( + ( + err: { + status: number; + message: string; + errors: string[]; + retryAfter?: number; + }, + _req: Request, + res: Response, + // We must provide a next function for the function signature here even though its not used + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _next: NextFunction + ) => { + // format error + if ( + err.status === 429 && + typeof err.retryAfter === 'number' && + Number.isFinite(err.retryAfter) + ) { + res.setHeader('Retry-After', String(Math.ceil(err.retryAfter))); + } + res.status(err.status || 500).json({ + message: err.message, + errors: err.errors, }); } - httpServer.on('error', (err) => { - logger.error('Failed to start server', { + ); + + const configuredPort = Number(process.env.PORT); + const port = + Number.isInteger(configuredPort) && configuredPort >= 0 + ? configuredPort + : 5055; + const host = desktopRuntime ? '127.0.0.1' : process.env.HOST; + let httpServer: HttpServer; + if (host) { + httpServer = server.listen(port, host, () => { + logger.info(`Server ready on ${host} port ${port}`, { label: 'Server', - message: err.message, }); - process.exit(1); }); - managedServer = httpServer; - if (desktopRuntime) { - httpServer.on('listening', () => { - const address = httpServer.address(); - if ( - typeof address !== 'object' || - !address || - address.address !== '127.0.0.1' - ) { - logger.error('Desktop runtime did not bind exact loopback', { - label: 'Desktop', - }); - requestManagedShutdown(); - return; - } - desktopOrigin = `http://127.0.0.1:${address.port}`; - process.stdout.write( - `FORESEERR_DESKTOP_READY ${JSON.stringify({ protocolVersion: 1, pid: process.pid, origin: desktopOrigin, foreseerrVersion: getAppVersion(), commit: process.env.FORESEERR_COMMIT ?? 'unknown', schemaVersion: 0 })}\n` - ); + } else { + httpServer = server.listen(port, () => { + logger.info(`Server ready on port ${port}`, { + label: 'Server', }); - } - }) - .catch(async (err) => { - logger.error(err.stack); - await releaseDesktopLock?.(); - releaseDesktopLock = undefined; - process.exit(err.exitCode ?? 1); + }); + } + httpServer.on('error', (err) => { + logger.error('Failed to start server', { + label: 'Server', + message: err.message, + }); }); + managedServer = httpServer; + if (desktopRuntime) { + httpServer.on('listening', () => { + const address = httpServer.address(); + if ( + typeof address !== 'object' || + !address || + address.address !== '127.0.0.1' + ) { + logger.error('Desktop runtime did not bind exact loopback', { + label: 'Desktop', + }); + requestManagedShutdown(); + return; + } + desktopOrigin = `http://127.0.0.1:${address.port}`; + process.stdout.write( + `FORESEERR_DESKTOP_READY ${JSON.stringify({ protocolVersion: 1, pid: process.pid, origin: desktopOrigin, foreseerrVersion: getAppVersion(), commit: process.env.FORESEERR_COMMIT ?? 'unknown', schemaVersion: 0 })}\n` + ); + }); + } + await new Promise((resolve, reject) => { + httpServer.once('listening', resolve); + httpServer.once('error', reject); + }); + return { + origin: desktopRuntime + ? desktopOrigin + : `http://${host ?? '127.0.0.1'}:${port}`, + server: httpServer, + stop: (options) => stopManagedRuntime(options?.deadlineMs), + }; +}; + +startForeseerr().catch(async (err) => { + logger.error(err.stack); + await releaseDesktopLock?.(); + releaseDesktopLock = undefined; + process.exit(err.exitCode ?? 1); +}); From f33497e34fb760acb000c8f73f06df8cb177716f Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:35:21 +0300 Subject: [PATCH 14/48] feat: parameterize managed server bind address --- server/index.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/server/index.ts b/server/index.ts index fdccfa8c67..9f7d3aaab3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -75,6 +75,12 @@ export interface ForeseerrRuntime { stop(options?: { deadlineMs?: number }): Promise; } +export interface ForeseerrStartOptions { + host?: string; + port?: number; + runtime?: 'hosted' | 'desktop'; +} + const stopManagedRuntime = async (deadlineMs = 10_000): Promise => { if (stopping) return; stopping = true; @@ -154,7 +160,9 @@ if (!appDataPermissions()) { ); } -export const startForeseerr = async (): Promise => { +export const startForeseerr = async ( + options: ForeseerrStartOptions = {} +): Promise => { await app.prepare(); if (desktopRuntime) { releaseDesktopLock = await acquireDesktopLock(); @@ -427,12 +435,16 @@ export const startForeseerr = async (): Promise => { } ); - const configuredPort = Number(process.env.PORT); + const configuredPort = options.port ?? Number(process.env.PORT); const port = Number.isInteger(configuredPort) && configuredPort >= 0 ? configuredPort : 5055; - const host = desktopRuntime ? '127.0.0.1' : process.env.HOST; + const host = + options.host ?? (desktopRuntime ? '127.0.0.1' : process.env.HOST); + if (desktopRuntime && host !== '127.0.0.1') { + throw new Error('Desktop runtime must bind exact IPv4 loopback'); + } let httpServer: HttpServer; if (host) { httpServer = server.listen(port, host, () => { From 8f99602fdd188ba32cc8482e421f17256642357a Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:51:01 +0300 Subject: [PATCH 15/48] refactor: separate Foreseerr process launcher --- launcher.js | 2 +- package.json | 4 ++-- server/index.ts | 27 +++++++++++++++++---------- server/launcher.ts | 10 ++++++++++ 4 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 server/launcher.ts diff --git a/launcher.js b/launcher.js index 1d52cf971b..43cb7640dc 100644 --- a/launcher.js +++ b/launcher.js @@ -1,2 +1,2 @@ #!/usr/bin/env node -import('./dist/index.js'); +import('./dist/launcher.js'); diff --git a/package.json b/package.json index 781165b7c2..20f5e9b43c 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,14 @@ "scripts": { "preinstall": "npx only-allow pnpm", "postinstall": "next telemetry disable", - "dev": "nodemon -e ts,json,yml --watch server --watch seerr-api.yml --exec 'ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts'", + "dev": "nodemon -e ts,json,yml --watch server --watch seerr-api.yml --exec 'ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/launcher.ts'", "build:server": "tsc --project server/tsconfig.build.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && copyfiles -u 2 \"server/i18n/locale/*.json\" dist/i18n && tsc-alias -p server/tsconfig.build.json", "build:next": "next build", "build": "pnpm build:next && pnpm build:server", "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache", "lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix", "test": "node server/test/index.mts", - "start": "NODE_ENV=production node dist/index.js", + "start": "NODE_ENV=production node dist/launcher.js", "i18n:extract": "ts-node --project server/tsconfig.json server/i18n/extractMessages.ts", "migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts", "migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts", diff --git a/server/index.ts b/server/index.ts index 9f7d3aaab3..445ac4376f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -63,11 +63,12 @@ logger.info(`Starting Seerr version ${getAppVersion()}`); const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); const handle = app.getRequestHandler(); -const desktopRuntime = process.env.FORESEERR_RUNTIME === 'desktop'; +let desktopRuntime = process.env.FORESEERR_RUNTIME === 'desktop'; let managedServer: HttpServer | undefined; let releaseDesktopLock: (() => Promise) | undefined; let stopping = false; let desktopOrigin = ''; +let desktopControlInstalled = false; export interface ForeseerrRuntime { origin: string; @@ -115,7 +116,9 @@ const requestManagedShutdown = (deadlineMs = 10_000): void => { void stopManagedRuntime(deadlineMs).finally(() => process.exit(0)); }; -if (desktopRuntime) { +const installDesktopControlHandlers = (): void => { + if (desktopControlInstalled) return; + desktopControlInstalled = true; process.stdin.setEncoding('utf8'); let input = ''; process.stdin.on('data', (chunk: string) => { @@ -152,7 +155,7 @@ if (desktopRuntime) { process.stdin.on('end', () => requestManagedShutdown()); process.once('SIGTERM', () => requestManagedShutdown()); process.once('SIGINT', () => requestManagedShutdown()); -} +}; if (!appDataPermissions()) { logger.error( @@ -163,6 +166,17 @@ if (!appDataPermissions()) { export const startForeseerr = async ( options: ForeseerrStartOptions = {} ): Promise => { + if (managedServer) { + throw new Error('Foreseerr runtime is already running'); + } + desktopRuntime = + options.runtime === 'desktop' || + (options.runtime === undefined && + process.env.FORESEERR_RUNTIME === 'desktop'); + if (desktopRuntime) installDesktopControlHandlers(); + stopping = false; + desktopOrigin = ''; + setDesktopStopping(false); await app.prepare(); if (desktopRuntime) { releaseDesktopLock = await acquireDesktopLock(); @@ -498,10 +512,3 @@ export const startForeseerr = async ( stop: (options) => stopManagedRuntime(options?.deadlineMs), }; }; - -startForeseerr().catch(async (err) => { - logger.error(err.stack); - await releaseDesktopLock?.(); - releaseDesktopLock = undefined; - process.exit(err.exitCode ?? 1); -}); diff --git a/server/launcher.ts b/server/launcher.ts new file mode 100644 index 0000000000..3b9a937a70 --- /dev/null +++ b/server/launcher.ts @@ -0,0 +1,10 @@ +// The process entry point is intentionally separate from server construction. +// Tests and embedding launchers can import `startForeseerr` without opening a +// listener; Docker and the desktop managed child use this module instead. +import { startForeseerr } from '@server/index'; +import logger from '@server/logger'; + +startForeseerr().catch((error: Error & { exitCode?: number }) => { + logger.error(error.stack ?? error.message); + process.exit(error.exitCode ?? 1); +}); From 97eec10768fe886e71724dfa11c887e55e34efcb Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:52:31 +0300 Subject: [PATCH 16/48] fix: reserve managed child stdout for readiness --- server/logger.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/server/logger.ts b/server/logger.ts index 628b9775cf..5125ed129a 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -15,10 +15,18 @@ const hformat = winston.format.printf( } ); +// A managed desktop child has a machine-readable stdout contract: its sole +// stdout record is readiness. All diagnostics, including normal Winston +// console output, must therefore go to stderr while durable logs use the +// explicitly-owned log directory. +const logDirectory = process.env.LOG_DIRECTORY + ? process.env.LOG_DIRECTORY + : process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/logs` + : path.join(__dirname, '../config/logs'); + const seerrFileTransport = new winston.transports.DailyRotateFile({ - filename: process.env.CONFIG_DIRECTORY - ? `${process.env.CONFIG_DIRECTORY}/logs/seerr-%DATE%.log` - : path.join(__dirname, '../config/logs/seerr-%DATE%.log'), + filename: path.join(logDirectory, 'seerr-%DATE%.log'), datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', @@ -27,9 +35,7 @@ const seerrFileTransport = new winston.transports.DailyRotateFile({ symlinkName: 'seerr.log', }); const machineLogFileTransport = new winston.transports.DailyRotateFile({ - filename: process.env.CONFIG_DIRECTORY - ? `${process.env.CONFIG_DIRECTORY}/logs/.machinelogs-%DATE%.json` - : path.join(__dirname, '../config/logs/.machinelogs-%DATE%.json'), + filename: path.join(logDirectory, '.machinelogs-%DATE%.json'), datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', @@ -60,6 +66,10 @@ const logger = winston.createLogger({ ), transports: [ new winston.transports.Console({ + stderrLevels: + process.env.FORESEERR_RUNTIME === 'desktop' + ? ['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly'] + : ['error', 'warn'], format: winston.format.combine( winston.format.colorize(), winston.format.splat(), From db937288f202ea791112d237466b7341b3f19b97 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:55:14 +0300 Subject: [PATCH 17/48] feat: authorize browser cache clearing through desktop ticket --- protocol/protocol-v1.json | 8 +++- server/index.ts | 5 ++- server/routes/desktop.ts | 78 +++++++++++++++++++++++++++++++++ server/routes/settings/index.ts | 22 ++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index 7aa97f33fd..55de2f7014 100644 --- a/protocol/protocol-v1.json +++ b/protocol/protocol-v1.json @@ -11,6 +11,7 @@ "auth-bootstrap", "player-events", "session-reset", + "browser-cache-clear", "window-controls", "quit", "setup" @@ -52,6 +53,10 @@ "type": "setup.save", "fields": ["id", "url", "allowHttp"] }, + { + "type": "browser-cache.clear", + "fields": ["id", "ticket"] + }, { "type": "window.minimize", "fields": ["id"] @@ -81,7 +86,8 @@ "canceled", "error", "connectivity-success", - "save-config-success" + "save-config-success", + "browser-cache-cleared" ], "terminalPlayEventTypes": ["stopped", "finished", "canceled", "error"], "envelopes": { diff --git a/server/index.ts b/server/index.ts index 445ac4376f..cc0e84ea6a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -359,7 +359,10 @@ export const startForeseerr = async ( // Native hosts redeem with ticket+verifier and no browser cookies. ignoreRequest: (req) => req.method === 'POST' && - req.path === '/api/v1/desktop/auth-tickets/redeem', + [ + '/api/v1/desktop/auth-tickets/redeem', + '/api/v1/desktop/browser-cache/redeem', + ].includes(req.path), }) ); server.use((req, res, next) => { diff --git a/server/routes/desktop.ts b/server/routes/desktop.ts index 8630aa75db..973460bfc3 100644 --- a/server/routes/desktop.ts +++ b/server/routes/desktop.ts @@ -4,6 +4,7 @@ import { getRepository } from '@server/datasource'; import { DesktopAuthTicket } from '@server/entity/DesktopAuthTicket'; import { Session } from '@server/entity/Session'; import { User } from '@server/entity/User'; +import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import { isAuthenticated } from '@server/middleware/auth'; import { ApiError } from '@server/types/error'; @@ -18,6 +19,10 @@ const ticketLifetimeMs = 60_000; const maxRequestsPerWindow = 10; const rateWindowMs = 60_000; const requests = new Map(); +const browserCacheTickets = new Map< + string, + { userId: number; sessionId: string; expiresAt: number } +>(); desktopRoutes.use((_req, res, next) => { res.setHeader('Cache-Control', 'no-store'); @@ -35,6 +40,11 @@ const redeemBody = z.object({ protocolVersion: z.literal(1), }); +const browserCacheRedeemBody = z.object({ + ticket: z.string().regex(/^[A-Za-z0-9_-]{43}$/), + protocolVersion: z.literal(1), +}); + const digest = (value: string) => createHash('sha256').update(value, 'utf8').digest('hex'); @@ -69,6 +79,30 @@ const allowRequest = (key: string) => { /** Test-only: reset in-memory redeem/issue rate windows between cases. */ export const resetDesktopAuthRateLimitsForTests = () => { requests.clear(); + browserCacheTickets.clear(); +}; + +const cleanupBrowserCacheTickets = () => { + const now = Date.now(); + for (const [ticket, value] of browserCacheTickets) { + if (value.expiresAt <= now) browserCacheTickets.delete(ticket); + } +}; + +/** + * A browser session may authorize an administrative cache action, but only the + * native host can clear Chromium's request context. Keep the handoff opaque, + * short-lived, single-use, and bound to the issuing session. + */ +export const issueBrowserCacheTicket = (userId: number, sessionId: string) => { + cleanupBrowserCacheTickets(); + const ticket = randomBytes(32).toString('base64url'); + browserCacheTickets.set(digest(ticket), { + userId, + sessionId, + expiresAt: Date.now() + ticketLifetimeMs, + }); + return { ticket, expiresIn: ticketLifetimeMs }; }; const cleanupExpiredTickets = async () => { @@ -260,4 +294,48 @@ desktopRoutes.post('/auth-tickets/redeem', async (req, res, next) => { } }); +desktopRoutes.post('/browser-cache/redeem', async (req, res, next) => { + const parsed = browserCacheRedeemBody.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ code: 'invalid_request' }); + } + if (!allowRequest(`browser-cache:ip:${req.ip ?? ''}`)) { + return res.status(429).json({ code: 'rate_limited' }); + } + + cleanupBrowserCacheTickets(); + const ticketDigest = digest(parsed.data.ticket); + const record = browserCacheTickets.get(ticketDigest); + // Consume before I/O to make the action unrepeatable even if a client races + // the native bridge. The action itself is idempotent. + browserCacheTickets.delete(ticketDigest); + if (!record || record.expiresAt <= Date.now()) { + return res.status(401).json({ code: 'ticket_expired' }); + } + + try { + const session = await getRepository(Session).findOne({ + where: { id: record.sessionId, expiredAt: MoreThan(Date.now()) }, + }); + let sessionUserId: number | undefined; + try { + sessionUserId = session ? JSON.parse(session.json).userId : undefined; + } catch { + sessionUserId = undefined; + } + const user = await getRepository(User).findOne({ + where: { id: record.userId }, + }); + if ( + sessionUserId !== record.userId || + !user?.hasPermission(Permission.ADMIN) + ) { + return res.status(401).json({ code: 'session_expired' }); + } + return res.status(204).send(); + } catch (error) { + return next(error); + } +}); + export default desktopRoutes; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 265655e6b9..faedadce62 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -41,6 +41,7 @@ import { } from '@server/lib/trakt'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; +import { issueBrowserCacheTicket } from '@server/routes/desktop'; import discoverSettingRoutes from '@server/routes/settings/discover'; import { ApiError } from '@server/types/error'; import { appDataPath } from '@server/utils/appDataVolume'; @@ -1267,6 +1268,27 @@ settingsRoutes.post( } ); +settingsRoutes.post( + '/cache/browser/flush', + isAuthenticated(Permission.ADMIN), + (req, res, next) => { + if (!req.user || !req.sessionID) { + return next({ status: 401, message: 'Session required.' }); + } + // Remote/browser deployments cannot and must not ask a native runtime to + // clear a profile they do not own. + if (process.env.FORESEERR_RUNTIME !== 'desktop') { + return next({ + status: 409, + message: 'Browser cache is managed by the desktop runtime only.', + }); + } + return res + .status(201) + .json(issueBrowserCacheTicket(req.user.id, req.sessionID)); + } +); + settingsRoutes.post( '/cache/all/flush', isAuthenticated(Permission.ADMIN), From 5996c34432a5619fccd8b25e10e085ba8b3fe93a Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:56:13 +0300 Subject: [PATCH 18/48] feat: add standalone cache controls --- .../Settings/SettingsJobsCache/index.tsx | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 3660b226a1..84c458d963 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -109,6 +109,14 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages( imagecachecount: 'Images Cached', imagecachesize: 'Total Cache Size', usersavatars: "Users' Avatars", + clearimagecache: 'Clear Image Cache', + clearbrowsercache: 'Clear Browser HTTP Cache', + clearallcaches: 'Clear All Transient Caches', + imagecacheflushed: 'Image cache cleared.', + browsercacheflushed: 'Browser HTTP cache cleared.', + allcachesflushed: 'All Foreseerr transient caches cleared.', + browsercacheunavailable: + 'Browser cache clearing is available only in Foreseer Desktop.', } ); @@ -275,6 +283,51 @@ const SettingsJobs = () => { cacheRevalidate(); }; + const flushImageCache = async () => { + await axios.post('/api/v1/settings/cache/images/flush'); + addToast(intl.formatMessage(messages.imagecacheflushed), { + appearance: 'success', + autoDismiss: true, + }); + cacheRevalidate(); + }; + + const flushAllCaches = async () => { + await axios.post('/api/v1/settings/cache/all/flush'); + addToast(intl.formatMessage(messages.allcachesflushed), { + appearance: 'success', + autoDismiss: true, + }); + cacheRevalidate(); + }; + + const flushBrowserCache = async () => { + const host = window.foreseerNative; + if (!host?.capabilities.includes('browser-cache-clear')) { + addToast(intl.formatMessage(messages.browsercacheunavailable), { + appearance: 'error', + autoDismiss: true, + }); + return; + } + const response = await axios.post<{ ticket: string }>( + '/api/v1/settings/cache/browser/flush' + ); + if ( + !host.send({ + type: 'browser-cache.clear', + id: crypto.randomUUID(), + ticket: response.data.ticket, + }) + ) { + throw new Error('Native browser cache bridge is unavailable'); + } + addToast(intl.formatMessage(messages.browsercacheflushed), { + appearance: 'success', + autoDismiss: true, + }); + }; + const scheduleJob = async () => { const jobScheduleCron = ['0', '0', '*', '*', '*', '*']; @@ -608,6 +661,20 @@ const SettingsJobs = () => { +
+ + + +
{cacheData?.dnsCache != null && ( <>
From 2b7c35d6102111b2024b1eae8eef51cb9db0cd5c Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:57:15 +0300 Subject: [PATCH 19/48] fix: include browser cache in desktop clear all --- .../Settings/SettingsJobsCache/index.tsx | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 84c458d963..c1afc3cda9 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -292,8 +292,31 @@ const SettingsJobs = () => { cacheRevalidate(); }; + const requestBrowserCacheClear = async () => { + const host = window.foreseerNative; + if (!host?.capabilities.includes('browser-cache-clear')) { + return false; + } + const response = await axios.post<{ ticket: string }>( + '/api/v1/settings/cache/browser/flush' + ); + if ( + !host.send({ + type: 'browser-cache.clear', + id: crypto.randomUUID(), + ticket: response.data.ticket, + }) + ) { + throw new Error('Native browser cache bridge is unavailable'); + } + return true; + }; + const flushAllCaches = async () => { await axios.post('/api/v1/settings/cache/all/flush'); + // Browser cache is native-owned. Include it when this is the desktop app, + // while preserving the hosted deployment's server-only clear behavior. + await requestBrowserCacheClear(); addToast(intl.formatMessage(messages.allcachesflushed), { appearance: 'success', autoDismiss: true, @@ -302,26 +325,13 @@ const SettingsJobs = () => { }; const flushBrowserCache = async () => { - const host = window.foreseerNative; - if (!host?.capabilities.includes('browser-cache-clear')) { + if (!(await requestBrowserCacheClear())) { addToast(intl.formatMessage(messages.browsercacheunavailable), { appearance: 'error', autoDismiss: true, }); return; } - const response = await axios.post<{ ticket: string }>( - '/api/v1/settings/cache/browser/flush' - ); - if ( - !host.send({ - type: 'browser-cache.clear', - id: crypto.randomUUID(), - ticket: response.data.ticket, - }) - ) { - throw new Error('Native browser cache bridge is unavailable'); - } addToast(intl.formatMessage(messages.browsercacheflushed), { appearance: 'success', autoDismiss: true, From 96d689ee08525122b90dd74736208776de0b7b3f Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:02:02 +0300 Subject: [PATCH 20/48] fix: derive managed status from start runtime --- server/index.ts | 2 ++ server/lib/desktopState.ts | 8 ++++++++ server/routes/index.ts | 4 ++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/server/index.ts b/server/index.ts index cc0e84ea6a..6cd68ac043 100644 --- a/server/index.ts +++ b/server/index.ts @@ -14,6 +14,7 @@ import { } from '@server/lib/desktopRuntime'; import { setDesktopPlaybackActive, + setDesktopRuntime, setDesktopStopping, } from '@server/lib/desktopState'; import ImageProxy from '@server/lib/imageproxy'; @@ -173,6 +174,7 @@ export const startForeseerr = async ( options.runtime === 'desktop' || (options.runtime === undefined && process.env.FORESEERR_RUNTIME === 'desktop'); + setDesktopRuntime(desktopRuntime); if (desktopRuntime) installDesktopControlHandlers(); stopping = false; desktopOrigin = ''; diff --git a/server/lib/desktopState.ts b/server/lib/desktopState.ts index 4c84ca50d7..2ebb692f16 100644 --- a/server/lib/desktopState.ts +++ b/server/lib/desktopState.ts @@ -1,5 +1,6 @@ let playbackActive = false; let stopping = false; +let managedDesktopRuntime = process.env.FORESEERR_RUNTIME === 'desktop'; export const setDesktopPlaybackActive = (active: boolean): void => { playbackActive = active; @@ -12,3 +13,10 @@ export const setDesktopStopping = (value: boolean): void => { }; export const isDesktopStopping = (): boolean => stopping; + +/** Runtime mode is chosen by the managed start API, not just process env. */ +export const setDesktopRuntime = (value: boolean): void => { + managedDesktopRuntime = value; +}; + +export const isDesktopRuntime = (): boolean => managedDesktopRuntime; diff --git a/server/routes/index.ts b/server/routes/index.ts index e6b61e5a97..b683136c4d 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -9,7 +9,7 @@ import { getRepository } from '@server/datasource'; import DiscoverSlider from '@server/entity/DiscoverSlider'; import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces'; import { scheduledJobs } from '@server/job/schedule'; -import { isDesktopStopping } from '@server/lib/desktopState'; +import { isDesktopRuntime, isDesktopStopping } from '@server/lib/desktopState'; import { createTmdbWithRegionLanguage } from '@server/lib/discover/tmdb'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; @@ -105,7 +105,7 @@ router.get('/status', async (req, res) => { commitTag: getCommitTag(), ...(checkUpdate && { updateAvailable, commitsBehind }), restartRequired: restartFlag.isSet(), - ...(process.env.FORESEERR_RUNTIME === 'desktop' && { + ...(isDesktopRuntime() && { runtime: 'desktop', managed: true, jobsStarted: scheduledJobs.length > 0, From 11ac295f7fedf543d39b6a64f3a8f90492f2fd2f Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:03:00 +0300 Subject: [PATCH 21/48] fix: run download tracker once per desktop launch --- server/job/schedule.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 5f85e144c4..42adcf2d6f 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -52,6 +52,7 @@ const heavyJobIds = new Set([ 'process-blocklisted-tags', ]); let desktopCatchUpTimer: NodeJS.Timeout | undefined; +let desktopDownloadStartupRun = false; const runScheduledJob = ( id: string, @@ -396,9 +397,28 @@ export const startDesktopCatchUp = (): void => { desktopCatchUpTimer = undefined; void (async () => { const repository = getRepository(JobExecutionState); + // Download tracking intentionally does not replay cron history. Each + // desktop launch resets stale tracker state and performs one fresh poll, + // then the ordinary schedule resumes. + if (!desktopDownloadStartupRun) { + desktopDownloadStartupRun = true; + await executeManagedJob('download-sync-reset', 'light', () => + downloadTracker.resetDownloadTracker() + ); + await executeManagedJob('download-sync', 'light', () => + downloadTracker.updateDownloads() + ); + } for (const job of scheduledJobs) { - // Download tracker reset has dedicated once-per-launch semantics. - if (job.id === 'download-sync-reset') continue; + // Download tracking and image cleanup have dedicated startup + // maintenance semantics rather than missed-cron replay. + if ( + job.id === 'download-sync-reset' || + job.id === 'download-sync' || + job.id === 'image-cache-cleanup' + ) { + continue; + } const state = await repository.findOne({ where: { jobId: job.id } }); const weight: ManagedJobWeight = heavyJobIds.has(job.id) ? 'heavy' From ed7429ccb803ca948324e80d8a6c6bf12ed926a6 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:03:29 +0300 Subject: [PATCH 22/48] fix: honor managed runtime in job catch-up --- server/job/schedule.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 42adcf2d6f..303f9220e4 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -10,7 +10,10 @@ import { type ManagedJobWeight, } from '@server/job/execution'; import availabilitySync from '@server/lib/availabilitySync'; -import { isDesktopPlaybackActive } from '@server/lib/desktopState'; +import { + isDesktopPlaybackActive, + isDesktopRuntime, +} from '@server/lib/desktopState'; import downloadTracker from '@server/lib/downloadtracker'; import ImageProxy from '@server/lib/imageproxy'; import refreshToken from '@server/lib/refreshToken'; @@ -391,7 +394,7 @@ export const startJobs = (): void => { /** Queue one coalesced desktop catch-up pass after the UI has settled. */ export const startDesktopCatchUp = (): void => { - if (process.env.FORESEERR_RUNTIME !== 'desktop') return; + if (!isDesktopRuntime()) return; if (desktopCatchUpTimer) return; desktopCatchUpTimer = setTimeout(() => { desktopCatchUpTimer = undefined; From 4c98db2918ceaa2c1b84b6ca818ff76f7bcbd01e Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:04:27 +0300 Subject: [PATCH 23/48] test: cover desktop catch-up boundaries --- server/job/catchup.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/server/job/catchup.test.ts b/server/job/catchup.test.ts index 070a8cff7f..9b614fcf7d 100644 --- a/server/job/catchup.test.ts +++ b/server/job/catchup.test.ts @@ -26,6 +26,18 @@ describe('launch catch-up', () => { ), false ); + assert.equal( + canRunLaunchCatchUp( + '* * * * *', + 'light', + { + lastSucceededAt: new Date('2026-08-22T10:00:00Z'), + lastFailedAt: new Date('2026-08-22T11:30:00Z'), + }, + now + ), + true + ); assert.equal( canRunLaunchCatchUp( '* * * * *', @@ -38,5 +50,29 @@ describe('launch catch-up', () => { ), false ); + assert.equal( + canRunLaunchCatchUp( + '* * * * *', + 'heavy', + { + lastSucceededAt: new Date('2026-08-22T01:00:00Z'), + lastFailedAt: new Date('2026-08-22T06:00:00Z'), + }, + now + ), + true + ); + }); + + it('does not invent overdue work without a successful baseline', () => { + assert.equal(hasMissedOccurrence('* * * * *', undefined, now), false); + assert.equal( + hasMissedOccurrence('* * * * *', new Date('2026-08-22T12:00:00Z'), now), + false + ); + assert.equal( + hasMissedOccurrence('not a cron', new Date('2026-08-22T11:00:00Z'), now), + false + ); }); }); From 191d8a5bddbcb184b6f18b31e8bb29e15452f8d2 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:05:33 +0300 Subject: [PATCH 24/48] test: cover browser cache authorization tickets --- server/routes/desktop.test.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/server/routes/desktop.test.ts b/server/routes/desktop.test.ts index dcdf939161..b5ce636729 100644 --- a/server/routes/desktop.test.ts +++ b/server/routes/desktop.test.ts @@ -15,7 +15,10 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { after, before, beforeEach, describe, it } from 'node:test'; import request from 'supertest'; -import desktopRoutes, { resetDesktopAuthRateLimitsForTests } from './desktop'; +import desktopRoutes, { + issueBrowserCacheTicket, + resetDesktopAuthRateLimitsForTests, +} from './desktop'; let app: Express; let apiApp: Express; @@ -87,6 +90,31 @@ beforeEach(async () => { }); describe('desktop auth tickets', () => { + it('redeems a browser-cache ticket exactly once for its admin session', async () => { + const issued = issueBrowserCacheTicket(1, 'desktop-test-session'); + const redeemed = await request(app) + .post('/desktop/browser-cache/redeem') + .send({ ticket: issued.ticket, protocolVersion: 1 }); + assert.strictEqual(redeemed.status, 204); + assert.strictEqual(redeemed.headers['cache-control'], 'no-store'); + + const replay = await request(app) + .post('/desktop/browser-cache/redeem') + .send({ ticket: issued.ticket, protocolVersion: 1 }); + assert.strictEqual(replay.status, 401); + assert.strictEqual(replay.body.code, 'ticket_expired'); + }); + + it('rejects browser-cache tickets after the issuing session expires', async () => { + const issued = issueBrowserCacheTicket(1, 'desktop-test-session'); + await getRepository(Session).delete({ id: 'desktop-test-session' }); + const response = await request(app) + .post('/desktop/browser-cache/redeem') + .send({ ticket: issued.ticket, protocolVersion: 1 }); + assert.strictEqual(response.status, 401); + assert.strictEqual(response.body.code, 'session_expired'); + }); + it('issues and redeems a ticket once', async () => { const verifier = 'v'.repeat(43); const challenge = await import('node:crypto').then(({ createHash }) => From 285d19d3b362974678a75077762cdc8f9a5b834c Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:06:34 +0300 Subject: [PATCH 25/48] fix: await native browser cache clear result --- .../Settings/SettingsJobsCache/index.tsx | 50 +++++++++++++++---- src/context/nativeRuntimeProtocol.ts | 1 + 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index c1afc3cda9..c3da26998f 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -300,15 +300,47 @@ const SettingsJobs = () => { const response = await axios.post<{ ticket: string }>( '/api/v1/settings/cache/browser/flush' ); - if ( - !host.send({ - type: 'browser-cache.clear', - id: crypto.randomUUID(), - ticket: response.data.ticket, - }) - ) { - throw new Error('Native browser cache bridge is unavailable'); - } + const id = crypto.randomUUID(); + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + cleanup(); + reject(new Error('Native browser cache clear timed out')); + }, 10_000); + const onEvent = (event: Event) => { + const detail = ( + event as CustomEvent<{ + id?: string; + type?: string; + errorCode?: string; + }> + ).detail; + if (detail?.id !== id) return; + if (detail.type === 'browser-cache-cleared') { + cleanup(); + resolve(); + } else if (detail.type === 'error') { + cleanup(); + reject( + new Error(detail.errorCode ?? 'Native browser cache clear failed') + ); + } + }; + const cleanup = () => { + window.clearTimeout(timeout); + window.removeEventListener('foreseer:native-event', onEvent); + }; + window.addEventListener('foreseer:native-event', onEvent); + if ( + !host.send({ + type: 'browser-cache.clear', + id, + ticket: response.data.ticket, + }) + ) { + cleanup(); + reject(new Error('Native browser cache bridge is unavailable')); + } + }); return true; }; diff --git a/src/context/nativeRuntimeProtocol.ts b/src/context/nativeRuntimeProtocol.ts index b099567c52..9ddef569bd 100644 --- a/src/context/nativeRuntimeProtocol.ts +++ b/src/context/nativeRuntimeProtocol.ts @@ -50,6 +50,7 @@ export const nativeHostEventTypesV1 = [ 'error', 'connectivity-success', 'save-config-success', + 'browser-cache-cleared', ] as const; export type NativeHostEventTypeV1 = (typeof nativeHostEventTypesV1)[number]; From 7a075883bfe9a759a180434cec09d32a1d94ca0b Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:07:55 +0300 Subject: [PATCH 26/48] feat: recognize standalone runtime recovery event --- protocol/protocol-v1.json | 3 ++- src/context/nativeRuntimeProtocol.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index 55de2f7014..931b3811cc 100644 --- a/protocol/protocol-v1.json +++ b/protocol/protocol-v1.json @@ -87,7 +87,8 @@ "error", "connectivity-success", "save-config-success", - "browser-cache-cleared" + "browser-cache-cleared", + "runtime-failed" ], "terminalPlayEventTypes": ["stopped", "finished", "canceled", "error"], "envelopes": { diff --git a/src/context/nativeRuntimeProtocol.ts b/src/context/nativeRuntimeProtocol.ts index 9ddef569bd..acbed12178 100644 --- a/src/context/nativeRuntimeProtocol.ts +++ b/src/context/nativeRuntimeProtocol.ts @@ -51,6 +51,7 @@ export const nativeHostEventTypesV1 = [ 'connectivity-success', 'save-config-success', 'browser-cache-cleared', + 'runtime-failed', ] as const; export type NativeHostEventTypeV1 = (typeof nativeHostEventTypesV1)[number]; From 724b96e180597e03e96cc033f760875ec6339aed Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:11:50 +0300 Subject: [PATCH 27/48] feat: recognize standalone runtime recovery retry --- protocol/protocol-v1.json | 7 ++++++- src/context/nativeRuntimeProtocol.ts | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index 931b3811cc..f0e9ca5676 100644 --- a/protocol/protocol-v1.json +++ b/protocol/protocol-v1.json @@ -57,6 +57,10 @@ "type": "browser-cache.clear", "fields": ["id", "ticket"] }, + { + "type": "runtime.retry", + "fields": ["id"] + }, { "type": "window.minimize", "fields": ["id"] @@ -88,7 +92,8 @@ "connectivity-success", "save-config-success", "browser-cache-cleared", - "runtime-failed" + "runtime-failed", + "runtime-recovered" ], "terminalPlayEventTypes": ["stopped", "finished", "canceled", "error"], "envelopes": { diff --git a/src/context/nativeRuntimeProtocol.ts b/src/context/nativeRuntimeProtocol.ts index acbed12178..0f42acddb3 100644 --- a/src/context/nativeRuntimeProtocol.ts +++ b/src/context/nativeRuntimeProtocol.ts @@ -52,6 +52,7 @@ export const nativeHostEventTypesV1 = [ 'save-config-success', 'browser-cache-cleared', 'runtime-failed', + 'runtime-recovered', ] as const; export type NativeHostEventTypeV1 = (typeof nativeHostEventTypesV1)[number]; From e4418f1277091be39a583e702271a90c2a978aeb Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:16:22 +0300 Subject: [PATCH 28/48] fix: clean managed runtime after startup failure --- server/index.ts | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/server/index.ts b/server/index.ts index 6cd68ac043..b3f04f78fd 100644 --- a/server/index.ts +++ b/server/index.ts @@ -164,7 +164,7 @@ if (!appDataPermissions()) { ); } -export const startForeseerr = async ( +const startForeseerrInternal = async ( options: ForeseerrStartOptions = {} ): Promise => { if (managedServer) { @@ -517,3 +517,41 @@ export const startForeseerr = async ( stop: (options) => stopManagedRuntime(options?.deadlineMs), }; }; + +/** + * A desktop launcher can retry after a failed start without replacing its + * process. Release resources acquired before readiness so the retry is not + * blocked by this process's own SQLite connection or instance lock. + */ +const cleanupFailedDesktopStart = async (): Promise => { + stopJobs(); + const server = managedServer; + managedServer = undefined; + if (server) { + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => resolve()); + }).catch(() => undefined); + } + if (dataSource.isInitialized) { + await dataSource.destroy().catch(() => undefined); + } + await releaseDesktopLock?.(); + releaseDesktopLock = undefined; + desktopOrigin = ''; + stopping = false; + setDesktopStopping(false); +}; + +export const startForeseerr = async ( + options: ForeseerrStartOptions = {} +): Promise => { + try { + return await startForeseerrInternal(options); + } catch (error) { + if (desktopRuntime) { + await cleanupFailedDesktopStart(); + } + throw error; + } +}; From c1d393b93ea8e3fa71f46d0856c8f16ed6b06436 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:19:25 +0300 Subject: [PATCH 29/48] feat: share library artwork memory budget --- server/lib/cache.ts | 4 +- server/lib/cacheStore.ts | 47 ++++++++++++++++---- server/lib/libraryImageCache.ts | 79 ++++++++++++--------------------- 3 files changed, 69 insertions(+), 61 deletions(-) diff --git a/server/lib/cache.ts b/server/lib/cache.ts index cf3b825944..8f3b9e82f3 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -20,7 +20,7 @@ export type AvailableCacheIds = | 'anilist'; const DEFAULT_TTL = 300; -const cacheBudget = new CacheBudget(); +export const memoryCacheBudget = new CacheBudget(); class Cache { public id: AvailableCacheIds; @@ -35,7 +35,7 @@ class Cache { this.id = id; this.name = name; this.data = new WeightedLruCacheStore( - cacheBudget, + memoryCacheBudget, options.stdTtl ?? DEFAULT_TTL ); } diff --git a/server/lib/cacheStore.ts b/server/lib/cacheStore.ts index 2473611c3e..7ff8b3789a 100644 --- a/server/lib/cacheStore.ts +++ b/server/lib/cacheStore.ts @@ -25,6 +25,7 @@ type Entry = { export class CacheBudget { private usedBytes = 0; private evictions = 0; + private accessSequence = 0; private readonly stores = new Set(); constructor(readonly limitBytes = 256 * 1024 * 1024) {} register(store: WeightedLruCacheStore): void { @@ -39,6 +40,10 @@ export class CacheBudget { evicted(): void { this.evictions += 1; } + nextAccess(): number { + this.accessSequence += 1; + return this.accessSequence; + } ensureCapacity(): void { while (this.usedBytes > this.limitBytes) { // Prune expiry across every provider before selecting an LRU victim. @@ -66,7 +71,12 @@ export class WeightedLruCacheStore implements CacheStore { private entries = new Map(); constructor( private readonly budget: CacheBudget, - private readonly defaultTtl = 300 + private readonly defaultTtl = 300, + private readonly limits: { + maxEntries?: number; + maxBytes?: number; + estimateSize?: (value: unknown) => number; + } = {} ) { budget.register(this); } @@ -77,20 +87,21 @@ export class WeightedLruCacheStore implements CacheStore { this.evict(key, false); return undefined; } - entry.accessedAt = Date.now(); + entry.accessedAt = this.budget.nextAccess(); return entry.value as T; } set(key: string, value: T, ttlSeconds = this.defaultTtl): void { this.evict(key, false); const entry = { value, - size: estimateSize(value), + size: this.limits.estimateSize?.(value) ?? estimateSize(value), expiresAt: Date.now() + ttlSeconds * 1000, - accessedAt: Date.now(), + accessedAt: this.budget.nextAccess(), }; this.entries.set(key, entry); this.budget.add(entry.size); this.removeExpired(); + this.trimToLocalLimits(); this.budget.ensureCapacity(); } delete(key: string): void { @@ -115,11 +126,13 @@ export class WeightedLruCacheStore implements CacheStore { accessedAt: number; }[] { this.removeExpired(); - return [...this.entries].map(([key, entry]) => ({ - store: this, - key, - accessedAt: entry.accessedAt, - })); + return [...this.entries] + .map(([key, entry]) => ({ + store: this, + key, + accessedAt: entry.accessedAt, + })) + .sort((a, b) => a.accessedAt - b.accessedAt); } count(): number { this.removeExpired(); @@ -136,6 +149,22 @@ export class WeightedLruCacheStore implements CacheStore { for (const [key, entry] of this.entries) if (entry.expiresAt <= Date.now()) this.evict(key, false); } + private trimToLocalLimits(): void { + while ( + (this.limits.maxEntries !== undefined && + this.entries.size > this.limits.maxEntries) || + (this.limits.maxBytes !== undefined && this.size() > this.limits.maxBytes) + ) { + const oldest = this.oldest()[0]; + if (!oldest) return; + this.evict(oldest.key); + } + } + private size(): number { + let total = 0; + for (const entry of this.entries.values()) total += entry.size; + return total; + } } const estimateSize = (value: unknown): number => { diff --git a/server/lib/libraryImageCache.ts b/server/lib/libraryImageCache.ts index dbf8125233..728e86e448 100644 --- a/server/lib/libraryImageCache.ts +++ b/server/lib/libraryImageCache.ts @@ -1,3 +1,6 @@ +import { memoryCacheBudget } from '@server/lib/cache'; +import { WeightedLruCacheStore } from '@server/lib/cacheStore'; + export const LIBRARY_IMAGE_CACHE_TTL_MS = 6 * 60 * 60 * 1000; export const LIBRARY_IMAGE_CACHE_MAX_ENTRIES = 256; export const LIBRARY_IMAGE_CACHE_MAX_BYTES = 64 * 1024 * 1024; @@ -5,11 +8,23 @@ export const LIBRARY_IMAGE_CACHE_MAX_BYTES = 64 * 1024 * 1024; type LibraryImageCacheEntry = { buffer: Buffer; contentType: string; - expiresAt: number; - lastAccessAt: number; }; -const libraryImageCache = new Map(); +// Library artwork participates in the provider-response budget. Its local +// caps preserve the established cache behavior while the shared budget can +// reclaim it first when another cache needs memory. +const libraryImageCache = new WeightedLruCacheStore( + memoryCacheBudget, + LIBRARY_IMAGE_CACHE_TTL_MS / 1000, + { + maxEntries: LIBRARY_IMAGE_CACHE_MAX_ENTRIES, + maxBytes: LIBRARY_IMAGE_CACHE_MAX_BYTES, + estimateSize: (value) => { + const entry = value as LibraryImageCacheEntry; + return entry.buffer.byteLength + Buffer.byteLength(entry.contentType); + }, + } +); export const libraryImageCacheKey = ( userId: number, @@ -17,50 +32,17 @@ export const libraryImageCacheKey = ( imageType: 'primary' | 'backdrop' ): string => `${userId}:${jellyfinItemId}:${imageType}`; -const cacheBytes = (): number => { - let total = 0; - for (const entry of libraryImageCache.values()) { - total += entry.buffer.byteLength; - } - return total; -}; - -const evictIfNeeded = (incomingBytes: number): void => { - while ( - libraryImageCache.size >= LIBRARY_IMAGE_CACHE_MAX_ENTRIES || - cacheBytes() + incomingBytes > LIBRARY_IMAGE_CACHE_MAX_BYTES - ) { - let oldestKey: string | undefined; - let oldestAccess = Number.POSITIVE_INFINITY; - for (const [key, entry] of libraryImageCache) { - if (entry.lastAccessAt < oldestAccess) { - oldestAccess = entry.lastAccessAt; - oldestKey = key; - } - } - if (!oldestKey) { - break; - } - libraryImageCache.delete(oldestKey); - } -}; - export const getCachedLibraryImage = ( userId: number, jellyfinItemId: string, imageType: 'primary' | 'backdrop' ): { buffer: Buffer; contentType: string } | undefined => { - const key = libraryImageCacheKey(userId, jellyfinItemId, imageType); - const cached = libraryImageCache.get(key); - if (!cached) { - return undefined; - } - if (cached.expiresAt <= Date.now()) { - libraryImageCache.delete(key); - return undefined; - } - cached.lastAccessAt = Date.now(); - return { buffer: cached.buffer, contentType: cached.contentType }; + const cached = libraryImageCache.get( + libraryImageCacheKey(userId, jellyfinItemId, imageType) + ); + return cached + ? { buffer: cached.buffer, contentType: cached.contentType } + : undefined; }; export const setCachedLibraryImage = ( @@ -69,19 +51,16 @@ export const setCachedLibraryImage = ( imageType: 'primary' | 'backdrop', image: { buffer: Buffer; contentType: string } ): void => { - evictIfNeeded(image.buffer.byteLength); libraryImageCache.set( libraryImageCacheKey(userId, jellyfinItemId, imageType), - { - ...image, - expiresAt: Date.now() + LIBRARY_IMAGE_CACHE_TTL_MS, - lastAccessAt: Date.now(), - } + image, + LIBRARY_IMAGE_CACHE_TTL_MS / 1000 ); }; export const resetLibraryImageCache = (): void => { - libraryImageCache.clear(); + libraryImageCache.flush(); }; -export const libraryImageCacheSize = (): number => libraryImageCache.size; +export const libraryImageCacheSize = (): number => + libraryImageCache.keys().length; From 5d578b15a92fc951b4128a32f911e54efaa71af8 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:20:26 +0300 Subject: [PATCH 30/48] fix: clear in-memory artwork with image cache --- server/routes/settings/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index faedadce62..680f8ab417 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -27,6 +27,7 @@ import { clearIntegrationHealthCache, getIntegrationHealth, } from '@server/lib/integrationHealth'; +import { resetLibraryImageCache } from '@server/lib/libraryImageCache'; import { clearAnilistSyncCache } from '@server/lib/mediaActions/anilistSyncCache'; import { clearSyncCache } from '@server/lib/mediaActions/syncCache'; import { Permission } from '@server/lib/permissions'; @@ -1261,6 +1262,7 @@ settingsRoutes.post( async (_req, res, next) => { try { await ImageProxy.clearAll(); + resetLibraryImageCache(); return res.status(204).send(); } catch (error) { return next({ status: 500, message: (error as Error).message }); @@ -1297,6 +1299,7 @@ settingsRoutes.post( for (const cache of Object.values(cacheManager.getAllCaches())) cache.flush(); await ImageProxy.clearAll(); + resetLibraryImageCache(); return res.status(204).send(); } catch (error) { return next({ status: 500, message: (error as Error).message }); From 92398793e252b6516a1c878d8b9cce9b4bfa0493 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:23:33 +0300 Subject: [PATCH 31/48] feat: add native standalone log recovery action --- protocol/protocol-v1.json | 7 ++++++- src/context/nativeRuntimeProtocol.ts | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index f0e9ca5676..bf6a0c1ca5 100644 --- a/protocol/protocol-v1.json +++ b/protocol/protocol-v1.json @@ -61,6 +61,10 @@ "type": "runtime.retry", "fields": ["id"] }, + { + "type": "runtime.open-logs", + "fields": ["id"] + }, { "type": "window.minimize", "fields": ["id"] @@ -93,7 +97,8 @@ "save-config-success", "browser-cache-cleared", "runtime-failed", - "runtime-recovered" + "runtime-recovered", + "logs-opened" ], "terminalPlayEventTypes": ["stopped", "finished", "canceled", "error"], "envelopes": { diff --git a/src/context/nativeRuntimeProtocol.ts b/src/context/nativeRuntimeProtocol.ts index 0f42acddb3..b2ff81160d 100644 --- a/src/context/nativeRuntimeProtocol.ts +++ b/src/context/nativeRuntimeProtocol.ts @@ -53,6 +53,7 @@ export const nativeHostEventTypesV1 = [ 'browser-cache-cleared', 'runtime-failed', 'runtime-recovered', + 'logs-opened', ] as const; export type NativeHostEventTypeV1 = (typeof nativeHostEventTypesV1)[number]; From 52a56fbc4ed4d7ad58f0f6c203366860506accf4 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:25:13 +0300 Subject: [PATCH 32/48] fix: cancel pending desktop catch-up on shutdown --- server/job/schedule.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 303f9220e4..4e32189df3 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -443,6 +443,11 @@ export const startDesktopCatchUp = (): void => { }; export const stopJobs = (): void => { + if (desktopCatchUpTimer) { + clearTimeout(desktopCatchUpTimer); + desktopCatchUpTimer = undefined; + } + desktopDownloadStartupRun = false; cancelManagedJobs(); for (const scheduledJob of scheduledJobs) { scheduledJob.job.cancel(); From 61705f9c3f74f71399ab6e7ffc89f5b2074fa575 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:26:41 +0300 Subject: [PATCH 33/48] feat: report managed database schema in readiness --- server/index.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/server/index.ts b/server/index.ts index b3f04f78fd..84f7556f6f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -6,7 +6,10 @@ import { Session } from '@server/entity/Session'; import { User } from '@server/entity/User'; import { initI18n } from '@server/i18n'; import { startDesktopCatchUp, startJobs, stopJobs } from '@server/job/schedule'; -import { assertSupportedDatabaseSchema } from '@server/lib/db/schemaGuard'; +import { + assertSupportedDatabaseSchema, + isMissingMigrationsTableError, +} from '@server/lib/db/schemaGuard'; import { DESKTOP_SCHEMA_EXIT_CODE, acquireDesktopLock, @@ -158,6 +161,24 @@ const installDesktopControlHandlers = (): void => { process.once('SIGINT', () => requestManagedShutdown()); }; +/** The latest applied TypeORM migration is the durable database schema ID. */ +const getDatabaseSchemaVersion = async (): Promise => { + let rows: { schemaVersion?: number | string | null }[]; + try { + rows = (await dataSource.query( + 'SELECT MAX(timestamp) AS "schemaVersion" FROM migrations' + )) as { schemaVersion?: number | string | null }[]; + } catch (error) { + // Development can intentionally run before TypeORM creates migrations. + if (isMissingMigrationsTableError(error)) return 0; + throw error; + } + const schemaVersion = Number(rows[0]?.schemaVersion ?? 0); + return Number.isSafeInteger(schemaVersion) && schemaVersion >= 0 + ? schemaVersion + : 0; +}; + if (!appDataPermissions()) { logger.error( 'Something went wrong while checking config folder! Please ensure the config folder is set up properly.\nhttps://selmant.github.io/foreseerr/getting-started/' @@ -218,6 +239,7 @@ const startForeseerrInternal = async ( } throw error; } + const schemaVersion = await getDatabaseSchemaVersion(); // Load Settings const settings = await getSettings().load(); @@ -501,7 +523,7 @@ const startForeseerrInternal = async ( } desktopOrigin = `http://127.0.0.1:${address.port}`; process.stdout.write( - `FORESEERR_DESKTOP_READY ${JSON.stringify({ protocolVersion: 1, pid: process.pid, origin: desktopOrigin, foreseerrVersion: getAppVersion(), commit: process.env.FORESEERR_COMMIT ?? 'unknown', schemaVersion: 0 })}\n` + `FORESEERR_DESKTOP_READY ${JSON.stringify({ protocolVersion: 1, pid: process.pid, origin: desktopOrigin, foreseerrVersion: getAppVersion(), commit: process.env.FORESEERR_COMMIT ?? 'unknown', schemaVersion })}\n` ); }); } From f90c4e67046bc41a99b1bf774cb0393ea537c786 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:31:24 +0300 Subject: [PATCH 34/48] fix: admit native browser cache ticket redemption --- server/index.ts | 5 ++++- server/routes/desktop.test.ts | 6 ++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/server/index.ts b/server/index.ts index 84f7556f6f..8fe6c92490 100644 --- a/server/index.ts +++ b/server/index.ts @@ -328,7 +328,10 @@ const startForeseerrInternal = async ( const unsafeMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes( req.method ); - const nativeTicket = req.path === '/api/v1/desktop/auth-tickets/redeem'; + const nativeTicket = [ + '/api/v1/desktop/auth-tickets/redeem', + '/api/v1/desktop/browser-cache/redeem', + ].includes(req.path); if ( (stopping && req.path !== '/api/v1/status') || !desktopOrigin || diff --git a/server/routes/desktop.test.ts b/server/routes/desktop.test.ts index b5ce636729..3e8a38dce1 100644 --- a/server/routes/desktop.test.ts +++ b/server/routes/desktop.test.ts @@ -327,9 +327,7 @@ describe('desktop auth tickets', () => { it('exempts cookie-less ticket redeem from CSRF', () => { const serverSource = readFileSync(join(__dirname, '../index.ts'), 'utf8'); assert.match(serverSource, /ignoreRequest:/); - assert.match( - serverSource, - /req\.path === '\/api\/v1\/desktop\/auth-tickets\/redeem'/ - ); + assert.match(serverSource, /'\/api\/v1\/desktop\/auth-tickets\/redeem'/); + assert.match(serverSource, /'\/api\/v1\/desktop\/browser-cache\/redeem'/); }); }); From ad77a962b699e359cd1be4843308305106fc2b95 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:32:36 +0300 Subject: [PATCH 35/48] fix: defer desktop catch-up until CEF is ready --- server/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/index.ts b/server/index.ts index 8fe6c92490..5b3c75a234 100644 --- a/server/index.ts +++ b/server/index.ts @@ -301,7 +301,8 @@ const startForeseerrInternal = async ( const totalUsers = await userRepository.count(); if (totalUsers > 0) { startJobs(); - startDesktopCatchUp(); + // The desktop host sends its first runtime-state message only after CEF + // is ready. That event starts the 30-second managed catch-up delay. } else { logger.info( `Skipping starting the scheduled jobs as we have no Plex/Jellyfin/Emby servers setup yet`, From f66793f2790aca5840f5e6907999201bc7c78777 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:34:49 +0300 Subject: [PATCH 36/48] feat: add native remote recovery setup action --- protocol/protocol-v1.json | 7 ++++++- src/context/nativeRuntimeProtocol.ts | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index bf6a0c1ca5..caf0b839ba 100644 --- a/protocol/protocol-v1.json +++ b/protocol/protocol-v1.json @@ -65,6 +65,10 @@ "type": "runtime.open-logs", "fields": ["id"] }, + { + "type": "runtime.open-setup", + "fields": ["id"] + }, { "type": "window.minimize", "fields": ["id"] @@ -98,7 +102,8 @@ "browser-cache-cleared", "runtime-failed", "runtime-recovered", - "logs-opened" + "logs-opened", + "setup-opened" ], "terminalPlayEventTypes": ["stopped", "finished", "canceled", "error"], "envelopes": { diff --git a/src/context/nativeRuntimeProtocol.ts b/src/context/nativeRuntimeProtocol.ts index b2ff81160d..ff16046e5b 100644 --- a/src/context/nativeRuntimeProtocol.ts +++ b/src/context/nativeRuntimeProtocol.ts @@ -54,6 +54,7 @@ export const nativeHostEventTypesV1 = [ 'runtime-failed', 'runtime-recovered', 'logs-opened', + 'setup-opened', ] as const; export type NativeHostEventTypeV1 = (typeof nativeHostEventTypesV1)[number]; From da6ad0a8eb4e1fc63ac9ac2eb7ca2e2989bfd135 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:35:43 +0300 Subject: [PATCH 37/48] fix: reset managed server after graceful stop --- server/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/index.ts b/server/index.ts index 5b3c75a234..4b334c6b19 100644 --- a/server/index.ts +++ b/server/index.ts @@ -104,6 +104,8 @@ const stopManagedRuntime = async (deadlineMs = 10_000): Promise => { }); }); } + managedServer = undefined; + desktopOrigin = ''; if (dataSource.isInitialized) { if (!isPgsql) { await dataSource From 78a13fb68968e7de6e6ac050ff328ca77728efbb Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:36:59 +0300 Subject: [PATCH 38/48] fix: start desktop catch-up after initial user setup --- server/routes/auth.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 89ac1d4e1b..ada00461db 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -5,7 +5,7 @@ import { MediaServerType, ServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; -import { startJobs } from '@server/job/schedule'; +import { startDesktopCatchUp, startJobs } from '@server/job/schedule'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -101,9 +101,9 @@ authRoutes.post('/plex', async (req, res, next) => { settings.main.mediaServerType = MediaServerType.PLEX; await settings.save(); - startJobs(); - await userRepository.save(user); + startJobs(); + startDesktopCatchUp(); } else { const mainUser = await userRepository.findOneOrFail({ select: { id: true, plexToken: true, plexId: true, email: true }, @@ -419,6 +419,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { settings.jellyfin.apiKey = apiKey; await settings.save(); startJobs(); + startDesktopCatchUp(); } // User already exists, let's update their information else if (account.User.Id === user?.jellyfinUserId) { From 7816e514d0ce3c1f8449a5c088ccde060add4267 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:39:25 +0300 Subject: [PATCH 39/48] fix: log managed server bound port --- server/index.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/server/index.ts b/server/index.ts index 4b334c6b19..bcb49dcf3c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -493,18 +493,18 @@ const startForeseerrInternal = async ( throw new Error('Desktop runtime must bind exact IPv4 loopback'); } let httpServer: HttpServer; - if (host) { - httpServer = server.listen(port, host, () => { - logger.info(`Server ready on ${host} port ${port}`, { - label: 'Server', - }); + const logBoundAddress = () => { + const address = httpServer.address(); + const boundPort = + typeof address === 'object' && address ? address.port : port; + logger.info(`Server ready on ${host ?? '127.0.0.1'} port ${boundPort}`, { + label: 'Server', }); + }; + if (host) { + httpServer = server.listen(port, host, logBoundAddress); } else { - httpServer = server.listen(port, () => { - logger.info(`Server ready on port ${port}`, { - label: 'Server', - }); - }); + httpServer = server.listen(port, logBoundAddress); } httpServer.on('error', (err) => { logger.error('Failed to start server', { From 1aac18ec49ff31354750f4c13ce87a3b630d7c5f Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:57:12 +0300 Subject: [PATCH 40/48] fix: keep standalone application URL in memory --- server/index.ts | 5 +++ server/lib/desktopState.ts | 10 ++++++ .../settings/desktopApplicationUrl.test.ts | 36 +++++++++++++++++++ server/lib/settings/index.ts | 29 +++++++++++++-- 4 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 server/lib/settings/desktopApplicationUrl.test.ts diff --git a/server/index.ts b/server/index.ts index bcb49dcf3c..624932c5b3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -16,6 +16,7 @@ import { desktopError, } from '@server/lib/desktopRuntime'; import { + setDesktopApplicationUrl, setDesktopPlaybackActive, setDesktopRuntime, setDesktopStopping, @@ -106,6 +107,7 @@ const stopManagedRuntime = async (deadlineMs = 10_000): Promise => { } managedServer = undefined; desktopOrigin = ''; + setDesktopApplicationUrl(''); if (dataSource.isInitialized) { if (!isPgsql) { await dataSource @@ -201,6 +203,7 @@ const startForeseerrInternal = async ( if (desktopRuntime) installDesktopControlHandlers(); stopping = false; desktopOrigin = ''; + setDesktopApplicationUrl(''); setDesktopStopping(false); await app.prepare(); if (desktopRuntime) { @@ -528,6 +531,7 @@ const startForeseerrInternal = async ( return; } desktopOrigin = `http://127.0.0.1:${address.port}`; + setDesktopApplicationUrl(desktopOrigin); process.stdout.write( `FORESEERR_DESKTOP_READY ${JSON.stringify({ protocolVersion: 1, pid: process.pid, origin: desktopOrigin, foreseerrVersion: getAppVersion(), commit: process.env.FORESEERR_COMMIT ?? 'unknown', schemaVersion })}\n` ); @@ -567,6 +571,7 @@ const cleanupFailedDesktopStart = async (): Promise => { await releaseDesktopLock?.(); releaseDesktopLock = undefined; desktopOrigin = ''; + setDesktopApplicationUrl(''); stopping = false; setDesktopStopping(false); }; diff --git a/server/lib/desktopState.ts b/server/lib/desktopState.ts index 2ebb692f16..1b53cd8bfb 100644 --- a/server/lib/desktopState.ts +++ b/server/lib/desktopState.ts @@ -1,6 +1,7 @@ let playbackActive = false; let stopping = false; let managedDesktopRuntime = process.env.FORESEERR_RUNTIME === 'desktop'; +let desktopApplicationUrl = ''; export const setDesktopPlaybackActive = (active: boolean): void => { playbackActive = active; @@ -17,6 +18,15 @@ export const isDesktopStopping = (): boolean => stopping; /** Runtime mode is chosen by the managed start API, not just process env. */ export const setDesktopRuntime = (value: boolean): void => { managedDesktopRuntime = value; + if (!value) desktopApplicationUrl = ''; }; export const isDesktopRuntime = (): boolean => managedDesktopRuntime; + +/** Keep the volatile managed loopback origin out of durable settings.json. */ +export const setDesktopApplicationUrl = (origin: string): void => { + desktopApplicationUrl = managedDesktopRuntime ? origin : ''; +}; + +export const effectiveApplicationUrl = (persistedUrl: string): string => + desktopApplicationUrl || persistedUrl; diff --git a/server/lib/settings/desktopApplicationUrl.test.ts b/server/lib/settings/desktopApplicationUrl.test.ts new file mode 100644 index 0000000000..943fa92bc1 --- /dev/null +++ b/server/lib/settings/desktopApplicationUrl.test.ts @@ -0,0 +1,36 @@ +import { + setDesktopApplicationUrl, + setDesktopRuntime, +} from '@server/lib/desktopState'; +import Settings from '@server/lib/settings'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +describe('desktop application URL', () => { + it('uses the managed loopback origin without persisting its random port', () => { + const settings = new Settings(); + settings.main.applicationUrl = 'https://persisted.example.test'; + + try { + setDesktopRuntime(true); + setDesktopApplicationUrl('http://127.0.0.1:43127'); + + assert.equal(settings.main.applicationUrl, 'http://127.0.0.1:43127'); + assert.equal( + settings.fullPublicSettings.applicationUrl, + 'http://127.0.0.1:43127' + ); + + // Settings updates commonly spread the currently visible main object. + // That must not turn the effective URL into durable configuration. + settings.main = { ...settings.main, applicationTitle: 'Standalone' }; + } finally { + setDesktopRuntime(false); + } + + assert.equal( + settings.main.applicationUrl, + 'https://persisted.example.test' + ); + }); +}); diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index ff1a0232c9..8a8dab1e57 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -1,6 +1,10 @@ import type { RatingBadgeSettings } from '@server/constants/ratingBadges'; import { DEFAULT_RATING_BADGE_SETTINGS } from '@server/constants/ratingBadges'; import { MediaServerType } from '@server/constants/server'; +import { + effectiveApplicationUrl, + isDesktopRuntime, +} from '@server/lib/desktopState'; import { Permission } from '@server/lib/permissions'; import { runMigrations } from '@server/lib/settings/migrator'; import type { AvailableLocale } from '@server/types/languages'; @@ -711,10 +715,31 @@ class Settings { } get main(): MainSettings { - return this.data.main; + const applicationUrl = effectiveApplicationUrl( + this.data.main.applicationUrl + ); + if (applicationUrl === this.data.main.applicationUrl) { + return this.data.main; + } + // Preserve normal mutable settings semantics while exposing the volatile + // desktop origin only to runtime readers. `this.data` remains the durable + // source used by save(), so a random loopback port cannot leak to disk. + return new Proxy(this.data.main, { + get: (target, property, receiver) => + property === 'applicationUrl' + ? applicationUrl + : Reflect.get(target, property, receiver), + }); } set main(data: MainSettings) { + if ( + isDesktopRuntime() && + data.applicationUrl === + effectiveApplicationUrl(this.data.main.applicationUrl) + ) { + data = { ...data, applicationUrl: this.data.main.applicationUrl }; + } this.data.main = mergeSettings(this.data.main, data); } @@ -869,7 +894,7 @@ class Settings { return { ...this.data.public, applicationTitle: this.data.main.applicationTitle, - applicationUrl: this.data.main.applicationUrl, + applicationUrl: this.main.applicationUrl, hideAvailable: this.data.main.hideAvailable, hideBlocklisted: this.data.main.hideBlocklisted, localLogin: this.data.main.localLogin, From 6f49d2b19c49fa7a648708880270563e95ada156 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:58:37 +0300 Subject: [PATCH 41/48] fix: resume heavy jobs deferred by desktop playback --- server/job/schedule.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 4e32189df3..ef876c3eee 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -56,14 +56,22 @@ const heavyJobIds = new Set([ ]); let desktopCatchUpTimer: NodeJS.Timeout | undefined; let desktopDownloadStartupRun = false; +const deferredHeavyJobs = new Map< + JobId, + { name: string; run: () => Promise } +>(); const runScheduledJob = ( - id: string, + id: JobId, weight: ManagedJobWeight, name: string, run: () => Promise ): void => { if (weight === 'heavy' && isDesktopPlaybackActive()) { + // Cron may fire repeatedly while a film is playing. Coalesce by job ID; + // a single run is retained for the first 30-second idle window after + // playback stops rather than replaying every missed occurrence. + deferredHeavyJobs.set(id, { name, run }); logger.info( `Deferring heavy job while desktop playback is active: ${name}`, { @@ -80,9 +88,18 @@ const runScheduledJob = ( }); }; -const runHeavy = (id: string, name: string, run: () => Promise) => +const runHeavy = (id: JobId, name: string, run: () => Promise) => runScheduledJob(id, 'heavy', name, run); +const runDeferredHeavyJobs = (): void => { + if (isDesktopPlaybackActive()) return; + const jobs = [...deferredHeavyJobs]; + deferredHeavyJobs.clear(); + for (const [id, { name, run }] of jobs) { + runHeavy(id, name, run); + } +}; + export const startJobs = (): void => { if (scheduledJobs.length > 0) { return; @@ -412,6 +429,10 @@ export const startDesktopCatchUp = (): void => { downloadTracker.updateDownloads() ); } + // A playback session may have deferred scheduled heavy work while the + // timer was pending. Draining here guarantees a full 30 seconds of + // desktop idleness after the final playback-active=false signal. + runDeferredHeavyJobs(); for (const job of scheduledJobs) { // Download tracking and image cleanup have dedicated startup // maintenance semantics rather than missed-cron replay. @@ -448,6 +469,7 @@ export const stopJobs = (): void => { desktopCatchUpTimer = undefined; } desktopDownloadStartupRun = false; + deferredHeavyJobs.clear(); cancelManagedJobs(); for (const scheduledJob of scheduledJobs) { scheduledJob.job.cancel(); From 0f3e73b2fb683d0348b15815e54dee6bf46d76fc Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:59:53 +0300 Subject: [PATCH 42/48] fix: manage manual library scans through scheduler --- server/job/execution.ts | 5 +++++ server/routes/settings/index.ts | 11 +++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/server/job/execution.ts b/server/job/execution.ts index c9112b0855..b32c7ab099 100644 --- a/server/job/execution.ts +++ b/server/job/execution.ts @@ -24,6 +24,11 @@ export const cancelManagedJobs = (): void => { } }; +/** Cancel one manual or scheduled invocation without disturbing other jobs. */ +export const cancelManagedJob = (id: string): void => { + activeControllers.get(id)?.abort(); +}; + /** Run scheduled and manual work through one non-overlapping, persisted path. */ export const executeManagedJob = async ( id: string, diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 680f8ab417..c4fa587249 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -15,6 +15,7 @@ import type { LogsResultsResponse, SettingsAboutResponse, } from '@server/interfaces/api/settingsInterfaces'; +import { cancelManagedJob, executeManagedJob } from '@server/job/execution'; import { scheduledJobs } from '@server/job/schedule'; import { countLinkedAnilistAccounts, @@ -279,9 +280,12 @@ settingsRoutes.get('/plex/sync', (_req, res) => { settingsRoutes.post('/plex/sync', (req, res) => { if (req.body.cancel) { + cancelManagedJob('plex-full-scan'); plexFullScanner.cancel(); } else if (req.body.start) { - plexFullScanner.run(); + void executeManagedJob('plex-full-scan', 'heavy', () => + plexFullScanner.run() + ); } return res.status(200).json(plexFullScanner.status()); }); @@ -446,9 +450,12 @@ settingsRoutes.get('/jellyfin/sync', (_req, res) => { settingsRoutes.post('/jellyfin/sync', (req, res) => { if (req.body.cancel) { + cancelManagedJob('jellyfin-full-scan'); jellyfinFullScanner.cancel(); } else if (req.body.start) { - jellyfinFullScanner.run(); + void executeManagedJob('jellyfin-full-scan', 'heavy', () => + jellyfinFullScanner.run() + ); } return res.status(200).json(jellyfinFullScanner.status()); }); From c1a20cf036040bae0e0969eaa27c98590b3a5b6e Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 20:16:47 +0300 Subject: [PATCH 43/48] fix: drain playback-deferred heavy jobs serially --- server/job/schedule.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/server/job/schedule.ts b/server/job/schedule.ts index ef876c3eee..fb798f65b2 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -91,12 +91,18 @@ const runScheduledJob = ( const runHeavy = (id: JobId, name: string, run: () => Promise) => runScheduledJob(id, 'heavy', name, run); -const runDeferredHeavyJobs = (): void => { +const runDeferredHeavyJobs = async (): Promise => { if (isDesktopPlaybackActive()) return; const jobs = [...deferredHeavyJobs]; deferredHeavyJobs.clear(); for (const [id, { name, run }] of jobs) { - runHeavy(id, name, run); + logger.info(`Resuming heavy job deferred by desktop playback: ${name}`, { + label: 'Jobs', + }); + // Unlike ordinary cron callbacks, this drain intentionally waits for one + // heavy job before beginning the next. Calling them all at once would + // cause the shared executor to reject every job after the first. + await executeManagedJob(id, 'heavy', () => run()); } }; @@ -432,7 +438,7 @@ export const startDesktopCatchUp = (): void => { // A playback session may have deferred scheduled heavy work while the // timer was pending. Draining here guarantees a full 30 seconds of // desktop idleness after the final playback-active=false signal. - runDeferredHeavyJobs(); + await runDeferredHeavyJobs(); for (const job of scheduledJobs) { // Download tracking and image cleanup have dedicated startup // maintenance semantics rather than missed-cron replay. From 8be91711640d7b5e6fdd24d210fd3027023af501 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 20:18:12 +0300 Subject: [PATCH 44/48] fix: keep direct settings mutations from persisting loopback URL --- server/lib/settings/desktopApplicationUrl.test.ts | 2 ++ server/lib/settings/index.ts | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/server/lib/settings/desktopApplicationUrl.test.ts b/server/lib/settings/desktopApplicationUrl.test.ts index 943fa92bc1..b3b4faee3f 100644 --- a/server/lib/settings/desktopApplicationUrl.test.ts +++ b/server/lib/settings/desktopApplicationUrl.test.ts @@ -21,6 +21,8 @@ describe('desktop application URL', () => { 'http://127.0.0.1:43127' ); + settings.main.applicationUrl = 'http://127.0.0.1:43127'; + // Settings updates commonly spread the currently visible main object. // That must not turn the effective URL into durable configuration. settings.main = { ...settings.main, applicationTitle: 'Standalone' }; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 8a8dab1e57..19222b9b89 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -729,6 +729,15 @@ class Settings { property === 'applicationUrl' ? applicationUrl : Reflect.get(target, property, receiver), + set: (target, property, value, receiver) => { + // Settings route handlers may mutate `settings.main` directly. Keep + // the visible ephemeral origin from becoming durable configuration + // through that path as well as through the `main` setter below. + if (property === 'applicationUrl' && value === applicationUrl) { + return true; + } + return Reflect.set(target, property, value, receiver); + }, }); } From 331833d9e3b0686def85ebb81ba667c45b87ec55 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 20:19:09 +0300 Subject: [PATCH 45/48] fix: normalize standalone application URL persistence guard --- .../lib/settings/desktopApplicationUrl.test.ts | 1 + server/lib/settings/index.ts | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/server/lib/settings/desktopApplicationUrl.test.ts b/server/lib/settings/desktopApplicationUrl.test.ts index b3b4faee3f..02fa97c599 100644 --- a/server/lib/settings/desktopApplicationUrl.test.ts +++ b/server/lib/settings/desktopApplicationUrl.test.ts @@ -22,6 +22,7 @@ describe('desktop application URL', () => { ); settings.main.applicationUrl = 'http://127.0.0.1:43127'; + settings.main.applicationUrl = 'http://127.0.0.1:43127/'; // Settings updates commonly spread the currently visible main object. // That must not turn the effective URL into durable configuration. diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 19222b9b89..292f9d7f8d 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -23,6 +23,15 @@ const mergeSettings = (current: T, incoming: Partial): T => Array.isArray(srcValue) ? srcValue : undefined ) as T; +const isManagedApplicationUrl = (value: unknown, origin: string): boolean => { + if (typeof value !== 'string') return false; + try { + return new URL(value).origin === origin; + } catch { + return false; + } +}; + export interface Library { id: string; name: string; @@ -733,7 +742,10 @@ class Settings { // Settings route handlers may mutate `settings.main` directly. Keep // the visible ephemeral origin from becoming durable configuration // through that path as well as through the `main` setter below. - if (property === 'applicationUrl' && value === applicationUrl) { + if ( + property === 'applicationUrl' && + isManagedApplicationUrl(value, applicationUrl) + ) { return true; } return Reflect.set(target, property, value, receiver); @@ -744,8 +756,10 @@ class Settings { set main(data: MainSettings) { if ( isDesktopRuntime() && - data.applicationUrl === + isManagedApplicationUrl( + data.applicationUrl, effectiveApplicationUrl(this.data.main.applicationUrl) + ) ) { data = { ...data, applicationUrl: this.data.main.applicationUrl }; } From b820e5bf0787a2d1c907a6da6956fc515425e90e Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 20:23:39 +0300 Subject: [PATCH 46/48] feat: expose desktop mode preferences from settings --- .../Settings/SettingsMain/index.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 6721f615bd..3f60efdd1e 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -76,6 +76,10 @@ const messages = defineMessages('components.Settings.SettingsMain', { 'Base URL for YouTube videos if a self-hosted YouTube instance is used.', versionCheck: 'Version Check', versionCheckTip: 'Automatically check for new versions on GitHub.', + desktopMode: 'Desktop Mode', + desktopModeTip: + 'Switch between the bundled standalone server and a remote Foreseerr connection. Foreseer will restart to apply the change.', + changeDesktopMode: 'Change Desktop Mode', validationUrl: 'You must provide a valid URL', validationUrlTrailingSlash: 'URL must not end in a trailing slash', }); @@ -146,6 +150,15 @@ const SettingsMain = () => { } }; + const canChangeDesktopMode = + typeof window !== 'undefined' && + window.foreseerNative?.capabilities.includes('mode-setup'); + const openDesktopModePreferences = () => { + const host = window.foreseerNative; + if (!host?.capabilities.includes('mode-setup')) return; + host.send({ type: 'runtime.open-setup', id: crypto.randomUUID() }); + }; + if (!data && !error) { return ; } @@ -632,6 +645,28 @@ const SettingsMain = () => { />
+ {userHasPermission(Permission.ADMIN) && + canChangeDesktopMode && ( +
+ +
+ +
+
+ )}
From 85ef7541895d479cf2b3beb4d8bcb29444238cbb Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 23:01:05 +0300 Subject: [PATCH 47/48] fix: persist desktop login and keep play on the LAN Jellyfin session Remember the signed-in user across loopback port changes, bind ticket redeem to the live session store, and stop TypeORM cycles from freezing the login page. Co-authored-by: Cursor --- launcher.js | 15 +++ server/entity/User.ts | 38 +++++++- server/index.ts | 54 +++++++++-- server/lib/cacheStore.ts | 6 +- server/lib/desktopLogin.test.ts | 125 +++++++++++++++++++++++++ server/lib/desktopLogin.ts | 107 +++++++++++++++++++++ server/lib/jsonSafe.test.ts | 65 +++++++++++++ server/lib/jsonSafe.ts | 69 ++++++++++++++ server/lib/library.ts | 7 +- server/middleware/auth.ts | 44 +++++---- server/routes/auth.ts | 103 +++++++++++++++----- server/routes/desktop.test.ts | 83 ++++++++++++++++ server/routes/desktop.ts | 104 +++++++++++++++----- src/components/Login/JellyfinLogin.tsx | 3 +- 14 files changed, 745 insertions(+), 78 deletions(-) create mode 100644 server/lib/desktopLogin.test.ts create mode 100644 server/lib/desktopLogin.ts create mode 100644 server/lib/jsonSafe.test.ts create mode 100644 server/lib/jsonSafe.ts diff --git a/launcher.js b/launcher.js index 43cb7640dc..aa044e74a2 100644 --- a/launcher.js +++ b/launcher.js @@ -1,2 +1,17 @@ #!/usr/bin/env node +function ignoreBrokenStdio(stream) { + if (!stream || typeof stream.on !== 'function') { + return; + } + stream.on('error', (error) => { + if ( + error && + (error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED') + ) { + return; + } + }); +} +ignoreBrokenStdio(process.stdout); +ignoreBrokenStdio(process.stderr); import('./dist/launcher.js'); diff --git a/server/entity/User.ts b/server/entity/User.ts index ddc2c49bfd..4f529128be 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -135,7 +135,7 @@ export class User { @OneToOne(() => UserSettings, (settings) => settings.user, { cascade: true, - eager: true, + eager: false, onDelete: 'CASCADE', }) public settings?: UserSettings; @@ -172,6 +172,42 @@ export class User { return filtered; } + /** + * Allowlisted client payload. Never spread a TypeORM entity into res.json: + * eager `settings.user` and relation getters can expand until the event + * loop is stuck (desktop "Signing In…" freeze). + */ + public toPublicJSON(): Record { + const settings = this.settings; + return { + id: this.id, + email: this.email, + username: this.username, + plexUsername: this.plexUsername, + jellyfinUsername: this.jellyfinUsername, + displayName: this.displayName, + avatar: this.avatar, + permissions: this.permissions, + userType: this.userType, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + requestCount: this.requestCount, + warnings: this.warnings ?? [], + plexId: this.plexId, + settings: settings + ? { + locale: settings.locale, + discoverRegion: settings.discoverRegion, + streamingRegion: settings.streamingRegion, + originalLanguage: settings.originalLanguage, + notificationTypes: settings.notificationTypes, + watchlistSyncMovies: settings.watchlistSyncMovies, + watchlistSyncTv: settings.watchlistSyncTv, + } + : undefined, + }; + } + public hasPermission( permissions: Permission | Permission[], options?: PermissionCheckOptions diff --git a/server/index.ts b/server/index.ts index 624932c5b3..4fcca074f7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,7 @@ import { assertSupportedDatabaseSchema, isMissingMigrationsTableError, } from '@server/lib/db/schemaGuard'; +import { restoreDesktopSession } from '@server/lib/desktopLogin'; import { DESKTOP_SCHEMA_EXIT_CODE, acquireDesktopLock, @@ -22,6 +23,7 @@ import { setDesktopStopping, } from '@server/lib/desktopState'; import ImageProxy from '@server/lib/imageproxy'; +import { jsonSafeClone } from '@server/lib/jsonSafe'; import notificationManager from '@server/lib/notifications'; import DiscordAgent from '@server/lib/notifications/agents/discord'; import EmailAgent from '@server/lib/notifications/agents/email'; @@ -39,6 +41,7 @@ import logger from '@server/logger'; import clearCookies from '@server/middleware/clearcookies'; import routes from '@server/routes'; import avatarproxy from '@server/routes/avatarproxy'; +import { bindDesktopSessionStore } from '@server/routes/desktop'; import imageproxy from '@server/routes/imageproxy'; import { appDataPermissions } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; @@ -304,7 +307,11 @@ const startForeseerrInternal = async ( const userRepository = getRepository(User); const totalUsers = await userRepository.count(); - if (totalUsers > 0) { + // A standalone desktop starts before anyone has authenticated. Defer its + // external sync work until an authenticated desktop session explicitly + // starts it; otherwise copied Arr/Jellyfin jobs can monopolize the local + // server while the login page is still loading. + if (totalUsers > 0 && !desktopRuntime) { startJobs(); // The desktop host sends its first runtime-state message only after CEF // is ready. That event starts the 30-second managed catch-up delay. @@ -409,6 +416,15 @@ const startForeseerrInternal = async ( // Set up sessions const sessionRespository = getRepository(Session); + const sessionStore = new TypeormStore({ + cleanupLimit: 2, + ttl: 60 * 60 * 24 * 30, + // SQLite cannot use LIMIT inside the expired-session subquery. + limitSubquery: isPgsql, + }).connect(sessionRespository) as Store; + if (desktopRuntime) { + bindDesktopSessionStore(sessionStore); + } server.use( '/api', session({ @@ -418,16 +434,29 @@ const startForeseerrInternal = async ( cookie: { maxAge: 1000 * 60 * 60 * 24 * 30, httpOnly: true, + path: '/', sameSite: desktopRuntime || settings.network.csrfProtection ? 'strict' : 'lax', secure: desktopRuntime ? false : 'auto', }, - store: new TypeormStore({ - cleanupLimit: 2, - ttl: 60 * 60 * 24 * 30, - }).connect(sessionRespository) as Store, + store: sessionStore, }) ); + if (desktopRuntime) { + server.use('/api', restoreDesktopSession); + logger.info('Desktop session store is sqlite', { label: 'Server' }); + server.use('/api', (req, res, next) => { + const started = Date.now(); + logger.info(`API ${req.method} ${req.path}`, { label: 'Desktop' }); + res.on('finish', () => { + logger.info( + `API ${req.method} ${req.path} ${res.statusCode} ${Date.now() - started}ms`, + { label: 'Desktop' } + ); + }); + next(); + }); + } const apiSpecContent = await fs.readFile(API_SPEC_PATH, 'utf-8'); const apiDocs = yaml.load(apiSpecContent) as Record; server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs)); @@ -438,14 +467,14 @@ const startForeseerrInternal = async ( }) ); /** - * This is a workaround to convert dates to strings before they are validated by - * OpenAPI validator. Otherwise, they are treated as objects instead of strings - * and response validation will fail + * Convert dates and drop cycles before JSON serialization. Only wrap API + * responses — applying this to Next.js `res.json` walks page/runtime graphs + * and can pin the event loop so `/login` never reaches the browser. */ - server.use((_req, res, next) => { + server.use('/api', (_req, res, next) => { const original = res.json; res.json = function jsonp(json) { - return original.call(this, JSON.parse(JSON.stringify(json))); + return original.call(this, jsonSafeClone(json)); }; next(); }); @@ -530,6 +559,11 @@ const startForeseerrInternal = async ( requestManagedShutdown(); return; } + // When the desktop asks the OS to choose a port, PORT is initially + // "0". Next's server-rendered pages use PORT for their same-process + // API calls, so replace that sentinel with the actual loopback port + // before emitting readiness. + process.env.PORT = String(address.port); desktopOrigin = `http://127.0.0.1:${address.port}`; setDesktopApplicationUrl(desktopOrigin); process.stdout.write( diff --git a/server/lib/cacheStore.ts b/server/lib/cacheStore.ts index 7ff8b3789a..78a55f5d41 100644 --- a/server/lib/cacheStore.ts +++ b/server/lib/cacheStore.ts @@ -45,7 +45,10 @@ export class CacheBudget { return this.accessSequence; } ensureCapacity(): void { - while (this.usedBytes > this.limitBytes) { + let remaining = 10_000; + while (this.usedBytes > this.limitBytes && remaining > 0) { + remaining -= 1; + const before = this.usedBytes; // Prune expiry across every provider before selecting an LRU victim. // An expired item must never cause a live response to be discarded. for (const store of this.stores) store.oldest(); @@ -55,6 +58,7 @@ export class CacheBudget { .sort((a, b) => a.accessedAt - b.accessedAt)[0]; if (!candidate) return; candidate.store.evict(candidate.key); + if (this.usedBytes >= before) return; } } stats(): CacheStats { diff --git a/server/lib/desktopLogin.test.ts b/server/lib/desktopLogin.test.ts new file mode 100644 index 0000000000..e3485b0a1d --- /dev/null +++ b/server/lib/desktopLogin.test.ts @@ -0,0 +1,125 @@ +import { + forgetDesktopUser, + recalledDesktopUserId, + rememberDesktopUser, + resetDesktopLoginCacheForTests, + restoreDesktopSession, +} from '@server/lib/desktopLogin'; +import { setDesktopRuntime } from '@server/lib/desktopState'; +import { setupTestDb } from '@server/test/db'; +import express from 'express'; +import session from 'express-session'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; +import request from 'supertest'; + +setupTestDb(); + +const withTempConfig = (): string => { + const directory = mkdtempSync(join(tmpdir(), 'foreseer-desktop-login-')); + process.env.CONFIG_DIRECTORY = directory; + resetDesktopLoginCacheForTests(); + return directory; +}; + +afterEach(() => { + setDesktopRuntime(false); + resetDesktopLoginCacheForTests(); + delete process.env.CONFIG_DIRECTORY; +}); + +describe('desktop login persistence', () => { + it('does not remember users outside the desktop runtime', () => { + const directory = withTempConfig(); + rememberDesktopUser(1); + assert.equal(recalledDesktopUserId(), undefined); + assert.throws(() => + readFileSync(join(directory, 'state', 'desktop-login.json')) + ); + }); + + it('round-trips the remembered user across a cache reset', () => { + const directory = withTempConfig(); + setDesktopRuntime(true); + rememberDesktopUser(1); + assert.equal( + JSON.parse( + readFileSync(join(directory, 'state', 'desktop-login.json'), 'utf8') + ).userId, + 1 + ); + + resetDesktopLoginCacheForTests(); + assert.equal(recalledDesktopUserId(), 1); + }); + + it('forgets the remembered user', () => { + const directory = withTempConfig(); + setDesktopRuntime(true); + rememberDesktopUser(1); + forgetDesktopUser(); + resetDesktopLoginCacheForTests(); + assert.equal(recalledDesktopUserId(), undefined); + assert.throws(() => + readFileSync(join(directory, 'state', 'desktop-login.json')) + ); + }); + + it('restores a session without a cookie on a fresh MemoryStore', async () => { + withTempConfig(); + setDesktopRuntime(true); + rememberDesktopUser(1); + + const app = express(); + app.use( + session({ + secret: 'test-secret', + resave: false, + saveUninitialized: false, + store: new session.MemoryStore(), + }) + ); + app.use(restoreDesktopSession); + app.get('/who', (req, res) => { + res.json({ userId: req.session.userId ?? null }); + }); + + const restored = await request(app).get('/who'); + assert.equal(restored.status, 200); + assert.equal(restored.body.userId, 1); + const cookies = restored.headers['set-cookie']; + assert.match( + (Array.isArray(cookies) ? cookies.join(';') : cookies) ?? '', + /connect\.sid/ + ); + }); + + it('does not restore a missing user and clears the file', async () => { + withTempConfig(); + setDesktopRuntime(true); + rememberDesktopUser(99999); + + const app = express(); + app.use( + session({ + secret: 'test-secret', + resave: false, + saveUninitialized: false, + store: new session.MemoryStore(), + }) + ); + app.use(restoreDesktopSession); + app.get('/who', (req, res) => { + res.json({ userId: req.session.userId ?? null }); + }); + + const restored = await request(app).get('/who'); + assert.equal(restored.status, 200); + assert.equal(restored.body.userId, null); + resetDesktopLoginCacheForTests(); + assert.equal(recalledDesktopUserId(), undefined); + }); +}); diff --git a/server/lib/desktopLogin.ts b/server/lib/desktopLogin.ts new file mode 100644 index 0000000000..ba6a247adb --- /dev/null +++ b/server/lib/desktopLogin.ts @@ -0,0 +1,107 @@ +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { isDesktopRuntime } from '@server/lib/desktopState'; +import fs from 'fs'; +import path from 'path'; + +const LOGIN_STATE = ['state', 'desktop-login.json'] as const; + +let cachedUserId: number | null | undefined; + +const loginStatePath = (): string | undefined => { + const configDirectory = process.env.CONFIG_DIRECTORY; + if (!configDirectory) { + return undefined; + } + return path.join(configDirectory, ...LOGIN_STATE); +}; + +export const resetDesktopLoginCacheForTests = (): void => { + cachedUserId = undefined; +}; + +export const rememberDesktopUser = (userId: number): void => { + if (!isDesktopRuntime() || !Number.isInteger(userId) || userId <= 0) { + return; + } + if (cachedUserId === userId) { + return; + } + cachedUserId = userId; + const file = loginStatePath(); + if (!file) { + return; + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify({ userId })}\n`, { mode: 0o600 }); +}; + +export const forgetDesktopUser = (): void => { + cachedUserId = null; + const file = loginStatePath(); + if (!file) { + return; + } + try { + fs.unlinkSync(file); + } catch { + // Missing file is the logged-out state. + } +}; + +export const recalledDesktopUserId = (): number | undefined => { + if (!isDesktopRuntime()) { + return undefined; + } + if (cachedUserId !== undefined) { + return cachedUserId ?? undefined; + } + const file = loginStatePath(); + if (!file) { + cachedUserId = null; + return undefined; + } + try { + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as { + userId?: unknown; + }; + const userId = parsed.userId; + if (typeof userId === 'number' && Number.isInteger(userId) && userId > 0) { + cachedUserId = userId; + return userId; + } + } catch { + // Missing or garbage — treat as logged out. + } + cachedUserId = null; + return undefined; +}; + +/** Re-issue a session cookie on a new loopback origin after app restart. */ +export const restoreDesktopSession: Middleware = async (req, _res, next) => { + if (!isDesktopRuntime() || req.header('X-API-Key')) { + next(); + return; + } + if (req.session.userId) { + rememberDesktopUser(req.session.userId); + next(); + return; + } + const recalled = recalledDesktopUserId(); + if (!recalled) { + next(); + return; + } + const user = await getRepository(User).findOne({ + where: { id: recalled }, + loadEagerRelations: false, + }); + if (!user) { + forgetDesktopUser(); + next(); + return; + } + req.session.userId = user.id; + next(); +}; diff --git a/server/lib/jsonSafe.test.ts b/server/lib/jsonSafe.test.ts new file mode 100644 index 0000000000..2f0e67754e --- /dev/null +++ b/server/lib/jsonSafe.test.ts @@ -0,0 +1,65 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { jsonSafeClone } from './jsonSafe'; + +describe('jsonSafeClone', () => { + it('clones plain data and ISO-stringifies dates', () => { + const when = new Date('2026-08-22T18:00:00.000Z'); + assert.deepEqual(jsonSafeClone({ id: 1, when, nested: { ok: true } }), { + id: 1, + when: '2026-08-22T18:00:00.000Z', + nested: { ok: true }, + }); + }); + + it('drops cycles instead of throwing or looping', () => { + const user: { name: string; self?: unknown } = { name: 'admin' }; + user.self = user; + assert.deepEqual(jsonSafeClone(user), { name: 'admin' }); + }); + + it('stops walking past a depth cap', () => { + let nested: Record = { value: 0 }; + for (let i = 1; i < 20; i++) { + nested = { value: i, child: nested }; + } + const cloned = jsonSafeClone(nested) as Record; + let depth = 0; + let cursor: unknown = cloned; + while (cursor && typeof cursor === 'object' && 'child' in cursor) { + depth += 1; + cursor = (cursor as { child: unknown }).child; + } + assert.ok(depth <= 8); + }); + + it('aborts a wide object instead of walking every key', () => { + const wide: Record>> = {}; + for (let i = 0; i < 80; i++) { + const inner: Record> = {}; + for (let j = 0; j < 80; j++) { + inner[`k${j}`] = { n: j }; + } + wide[`k${i}`] = inner; + } + const started = Date.now(); + const cloned = jsonSafeClone(wide) as Record; + assert.ok(Date.now() - started < 500); + let walked = 0; + for (const value of Object.values(cloned)) { + if (value && typeof value === 'object') { + walked += Object.keys(value).length; + } + } + assert.ok(walked < 80 * 80); + }); + + it('does not walk buffers as byte-index objects', () => { + const cloned = jsonSafeClone({ + name: 'admin', + blob: Buffer.alloc(1024 * 1024), + }) as Record; + assert.deepEqual(cloned, { name: 'admin' }); + }); +}); diff --git a/server/lib/jsonSafe.ts b/server/lib/jsonSafe.ts new file mode 100644 index 0000000000..5aec33d954 --- /dev/null +++ b/server/lib/jsonSafe.ts @@ -0,0 +1,69 @@ +const MAX_DEPTH = 8; +const MAX_NODES = 2_000; + +/** + * Produce a JSON-compatible clone. TypeORM entities and other graphs can + * carry cycles or expanding getters; JSON.stringify then never returns and + * the Node event loop stays at 100% CPU (desktop login freeze). + */ +export const jsonSafeClone = (value: unknown): unknown => { + const seen = new WeakSet(); + let nodes = 0; + + const walk = (input: unknown, depth: number): unknown => { + if (input === null || typeof input !== 'object') { + if (typeof input === 'bigint') { + return input.toString(); + } + if (typeof input === 'function' || typeof input === 'undefined') { + return undefined; + } + if (typeof input === 'symbol') { + return undefined; + } + if (typeof input === 'number' && !Number.isFinite(input)) { + return null; + } + return input; + } + if (depth > MAX_DEPTH || nodes > MAX_NODES) { + return undefined; + } + if (seen.has(input)) { + return undefined; + } + if (input instanceof Date) { + return Number.isNaN(input.getTime()) ? null : input.toISOString(); + } + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { + return undefined; + } + if (ArrayBuffer.isView(input)) { + return undefined; + } + seen.add(input); + nodes += 1; + if (Array.isArray(input)) { + if (input.length > MAX_NODES) { + return undefined; + } + return input.map((item) => walk(item, depth + 1)); + } + const output: Record = {}; + for (const key of Object.keys(input)) { + if (key.startsWith('__')) { + continue; + } + const next = walk((input as Record)[key], depth + 1); + if (next !== undefined) { + output[key] = next; + } + if (nodes > MAX_NODES) { + break; + } + } + return output; + }; + + return walk(value, 0); +}; diff --git a/server/lib/library.ts b/server/lib/library.ts index 8119d39d15..e7b2a18475 100644 --- a/server/lib/library.ts +++ b/server/lib/library.ts @@ -142,9 +142,10 @@ const mediaUrlForItem = (jellyfinItemId: string): string | undefined => { const jellyfin = settings.jellyfin; if (!jellyfin.ip && !jellyfin.externalHostname) return undefined; const jellyfinHost = - jellyfin.externalHostname && jellyfin.externalHostname.length > 0 - ? jellyfin.externalHostname - : getHostname(); + process.env.FORESEERR_RUNTIME === 'desktop' || + !(jellyfin.externalHostname && jellyfin.externalHostname.length > 0) + ? getHostname() + : jellyfin.externalHostname; const serverId = jellyfin.serverId ?? ''; return jellyfinPlaybackUrl(jellyfinHost, serverId, jellyfinItemId); }; diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index a8fd590493..70362c0a2c 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,18 +1,39 @@ import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; +import { UserSettings } from '@server/entity/UserSettings'; import type { Permission, PermissionCheckOptions, } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; +const loadUserById = async (userId: number): Promise => { + return getRepository(User).findOne({ + where: { id: userId }, + // Never join settings here. User.settings is eager and UserSettings.user + // points back; loading that graph on every API request wedges Node + // (desktop login looks like a silent Sign In bounce). + loadEagerRelations: false, + }); +}; + +const localeForUser = async ( + userId: number, + fallback: string +): Promise => { + const row = await getRepository(UserSettings) + .createQueryBuilder('us') + .select('us.locale', 'locale') + .where('us.userId = :userId', { userId }) + .getRawOne<{ locale?: string | null }>(); + return row?.locale || fallback; +}; + export const checkUser: Middleware = async (req, _res, next) => { const settings = getSettings(); let user: User | undefined | null; if (req.header('X-API-Key') === settings.main.apiKey) { - const userRepository = getRepository(User); - let userId = 1; // Work on original administrator account // If a User ID is provided, we will act on that user's behalf @@ -20,27 +41,18 @@ export const checkUser: Middleware = async (req, _res, next) => { userId = Number(req.header('X-API-User')); } - user = await userRepository.findOne({ - where: { id: userId }, - relations: { settings: true }, - }); + user = await loadUserById(userId); } else if (req.session?.userId) { - const userRepository = getRepository(User); - - user = await userRepository.findOne({ - where: { id: req.session.userId }, - relations: { settings: true }, - }); + user = await loadUserById(req.session.userId); } if (user) { req.user = user; + req.locale = await localeForUser(user.id, settings.main.locale); + } else { + req.locale = settings.main.locale; } - req.locale = user?.settings?.locale - ? user.settings.locale - : settings.main.locale; - next(); }; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index ada00461db..ee13715e39 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -6,6 +6,7 @@ import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { startDesktopCatchUp, startJobs } from '@server/job/schedule'; +import { forgetDesktopUser } from '@server/lib/desktopLogin'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -15,13 +16,33 @@ import { ApiError } from '@server/types/error'; import { getAppVersion } from '@server/utils/appVersion'; import { getHostname } from '@server/utils/getHostname'; import axios from 'axios'; -import { Router } from 'express'; +import { Router, type Response } from 'express'; import net from 'net'; import validator from 'validator'; import { z } from 'zod'; const authRoutes = Router(); +/** After the login HTTP response is flushed. Starting jobs in setImmediate + * races session.save and /auth/me, then a blocked download-sync makes the + * desktop supervisor kill Node while the UI still says "Signing In…". */ +const scheduleDesktopJobsAfterResponse = (res: Response): void => { + if (process.env.FORESEERR_RUNTIME !== 'desktop') { + return; + } + const start = (): void => { + setTimeout(() => { + startJobs(); + startDesktopCatchUp(); + }, 3_000); + }; + if (res.writableEnded) { + start(); + return; + } + res.once('finish', start); +}; + export const quickConnectSecret = z.object({ secret: z .string() @@ -31,16 +52,13 @@ export const quickConnectSecret = z.object({ }); authRoutes.get('/me', isAuthenticated(), async (req, res) => { - const userRepository = getRepository(User); if (!req.user) { return res.status(500).json({ status: 500, error: 'Please sign in.', }); } - const user = await userRepository.findOneOrFail({ - where: { id: req.user.id }, - }); + const user = req.user; // check if email is required in settings and if user has an valid email const settings = await getSettings(); @@ -52,7 +70,17 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => { logger.warn(`User ${user.username} has no valid email address`); } - return res.status(200).json(user); + if (process.env.FORESEERR_RUNTIME === 'desktop') { + logger.info('GET /auth/me', { label: 'Auth', userId: user.id }); + res.once('finish', () => { + setTimeout(() => { + startJobs(); + startDesktopCatchUp(); + }, 5_000); + }); + } + + return res.status(200).json(user.toPublicJSON()); }); authRoutes.post('/plex', async (req, res, next) => { @@ -101,7 +129,9 @@ authRoutes.post('/plex', async (req, res, next) => { settings.main.mediaServerType = MediaServerType.PLEX; await settings.save(); - await userRepository.save(user); + if (process.env.FORESEERR_RUNTIME !== 'desktop') { + await userRepository.save(user); + } startJobs(); startDesktopCatchUp(); } else { @@ -214,7 +244,8 @@ authRoutes.post('/plex', async (req, res, next) => { req.session.userId = user.id; } - return res.status(200).json(user?.filter() ?? {}); + scheduleDesktopJobsAfterResponse(res); + return res.status(200).json(user?.toPublicJSON() ?? {}); } catch (e) { logger.error('Something went wrong authenticating with Plex account', { label: 'API', @@ -296,7 +327,13 @@ authRoutes.post('/jellyfin', async (req, res, next) => { } // First we need to attempt to log the user in to jellyfin - const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId); + const desktopRuntime = process.env.FORESEERR_RUNTIME === 'desktop'; + const jellyfinserver = new JellyfinAPI( + hostname ?? '', + undefined, + deviceId, + desktopRuntime ? 15_000 : undefined + ); const ip = req.ip; let clientIp; @@ -404,7 +441,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => { const jellyfinClient = new JellyfinAPI( hostname, account.AccessToken, - deviceId + deviceId, + desktopRuntime ? 15_000 : undefined ); const apiKey = await jellyfinClient.createApiToken('Foreseerr'); @@ -439,16 +477,26 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUsername: account.User.Name, } ); - user.avatar = getUserAvatarUrl(user); - user.jellyfinUsername = account.User.Name; - user.jellyfinAuthToken = account.AccessToken; - user.jellyfinDeviceId = deviceId; - + // Persist the new access token without mutating the loaded entity. + // `save(user)` can spin TypeORM change-tracking (settings.user). Skipping + // the write is worse: Jellyfin invalidates the previous device token on + // login, so Library then 401s and the UI says "Could not reach Jellyfin." + const sessionFields: { + avatar: string; + jellyfinUsername: string; + jellyfinAuthToken: string; + jellyfinDeviceId: string; + username?: string; + } = { + avatar: getUserAvatarUrl(user), + jellyfinUsername: account.User.Name, + jellyfinAuthToken: account.AccessToken, + jellyfinDeviceId: deviceId, + }; if (user.username === account.User.Name) { - user.username = ''; + sessionFields.username = ''; } - - await userRepository.save(user); + await userRepository.update({ id: user.id }, sessionFields); } else if (!settings.main.newPlexLogin) { logger.warn( 'Failed sign-in attempt by unimported Jellyfin user with access to the media server', @@ -495,7 +543,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => { await userRepository.save(user); } - if (user && user.jellyfinUserId) { + // Avatar refresh is optional. In the managed desktop runtime it can hold + // the login response open indefinitely when the media server image route + // is slow or unavailable. + if ( + user && + user.jellyfinUserId && + process.env.FORESEERR_RUNTIME !== 'desktop' + ) { try { const { changed } = await checkAvatarChanged(user); @@ -520,7 +575,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => { req.session.userId = user?.id; } - return res.status(200).json(user?.filter() ?? {}); + scheduleDesktopJobsAfterResponse(res); + return res.status(200).json(user?.toPublicJSON() ?? {}); } catch (e) { switch (e.errorCode) { case ApiErrorCode.InvalidUrl: @@ -785,7 +841,8 @@ authRoutes.post( req.session.userId = user.id; } - return res.status(200).json(user?.filter() ?? {}); + scheduleDesktopJobsAfterResponse(res); + return res.status(200).json(user?.toPublicJSON() ?? {}); } catch (e) { logger.error('Quick Connect authentication failed', { label: 'Auth', @@ -837,7 +894,8 @@ authRoutes.post('/local', async (req, res, next) => { req.session.userId = user.id; } - return res.status(200).json(user?.filter() ?? {}); + scheduleDesktopJobsAfterResponse(res); + return res.status(200).json(user?.toPublicJSON() ?? {}); } catch (e) { logger.error( 'Something went wrong authenticating with Foreseerr password', @@ -857,6 +915,7 @@ authRoutes.post('/local', async (req, res, next) => { authRoutes.post('/logout', async (req, res, next) => { try { + forgetDesktopUser(); const userId = req.session?.userId; if (!userId) { return res.status(200).json({ status: 'ok' }); diff --git a/server/routes/desktop.test.ts b/server/routes/desktop.test.ts index 3e8a38dce1..570f3024c5 100644 --- a/server/routes/desktop.test.ts +++ b/server/routes/desktop.test.ts @@ -16,6 +16,7 @@ import { join } from 'node:path'; import { after, before, beforeEach, describe, it } from 'node:test'; import request from 'supertest'; import desktopRoutes, { + bindDesktopSessionStore, issueBrowserCacheTicket, resetDesktopAuthRateLimitsForTests, } from './desktop'; @@ -324,6 +325,88 @@ describe('desktop auth tickets', () => { assert.strictEqual(redeemed.body.accessToken, undefined); }); + it('bootstraps the LAN Jellyfin URL in the desktop runtime', async () => { + const previousRuntime = process.env.FORESEERR_RUNTIME; + process.env.FORESEERR_RUNTIME = 'desktop'; + try { + const settings = getSettings(); + settings.jellyfin.ip = '192.168.40.3'; + settings.jellyfin.port = 8096; + settings.jellyfin.useSsl = false; + settings.jellyfin.urlBase = ''; + settings.jellyfin.externalHostname = 'https://jellyfin.example.test'; + + const verifier = 'd'.repeat(43); + const challenge = await import('node:crypto').then(({ createHash }) => + createHash('sha256').update(verifier).digest('hex') + ); + const issued = await request(app) + .post('/desktop/auth-tickets') + .send({ challenge, protocolVersion: 1 }); + assert.strictEqual(issued.status, 201); + + const redeemed = await request(app) + .post('/desktop/auth-tickets/redeem') + .send({ ticket: issued.body.ticket, verifier, protocolVersion: 1 }); + assert.strictEqual(redeemed.status, 200); + assert.strictEqual(redeemed.body.serverUrl, 'http://192.168.40.3:8096'); + assert.strictEqual(redeemed.body.accessToken, 'secret-token'); + } finally { + if (previousRuntime === undefined) { + delete process.env.FORESEERR_RUNTIME; + } else { + process.env.FORESEERR_RUNTIME = previousRuntime; + } + } + }); + + it('redeems tickets from MemoryStore without a SQLite session row', async () => { + const store = new session.MemoryStore(); + bindDesktopSessionStore(store); + try { + await getRepository(Session).delete({ id: 'memory-desktop-session' }); + const memApp = express(); + memApp.use(express.json()); + memApp.use( + session({ + secret: 'test-secret', + resave: false, + saveUninitialized: false, + genid: () => 'memory-desktop-session', + store, + }) + ); + memApp.use((req, _res, next) => { + req.session.userId = 1; + next(); + }); + memApp.use(checkUser); + memApp.use('/desktop', desktopRoutes); + + const verifier = 'm'.repeat(43); + const challenge = await import('node:crypto').then(({ createHash }) => + createHash('sha256').update(verifier).digest('hex') + ); + const issued = await request(memApp) + .post('/desktop/auth-tickets') + .send({ challenge, protocolVersion: 1 }); + assert.strictEqual(issued.status, 201); + + const sqliteSession = await getRepository(Session).findOne({ + where: { id: 'memory-desktop-session' }, + }); + assert.equal(sqliteSession, null); + + const redeemed = await request(memApp) + .post('/desktop/auth-tickets/redeem') + .send({ ticket: issued.body.ticket, verifier, protocolVersion: 1 }); + assert.strictEqual(redeemed.status, 200); + assert.strictEqual(redeemed.body.accessToken, 'secret-token'); + } finally { + bindDesktopSessionStore(); + } + }); + it('exempts cookie-less ticket redeem from CSRF', () => { const serverSource = readFileSync(join(__dirname, '../index.ts'), 'utf8'); assert.match(serverSource, /ignoreRequest:/); diff --git a/server/routes/desktop.ts b/server/routes/desktop.ts index 973460bfc3..e621bda800 100644 --- a/server/routes/desktop.ts +++ b/server/routes/desktop.ts @@ -10,7 +10,9 @@ import { isAuthenticated } from '@server/middleware/auth'; import { ApiError } from '@server/types/error'; import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; +import type { Store } from 'express-session'; import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import net from 'node:net'; import { MoreThan } from 'typeorm'; import { z } from 'zod'; @@ -24,6 +26,39 @@ const browserCacheTickets = new Map< { userId: number; sessionId: string; expiresAt: number } >(); +let liveSessionStore: Store | undefined; + +/** Desktop ticket redeem prefers the live express-session store (MemoryStore + * in tests, SQLite TypeormStore in the app) over leftover hosted session rows. */ +export const bindDesktopSessionStore = (store?: Store): void => { + liveSessionStore = store; +}; + +const ticketSessionUserId = async ( + sessionId: string +): Promise => { + if (liveSessionStore) { + return new Promise((resolve) => { + liveSessionStore?.get(sessionId, (error, sess) => { + if (error || !sess) { + resolve(undefined); + return; + } + const userId = (sess as { userId?: unknown }).userId; + resolve(typeof userId === 'number' ? userId : undefined); + }); + }); + } + const session = await getRepository(Session).findOne({ + where: { id: sessionId, expiredAt: MoreThan(Date.now()) }, + }); + try { + return session ? JSON.parse(session.json).userId : undefined; + } catch { + return undefined; + } +}; + desktopRoutes.use((_req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); @@ -113,18 +148,57 @@ const cleanupExpiredTickets = async () => { .execute(); }; -const externalJellyfinHost = () => { +const isPrivateOrLoopbackHttpHost = (hostname: string): boolean => { + if (hostname === 'localhost') { + return true; + } + if (net.isIPv6(hostname)) { + const ip = hostname.toLowerCase(); + return ip === '::1' || ip.startsWith('fd') || ip.startsWith('fe80:'); + } + if (!net.isIPv4(hostname)) { + return false; + } + const octets = hostname.split('.').map(Number); + const [a, b] = octets; + return ( + a === 10 || + a === 127 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) + ); +}; + +/** Playback bootstrap URL. Hosted desktop requires HTTPS. The bundled + * standalone runtime talks to the same LAN Jellyfin that login/library use; + * copied production `externalHostname` values 401 and dump Play onto the + * public Jellyfin login page. */ +const jellyfinDesktopHost = () => { const settings = getSettings(); - const value = settings.jellyfin.externalHostname?.trim() || getHostname(); + const desktopRuntime = process.env.FORESEERR_RUNTIME === 'desktop'; + const value = desktopRuntime + ? getHostname() + : settings.jellyfin.externalHostname?.trim() || getHostname(); const parsed = z.string().url().safeParse(value); - if (!parsed.success || !parsed.data.startsWith('https://')) { + if (!parsed.success) { throw new Error('Invalid Jellyfin desktop server URL'); } const url = new URL(parsed.data); if (url.username || url.password) { throw new Error('Invalid Jellyfin desktop server URL'); } - return url.toString().replace(/\/$/, ''); + if (url.protocol === 'https:') { + return url.toString().replace(/\/$/, ''); + } + if ( + desktopRuntime && + url.protocol === 'http:' && + isPrivateOrLoopbackHttpHost(url.hostname) + ) { + return url.toString().replace(/\/$/, ''); + } + throw new Error('Invalid Jellyfin desktop server URL'); }; const findLinkedUser = (userId: number) => @@ -219,15 +293,7 @@ desktopRoutes.post('/auth-tickets/redeem', async (req, res, next) => { if (!sameDigest(record.challengeDigest, digest(verifier))) { return res.status(401).json({ code: 'invalid_verifier' }); } - const session = await getRepository(Session).findOne({ - where: { id: record.sessionId, expiredAt: MoreThan(Date.now()) }, - }); - let sessionUserId: number | undefined; - try { - sessionUserId = session ? JSON.parse(session.json).userId : undefined; - } catch { - sessionUserId = undefined; - } + const sessionUserId = await ticketSessionUserId(record.sessionId); if (sessionUserId !== record.userId) { return res.status(401).json({ code: 'session_expired' }); } @@ -245,7 +311,7 @@ desktopRoutes.post('/auth-tickets/redeem', async (req, res, next) => { ) { return res.status(409).json({ code: 'not_linked' }); } - const serverUrl = externalJellyfinHost(); + const serverUrl = jellyfinDesktopHost(); let linkedIdentity; try { linkedIdentity = await new JellyfinAPI( @@ -314,15 +380,7 @@ desktopRoutes.post('/browser-cache/redeem', async (req, res, next) => { } try { - const session = await getRepository(Session).findOne({ - where: { id: record.sessionId, expiredAt: MoreThan(Date.now()) }, - }); - let sessionUserId: number | undefined; - try { - sessionUserId = session ? JSON.parse(session.json).userId : undefined; - } catch { - sessionUserId = undefined; - } + const sessionUserId = await ticketSessionUserId(record.sessionId); const user = await getRepository(User).findOne({ where: { id: record.userId }, }); diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index acc3f8daf5..89f52e3f58 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -94,6 +94,7 @@ const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => { password: values.password, email: values.username, }); + await revalidate(); } catch (e) { let errorMessage = messages.loginerror; switch (e?.response?.data?.message) { @@ -117,8 +118,6 @@ const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => { appearance: 'error', } ); - } finally { - revalidate(); } }} > From 7d008cfcc70a212c63622c10682d7a2072cb7104 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sun, 23 Aug 2026 00:01:28 +0300 Subject: [PATCH 48/48] feat: prefer LAN Jellyfin with external fallback for desktop play Return both host candidates from redeem and surface remote Foreseerr entry from the login screen for desktop clients. Co-authored-by: Cursor --- server/routes/desktop.test.ts | 39 +++++++++++- server/routes/desktop.ts | 112 ++++++++++++++++----------------- src/components/Login/index.tsx | 34 ++++++++++ 3 files changed, 124 insertions(+), 61 deletions(-) diff --git a/server/routes/desktop.test.ts b/server/routes/desktop.test.ts index 570f3024c5..21a5a626bb 100644 --- a/server/routes/desktop.test.ts +++ b/server/routes/desktop.test.ts @@ -307,7 +307,7 @@ describe('desktop auth tickets', () => { assert.strictEqual(redeemed.body.userId, 'user-1'); }); - it('rejects an HTTP Jellyfin bootstrap URL by default', async () => { + it('accepts an HTTP Jellyfin bootstrap URL', async () => { getSettings().jellyfin.externalHostname = 'http://jellyfin.example.test'; const verifier = 'h'.repeat(43); const challenge = await import('node:crypto').then(({ createHash }) => @@ -321,8 +321,37 @@ describe('desktop auth tickets', () => { const redeemed = await request(app) .post('/desktop/auth-tickets/redeem') .send({ ticket: issued.body.ticket, verifier, protocolVersion: 1 }); - assert.strictEqual(redeemed.status, 500); - assert.strictEqual(redeemed.body.accessToken, undefined); + assert.strictEqual(redeemed.status, 200); + assert.strictEqual(redeemed.body.serverUrl, 'http://jellyfin.example.test'); + assert.strictEqual(redeemed.body.accessToken, 'secret-token'); + }); + + it('prefers the LAN Jellyfin URL and keeps the external URL as fallback', async () => { + const settings = getSettings(); + settings.jellyfin.ip = '192.168.40.3'; + settings.jellyfin.port = 8096; + settings.jellyfin.useSsl = false; + settings.jellyfin.urlBase = ''; + settings.jellyfin.externalHostname = 'https://jellyfin.example.test'; + + const verifier = 'e'.repeat(43); + const challenge = await import('node:crypto').then(({ createHash }) => + createHash('sha256').update(verifier).digest('hex') + ); + const issued = await request(app) + .post('/desktop/auth-tickets') + .send({ challenge, protocolVersion: 1 }); + assert.strictEqual(issued.status, 201); + + const redeemed = await request(app) + .post('/desktop/auth-tickets/redeem') + .send({ ticket: issued.body.ticket, verifier, protocolVersion: 1 }); + assert.strictEqual(redeemed.status, 200); + assert.strictEqual(redeemed.body.serverUrl, 'http://192.168.40.3:8096'); + assert.strictEqual( + redeemed.body.fallbackServerUrl, + 'https://jellyfin.example.test' + ); }); it('bootstraps the LAN Jellyfin URL in the desktop runtime', async () => { @@ -350,6 +379,10 @@ describe('desktop auth tickets', () => { .send({ ticket: issued.body.ticket, verifier, protocolVersion: 1 }); assert.strictEqual(redeemed.status, 200); assert.strictEqual(redeemed.body.serverUrl, 'http://192.168.40.3:8096'); + assert.strictEqual( + redeemed.body.fallbackServerUrl, + 'https://jellyfin.example.test' + ); assert.strictEqual(redeemed.body.accessToken, 'secret-token'); } finally { if (previousRuntime === undefined) { diff --git a/server/routes/desktop.ts b/server/routes/desktop.ts index e621bda800..4fe7ccb0a8 100644 --- a/server/routes/desktop.ts +++ b/server/routes/desktop.ts @@ -12,7 +12,6 @@ import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; import type { Store } from 'express-session'; import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; -import net from 'node:net'; import { MoreThan } from 'typeorm'; import { z } from 'zod'; @@ -148,57 +147,41 @@ const cleanupExpiredTickets = async () => { .execute(); }; -const isPrivateOrLoopbackHttpHost = (hostname: string): boolean => { - if (hostname === 'localhost') { - return true; +/** Playback bootstrap URLs. Prefer the internal Jellyfin box (LAN IP) and + * keep the external URL as a client fallback when that host is unreachable. */ +const normalizeJellyfinUrl = (value: string): string | undefined => { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const parsed = z.string().url().safeParse(trimmed); + if (!parsed.success) { + return undefined; } - if (net.isIPv6(hostname)) { - const ip = hostname.toLowerCase(); - return ip === '::1' || ip.startsWith('fd') || ip.startsWith('fe80:'); + const url = new URL(parsed.data); + if (url.username || url.password || !url.hostname) { + return undefined; } - if (!net.isIPv4(hostname)) { - return false; + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return undefined; } - const octets = hostname.split('.').map(Number); - const [a, b] = octets; - return ( - a === 10 || - a === 127 || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) || - (a === 169 && b === 254) - ); + return url.toString().replace(/\/$/, ''); }; -/** Playback bootstrap URL. Hosted desktop requires HTTPS. The bundled - * standalone runtime talks to the same LAN Jellyfin that login/library use; - * copied production `externalHostname` values 401 and dump Play onto the - * public Jellyfin login page. */ -const jellyfinDesktopHost = () => { +const jellyfinDesktopHosts = (): { preferred: string; fallback?: string } => { const settings = getSettings(); - const desktopRuntime = process.env.FORESEERR_RUNTIME === 'desktop'; - const value = desktopRuntime - ? getHostname() - : settings.jellyfin.externalHostname?.trim() || getHostname(); - const parsed = z.string().url().safeParse(value); - if (!parsed.success) { - throw new Error('Invalid Jellyfin desktop server URL'); + const internal = normalizeJellyfinUrl(getHostname()); + const external = normalizeJellyfinUrl( + settings.jellyfin.externalHostname ?? '' + ); + if (internal && external && internal !== external) { + return { preferred: internal, fallback: external }; } - const url = new URL(parsed.data); - if (url.username || url.password) { + const preferred = internal ?? external; + if (!preferred) { throw new Error('Invalid Jellyfin desktop server URL'); } - if (url.protocol === 'https:') { - return url.toString().replace(/\/$/, ''); - } - if ( - desktopRuntime && - url.protocol === 'http:' && - isPrivateOrLoopbackHttpHost(url.hostname) - ) { - return url.toString().replace(/\/$/, ''); - } - throw new Error('Invalid Jellyfin desktop server URL'); + return { preferred }; }; const findLinkedUser = (userId: number) => @@ -311,22 +294,34 @@ desktopRoutes.post('/auth-tickets/redeem', async (req, res, next) => { ) { return res.status(409).json({ code: 'not_linked' }); } - const serverUrl = jellyfinDesktopHost(); + const { preferred: serverUrl, fallback: fallbackServerUrl } = + jellyfinDesktopHosts(); + const candidates = [serverUrl, fallbackServerUrl].filter( + (value): value is string => Boolean(value) + ); let linkedIdentity; - try { - linkedIdentity = await new JellyfinAPI( - serverUrl, - user.jellyfinAuthToken, - user.jellyfinDeviceId, - 5000 - ).getUser(); - } catch (error) { - const status = error instanceof ApiError ? error.statusCode : undefined; - const code = - status === 401 || status === 403 - ? 'token_invalid' - : 'server_unreachable'; - return res.status(code === 'token_invalid' ? 401 : 503).json({ code }); + let lastCode: 'token_invalid' | 'server_unreachable' = 'server_unreachable'; + for (const candidate of candidates) { + try { + linkedIdentity = await new JellyfinAPI( + candidate, + user.jellyfinAuthToken, + user.jellyfinDeviceId, + 5000 + ).getUser(); + break; + } catch (error) { + const status = error instanceof ApiError ? error.statusCode : undefined; + lastCode = + status === 401 || status === 403 + ? 'token_invalid' + : 'server_unreachable'; + } + } + if (!linkedIdentity) { + return res + .status(lastCode === 'token_invalid' ? 401 : 503) + .json({ code: lastCode }); } if (linkedIdentity.Id !== user.jellyfinUserId || !linkedIdentity.ServerId) { return res.status(401).json({ code: 'token_invalid' }); @@ -349,6 +344,7 @@ desktopRoutes.post('/auth-tickets/redeem', async (req, res, next) => { return res.status(200).json({ serverUrl, + fallbackServerUrl, serverId: linkedIdentity.ServerId, userId: user.jellyfinUserId, deviceId: user.jellyfinDeviceId, diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 2c321490b6..aee0ed62d6 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -8,6 +8,7 @@ import LanguagePicker from '@app/components/Layout/LanguagePicker'; import JellyfinLogin from '@app/components/Login/JellyfinLogin'; import LocalLogin from '@app/components/Login/LocalLogin'; import PlexLoginButton from '@app/components/Login/PlexLoginButton'; +import { isUsableForeseerNative } from '@app/context/nativeRuntimeProtocol'; import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; @@ -29,6 +30,7 @@ const messages = defineMessages('components.Login', { signinwithjellyfin: 'Use your {mediaServerName} account', signinwithoverseerr: 'Use your {applicationTitle} account', orsigninwith: 'Or sign in with', + useRemoteForeseerr: 'Use a remote Foreseerr instead', }); const Login = () => { @@ -43,6 +45,25 @@ const Login = () => { const [mediaServerLogin, setMediaServerLogin] = useState( settings.currentSettings.mediaServerLogin ); + const [canOpenRemoteSetup, setCanOpenRemoteSetup] = useState(false); + + useEffect(() => { + const host = window.foreseerNative; + setCanOpenRemoteSetup( + isUsableForeseerNative(host) && host.capabilities.includes('mode-setup') + ); + }, []); + + const openRemoteSetup = () => { + const host = window.foreseerNative; + if ( + !isUsableForeseerNative(host) || + !host.capabilities.includes('mode-setup') + ) { + return; + } + host.send({ type: 'runtime.open-setup', id: crypto.randomUUID() }); + }; // Effect that is triggered when the `authToken` comes back from the Plex OAuth // We take the token and attempt to sign in. If we get a success message, we will @@ -253,6 +274,19 @@ const Login = () => { > {additionalLoginOptions} + {canOpenRemoteSetup && ( +
+ +
+ )}