diff --git a/launcher.js b/launcher.js new file mode 100644 index 0000000000..aa044e74a2 --- /dev/null +++ b/launcher.js @@ -0,0 +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/package.json b/package.json index 8eb4bd0064..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", @@ -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==} diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index 7aa97f33fd..caf0b839ba 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,22 @@ "type": "setup.save", "fields": ["id", "url", "allowHttp"] }, + { + "type": "browser-cache.clear", + "fields": ["id", "ticket"] + }, + { + "type": "runtime.retry", + "fields": ["id"] + }, + { + "type": "runtime.open-logs", + "fields": ["id"] + }, + { + "type": "runtime.open-setup", + "fields": ["id"] + }, { "type": "window.minimize", "fields": ["id"] @@ -81,7 +98,12 @@ "canceled", "error", "connectivity-success", - "save-config-success" + "save-config-success", + "browser-cache-cleared", + "runtime-failed", + "runtime-recovered", + "logs-opened", + "setup-opened" ], "terminalPlayEventTypes": ["stopped", "finished", "canceled", "error"], "envelopes": { 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..051a551672 --- /dev/null +++ b/server/entity/JobExecutionState.ts @@ -0,0 +1,31 @@ +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; + + @DbAwareColumn({ type: 'datetime', nullable: true }) + public lastCancelledAt?: 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/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 434cbc77f2..4fcca074f7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,8 +5,25 @@ 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 { assertSupportedDatabaseSchema } from '@server/lib/db/schemaGuard'; +import { startDesktopCatchUp, startJobs, stopJobs } from '@server/job/schedule'; +import { + assertSupportedDatabaseSchema, + isMissingMigrationsTableError, +} from '@server/lib/db/schemaGuard'; +import { restoreDesktopSession } from '@server/lib/desktopLogin'; +import { + DESKTOP_SCHEMA_EXIT_CODE, + acquireDesktopLock, + desktopError, +} from '@server/lib/desktopRuntime'; +import { + setDesktopApplicationUrl, + setDesktopPlaybackActive, + setDesktopRuntime, + 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'; @@ -24,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'; @@ -41,6 +59,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,267 +71,554 @@ logger.info(`Starting Seerr version ${getAppVersion()}`); const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); const handle = app.getRequestHandler(); +let desktopRuntime = process.env.FORESEERR_RUNTIME === 'desktop'; +let managedServer: HttpServer | undefined; +let releaseDesktopLock: (() => Promise) | undefined; +let stopping = false; +let desktopOrigin = ''; +let desktopControlInstalled = false; -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/' - ); +export interface ForeseerrRuntime { + origin: string; + server: HttpServer; + stop(options?: { deadlineMs?: number }): Promise; +} + +export interface ForeseerrStartOptions { + host?: string; + port?: number; + runtime?: 'hosted' | 'desktop'; } -app - .prepare() - .then(async () => { - // Run Overseerr to Seerr migration - await checkOverseerrMerge(); - - 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'); +const stopManagedRuntime = async (deadlineMs = 10_000): Promise => { + if (stopping) return; + stopping = true; + setDesktopStopping(true); + stopJobs(); + const server = managedServer; + if (server) { + await new Promise((resolve) => { + const timeout = setTimeout(() => { + server.closeAllConnections(); + resolve(); + }, deadlineMs); + server.close(() => { + clearTimeout(timeout); + resolve(); + }); + }); + } + managedServer = undefined; + desktopOrigin = ''; + setDesktopApplicationUrl(''); + 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)); +}; + +const installDesktopControlHandlers = (): void => { + if (desktopControlInstalled) return; + desktopControlInstalled = true; + 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); + if (!message.playbackActive) startDesktopCatchUp(); + } 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()); +}; - // 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. - await assertSupportedDatabaseSchema(dbConnection); +/** 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; +}; - // Load Settings - const settings = await getSettings().load(); - restartFlag.initializeSettings(settings); +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/' + ); +} - initI18n(); +const startForeseerrInternal = 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'); + setDesktopRuntime(desktopRuntime); + if (desktopRuntime) installDesktopControlHandlers(); + stopping = false; + desktopOrigin = ''; + setDesktopApplicationUrl(''); + setDesktopStopping(false); + await app.prepare(); + if (desktopRuntime) { + releaseDesktopLock = await acquireDesktopLock(); + } + // Run Overseerr to Seerr migration + await checkOverseerrMerge(); - setForceIpv4First(settings.network.forceIpv4First); + const dbConnection = dataSource.isInitialized + ? dataSource + : await dataSource.initialize(); - // Add DNS caching - if (settings.network.dnsCache?.enabled) { - initializeDnsCache({ - forceMinTtl: settings.network.dnsCache.forceMinTtl, - forceMaxTtl: settings.network.dnsCache.forceMaxTtl, - }); + // 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'); } + } - // Register HTTP proxy - if (settings.network.proxy.enabled) { - await createCustomProxyAgent( - settings.network.proxy, - settings.network.forceIpv4First + // 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; + } + const schemaVersion = await getDatabaseSchemaVersion(); - // 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 }, - }); + // Load Settings + const settings = await getSettings().load(); + restartFlag.initializeSettings(settings); - if (admin) { - logger.info('Migrating Plex libraries to include media type', { - label: 'Settings', - }); + initI18n(); - const plexapi = new PlexAPI({ plexToken: admin.plexToken }); - await plexapi.syncLibraries(); - } - } + setForceIpv4First(settings.network.forceIpv4First); - // 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(), - ]); + // 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 + ); + } + // Migrate library types + if (settings.plex.libraries.length > 1 && !settings.plex.libraries[0].type) { const userRepository = getRepository(User); - const totalUsers = await userRepository.count(); - if (totalUsers > 0) { - startJobs(); - } else { - logger.info( - `Skipping starting the scheduled jobs as we have no Plex/Jellyfin/Emby servers setup yet`, - { - label: 'Server', - } - ); - } + const admin = await userRepository.findOne({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); - // Bootstrap Discovery Sliders - await DiscoverSlider.bootstrapSliders(); + if (admin) { + logger.info('Migrating Plex libraries to include media type', { + label: 'Settings', + }); - const server = express(); - if (settings.network.trustProxy) { - server.enable('trust proxy'); + const plexapi = new PlexAPI({ plexToken: admin.plexToken }); + await plexapi.syncLibraries(); } - 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, + } + + // 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(); + // 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. + } 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(); + + // 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 = [ + '/api/v1/desktop/auth-tickets/redeem', + '/api/v1/desktop/browser-cache/redeem', + ].includes(req.path); + 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 (settings.network.csrfProtection) { - server.use( - csurf({ - cookie: { - httpOnly: true, - sameSite: true, - secure: !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: true, - secure: !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: settings.network.csrfProtection ? 'strict' : 'lax', - secure: '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' && + [ + '/api/v1/desktop/auth-tickets/redeem', + '/api/v1/desktop/browser-cache/redeem', + ].includes(req.path), }) ); - /** - * 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); + 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({ + secret: settings.sessionSecret, + resave: false, + saveUninitialized: false, + cookie: { + maxAge: 1000 * 60 * 60 * 24 * 30, + httpOnly: true, + path: '/', + sameSite: + desktopRuntime || settings.network.csrfProtection ? 'strict' : 'lax', + secure: desktopRuntime ? false : 'auto', + }, + 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)); + server.use( + OpenApiValidator.middleware({ + apiSpec: API_SPEC_PATH, + validateRequests: true, + }) + ); + /** + * 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('/api', (_req, res, next) => { + const original = res.json; + res.json = function jsonp(json) { + return original.call(this, jsonSafeClone(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 port = Number(process.env.PORT) || 5055; - const host = process.env.HOST; - let 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', - }); - }); - } - httpServer.on('error', (err) => { - logger.error('Failed to start server', { - 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, }); - process.exit(1); + } + ); + + const configuredPort = options.port ?? Number(process.env.PORT); + const port = + Number.isInteger(configuredPort) && configuredPort >= 0 + ? configuredPort + : 5055; + 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; + 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, logBoundAddress); + } + httpServer.on('error', (err) => { + logger.error('Failed to start server', { + label: 'Server', + message: err.message, }); - }) - .catch((err) => { - logger.error(err.stack); - 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; + } + // 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( + `FORESEERR_DESKTOP_READY ${JSON.stringify({ protocolVersion: 1, pid: process.pid, origin: desktopOrigin, foreseerrVersion: getAppVersion(), commit: process.env.FORESEERR_COMMIT ?? 'unknown', schemaVersion })}\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), + }; +}; + +/** + * 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 = ''; + setDesktopApplicationUrl(''); + stopping = false; + setDesktopStopping(false); +}; + +export const startForeseerr = async ( + options: ForeseerrStartOptions = {} +): Promise => { + try { + return await startForeseerrInternal(options); + } catch (error) { + if (desktopRuntime) { + await cleanupFailedDesktopStart(); + } + throw error; + } +}; 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/catchup.test.ts b/server/job/catchup.test.ts new file mode 100644 index 0000000000..9b614fcf7d --- /dev/null +++ b/server/job/catchup.test.ts @@ -0,0 +1,78 @@ +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( + '* * * * *', + 'light', + { + lastSucceededAt: new Date('2026-08-22T10:00:00Z'), + lastFailedAt: new Date('2026-08-22T11:30:00Z'), + }, + now + ), + true + ); + assert.equal( + canRunLaunchCatchUp( + '* * * * *', + 'heavy', + { + lastSucceededAt: new Date('2026-08-22T01:00:00Z'), + lastFailedAt: new Date('2026-08-22T07:00:00Z'), + }, + now + ), + 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 + ); + }); +}); 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; +}; diff --git a/server/job/execution.ts b/server/job/execution.ts new file mode 100644 index 0000000000..b32c7ab099 --- /dev/null +++ b/server/job/execution.ts @@ -0,0 +1,91 @@ +import { getRepository } from '@server/datasource'; +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; + +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); + +/** Signals managed work; callers also cancel their underlying scanner. */ +export const cancelManagedJobs = (): void => { + for (const controller of activeControllers.values()) { + controller.abort(); + } +}; + +/** 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, + 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 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) { + 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: 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 2b82470491..fb798f65b2 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,7 +1,19 @@ 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, + executeManagedJob, + type ManagedJobWeight, +} from '@server/job/execution'; import availabilitySync from '@server/lib/availabilitySync'; +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'; @@ -32,7 +44,72 @@ 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', +]); +let desktopCatchUpTimer: NodeJS.Timeout | undefined; +let desktopDownloadStartupRun = false; +const deferredHeavyJobs = new Map< + JobId, + { name: string; run: () => Promise } +>(); + +const runScheduledJob = ( + 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}`, + { + label: 'Jobs', + } + ); + return; + } + 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: JobId, name: string, run: () => Promise) => + runScheduledJob(id, 'heavy', name, run); + +const runDeferredHeavyJobs = async (): Promise => { + if (isDesktopPlaybackActive()) return; + const jobs = [...deferredHeavyJobs]; + deferredHeavyJobs.clear(); + for (const [id, { name, run }] of jobs) { + 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()); + } +}; + export const startJobs = (): void => { + if (scheduledJobs.length > 0) { + return; + } const jobs = getSettings().jobs; const mediaServerType = getSettings().main.mediaServerType; @@ -50,7 +127,9 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Plex Recently Added Scan', { label: 'Jobs', }); - plexRecentScanner.run(); + runHeavy('plex-recently-added-scan', 'Plex Recently Added Scan', () => + plexRecentScanner.run() + ); } ), running: () => plexRecentScanner.status().running, @@ -68,7 +147,9 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Plex Full Library Scan', { label: 'Jobs', }); - plexFullScanner.run(); + runHeavy('plex-full-scan', 'Plex Full Library Scan', () => + plexFullScanner.run() + ); }), running: () => plexFullScanner.status().running, cancelFn: () => plexFullScanner.cancel(), @@ -84,7 +165,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() + ); }), }); @@ -99,12 +185,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 ( @@ -124,7 +210,11 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Jellyfin Recently Added Scan', { label: 'Jobs', }); - jellyfinRecentScanner.run(); + runHeavy( + 'jellyfin-recently-added-scan', + 'Jellyfin Recently Added Scan', + () => jellyfinRecentScanner.run() + ); } ), running: () => jellyfinRecentScanner.status().running, @@ -142,7 +232,9 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Jellyfin Full Scan', { label: 'Jobs', }); - jellyfinFullScanner.run(); + runHeavy('jellyfin-full-scan', 'Jellyfin Full Scan', () => + jellyfinFullScanner.run() + ); }), running: () => jellyfinFullScanner.status().running, cancelFn: () => jellyfinFullScanner.cancel(), @@ -159,7 +251,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', 'Radarr Scan', () => radarrScanner.run()); }), running: () => radarrScanner.status().running, cancelFn: () => radarrScanner.cancel(), @@ -175,7 +267,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(), @@ -191,7 +288,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(), @@ -205,7 +307,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', 'Sonarr Scan', () => sonarrScanner.run()); }), running: () => sonarrScanner.status().running, cancelFn: () => sonarrScanner.cancel(), @@ -222,7 +324,9 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Media Availability Sync', { label: 'Jobs', }); - availabilitySync.run(); + runHeavy('availability-sync', 'Media Availability Sync', () => + availabilitySync.run() + ); }), running: () => availabilitySync.running, cancelFn: () => availabilitySync.cancel(), @@ -239,7 +343,9 @@ export const startJobs = (): void => { logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs', }); - downloadTracker.updateDownloads(); + runScheduledJob('download-sync', 'light', 'Download Sync', () => + downloadTracker.updateDownloads() + ); }), }); @@ -254,7 +360,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() + ); }), }); @@ -270,10 +381,16 @@ export const startJobs = (): void => { label: 'Jobs', }); // Clean TMDB image cache - ImageProxy.clearCache('tmdb'); - - // Clean users avatar image cache - ImageProxy.clearCache('avatar'); + runScheduledJob( + 'image-cache-cleanup', + 'light', + 'Image Cache Cleanup', + async () => { + ImageProxy.clearCache('tmdb'); + ImageProxy.clearCache('avatar'); + await ImageProxy.maintainCache(); + } + ); }), }); @@ -287,7 +404,9 @@ export const startJobs = (): void => { logger.info('Starting scheduled job: Process Blocklisted Tags', { label: 'Jobs', }); - blocklistedTagsProcessor.run(); + runHeavy('process-blocklisted-tags', 'Process Blocklisted Tags', () => + blocklistedTagsProcessor.run() + ); }), running: () => blocklistedTagsProcessor.status().running, cancelFn: () => blocklistedTagsProcessor.cancel(), @@ -295,3 +414,73 @@ 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 (!isDesktopRuntime()) return; + if (desktopCatchUpTimer) return; + desktopCatchUpTimer = setTimeout(() => { + 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() + ); + } + // 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. + await runDeferredHeavyJobs(); + for (const job of scheduledJobs) { + // 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' + : '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 => { + if (desktopCatchUpTimer) { + clearTimeout(desktopCatchUpTimer); + desktopCatchUpTimer = undefined; + } + desktopDownloadStartupRun = false; + deferredHeavyJobs.clear(); + cancelManagedJobs(); + 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/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); +}); diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 4381571bd3..8f3b9e82f3 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; +export const memoryCacheBudget = 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( + memoryCacheBudget, + 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.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 new file mode 100644 index 0000000000..78a55f5d41 --- /dev/null +++ b/server/lib/cacheStore.ts @@ -0,0 +1,182 @@ +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 accessSequence = 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; + } + nextAccess(): number { + this.accessSequence += 1; + return this.accessSequence; + } + ensureCapacity(): void { + 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(); + if (this.usedBytes <= this.limitBytes) return; + const candidate = [...this.stores] + .flatMap((store) => store.oldest()) + .sort((a, b) => a.accessedAt - b.accessedAt)[0]; + if (!candidate) return; + candidate.store.evict(candidate.key); + if (this.usedBytes >= before) return; + } + } + 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, + private readonly limits: { + maxEntries?: number; + maxBytes?: number; + estimateSize?: (value: unknown) => number; + } = {} + ) { + 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 = 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: this.limits.estimateSize?.(value) ?? estimateSize(value), + expiresAt: Date.now() + ttlSeconds * 1000, + 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 { + 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, + })) + .sort((a, b) => a.accessedAt - b.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); + } + 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 => { + 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/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/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..1b53cd8bfb --- /dev/null +++ b/server/lib/desktopState.ts @@ -0,0 +1,32 @@ +let playbackActive = false; +let stopping = false; +let managedDesktopRuntime = process.env.FORESEERR_RUNTIME === 'desktop'; +let desktopApplicationUrl = ''; + +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; + +/** 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/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() { diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 2dac7d9c5d..4b3ac5bd17 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -20,9 +20,23 @@ 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 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. */ const headerToString = (value: unknown, fallback = ''): string => { @@ -39,6 +53,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 +356,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 +421,7 @@ class ImageProxy { buffer, etag ); + void ImageProxy.maintainCache(); return { meta: { 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/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; diff --git a/server/lib/settings/desktopApplicationUrl.test.ts b/server/lib/settings/desktopApplicationUrl.test.ts new file mode 100644 index 0000000000..02fa97c599 --- /dev/null +++ b/server/lib/settings/desktopApplicationUrl.test.ts @@ -0,0 +1,39 @@ +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.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. + 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..292f9d7f8d 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'; @@ -19,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; @@ -711,10 +724,45 @@ 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: (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' && + isManagedApplicationUrl(value, applicationUrl) + ) { + return true; + } + return Reflect.set(target, property, value, receiver); + }, + }); } set main(data: MainSettings) { + if ( + isDesktopRuntime() && + isManagedApplicationUrl( + data.applicationUrl, + effectiveApplicationUrl(this.data.main.applicationUrl) + ) + ) { + data = { ...data, applicationUrl: this.data.main.applicationUrl }; + } this.data.main = mergeSettings(this.data.main, data); } @@ -869,7 +917,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, 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(), 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/migration/postgres/1786200000000-CreateJobExecutionState.ts b/server/migration/postgres/1786200000000-CreateJobExecutionState.ts new file mode 100644 index 0000000000..a7ebc299ac --- /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, "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 { + 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..bf3252f718 --- /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, "lastCancelledAt" 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/auth.ts b/server/routes/auth.ts index 89ac1d4e1b..ee13715e39 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -5,7 +5,8 @@ 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 { 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,9 +129,11 @@ authRoutes.post('/plex', async (req, res, next) => { settings.main.mediaServerType = MediaServerType.PLEX; await settings.save(); + if (process.env.FORESEERR_RUNTIME !== 'desktop') { + await userRepository.save(user); + } startJobs(); - - await userRepository.save(user); + startDesktopCatchUp(); } else { const mainUser = await userRepository.findOneOrFail({ select: { id: true, plexToken: true, plexId: true, email: true }, @@ -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'); @@ -419,6 +457,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) { @@ -438,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', @@ -494,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); @@ -519,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: @@ -784,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', @@ -836,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', @@ -856,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 dcdf939161..21a5a626bb 100644 --- a/server/routes/desktop.test.ts +++ b/server/routes/desktop.test.ts @@ -15,7 +15,11 @@ 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, { + bindDesktopSessionStore, + issueBrowserCacheTicket, + resetDesktopAuthRateLimitsForTests, +} from './desktop'; let app: Express; let apiApp: Express; @@ -87,6 +91,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 }) => @@ -278,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 }) => @@ -292,16 +321,129 @@ 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 () => { + 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.fallbackServerUrl, + 'https://jellyfin.example.test' + ); + 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:/); - 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'/); }); }); diff --git a/server/routes/desktop.ts b/server/routes/desktop.ts index 8630aa75db..4fe7ccb0a8 100644 --- a/server/routes/desktop.ts +++ b/server/routes/desktop.ts @@ -4,11 +4,13 @@ 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'; import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; +import type { Store } from 'express-session'; import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; import { MoreThan } from 'typeorm'; import { z } from 'zod'; @@ -18,6 +20,43 @@ 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 } +>(); + +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'); @@ -35,6 +74,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 +113,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 () => { @@ -79,20 +147,43 @@ const cleanupExpiredTickets = async () => { .execute(); }; -const externalJellyfinHost = () => { - const settings = getSettings(); - const value = settings.jellyfin.externalHostname?.trim() || getHostname(); - const parsed = z.string().url().safeParse(value); - if (!parsed.success || !parsed.data.startsWith('https://')) { - throw new Error('Invalid Jellyfin desktop server URL'); +/** 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; } const url = new URL(parsed.data); - if (url.username || url.password) { - throw new Error('Invalid Jellyfin desktop server URL'); + if (url.username || url.password || !url.hostname) { + return undefined; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return undefined; } return url.toString().replace(/\/$/, ''); }; +const jellyfinDesktopHosts = (): { preferred: string; fallback?: string } => { + const settings = getSettings(); + const internal = normalizeJellyfinUrl(getHostname()); + const external = normalizeJellyfinUrl( + settings.jellyfin.externalHostname ?? '' + ); + if (internal && external && internal !== external) { + return { preferred: internal, fallback: external }; + } + const preferred = internal ?? external; + if (!preferred) { + throw new Error('Invalid Jellyfin desktop server URL'); + } + return { preferred }; +}; + const findLinkedUser = (userId: number) => getRepository(User) .createQueryBuilder('user') @@ -185,15 +276,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' }); } @@ -211,22 +294,34 @@ desktopRoutes.post('/auth-tickets/redeem', async (req, res, next) => { ) { return res.status(409).json({ code: 'not_linked' }); } - const serverUrl = externalJellyfinHost(); + 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' }); @@ -249,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, @@ -260,4 +356,40 @@ 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 sessionUserId = await ticketSessionUserId(record.sessionId); + 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/index.ts b/server/routes/index.ts index 5eb17eca64..b683136c4d 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -8,6 +8,8 @@ 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 { 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'; @@ -103,6 +105,12 @@ router.get('/status', async (req, res) => { commitTag: getCommitTag(), ...(checkUpdate && { updateAvailable, commitsBehind }), restartRequired: restartFlag.isSet(), + ...(isDesktopRuntime() && { + runtime: 'desktop', + managed: true, + jobsStarted: scheduledJobs.length > 0, + stopping: isDesktopStopping(), + }), }); }); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 1b4c59074f..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, @@ -27,6 +28,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'; @@ -41,6 +43,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'; @@ -277,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()); }); @@ -444,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()); }); @@ -1202,36 +1211,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 +1263,57 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>( } ); +settingsRoutes.post( + '/cache/images/flush', + isAuthenticated(Permission.ADMIN), + 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 }); + } + } +); + +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), + async (_req, res, next) => { + try { + 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 }); + } + } +); + settingsRoutes.post<{ dnsEntry: string }>( '/cache/dns/:dnsEntry/flush', (req, res, next) => { 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(); } }} > 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 && ( +
+ +
+ )} diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 3660b226a1..c3da26998f 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,93 @@ 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 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' + ); + 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; + }; + + 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, + }); + cacheRevalidate(); + }; + + const flushBrowserCache = async () => { + if (!(await requestBrowserCacheClear())) { + addToast(intl.formatMessage(messages.browsercacheunavailable), { + appearance: 'error', + autoDismiss: true, + }); + return; + } + addToast(intl.formatMessage(messages.browsercacheflushed), { + appearance: 'success', + autoDismiss: true, + }); + }; + const scheduleJob = async () => { const jobScheduleCron = ['0', '0', '*', '*', '*', '*']; @@ -608,6 +703,20 @@ const SettingsJobs = () => { +
+ + + +
{cacheData?.dnsCache != null && ( <>
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 && ( +
+ +
+ +
+
+ )}
diff --git a/src/context/nativeRuntimeProtocol.ts b/src/context/nativeRuntimeProtocol.ts index b099567c52..ff16046e5b 100644 --- a/src/context/nativeRuntimeProtocol.ts +++ b/src/context/nativeRuntimeProtocol.ts @@ -50,6 +50,11 @@ export const nativeHostEventTypesV1 = [ 'error', 'connectivity-success', 'save-config-success', + 'browser-cache-cleared', + 'runtime-failed', + 'runtime-recovered', + 'logs-opened', + 'setup-opened', ] as const; export type NativeHostEventTypeV1 = (typeof nativeHostEventTypesV1)[number];