From c088be443b39508bb6213b675bb7c17b8223292a Mon Sep 17 00:00:00 2001 From: Christian Benincasa Date: Thu, 6 Jun 2024 15:45:19 -0400 Subject: [PATCH] Implement basic database backup mechanism (#483) * Implement basic database backup mechanism Closes #182 This commit implements a MVP of Tunarr database backup, with the following features: * Future-looking schema for backup configuration. Multiple backup "configurations" are supported, each with >=1 "outputs". Each configuration runs on a singluar schedule. Currently only one output type, archive file, is supported. * Full SQLite DB backup * Support for zip, tar, and tar.gz output * UI for configuring backup location, format, rotation, and schedule * Add feature to README --- README.md | 3 +- pnpm-lock.yaml | 190 +- server/package.json | 10 +- server/src/api/debugApi.ts | 13 + server/src/api/systemSettingsApi.ts | 56 +- server/src/api/tasksApi.ts | 18 +- .../src/dao/backup/ArchiveDatabaseBackup.ts | 151 + server/src/dao/backup/DatabaseBackup.ts | 20 + .../src/dao/backup/DatabaseBackupStrategy.ts | 1 + server/src/dao/backup/SqliteDatabaseBackup.ts | 30 + server/src/dao/databaseDirectoryUtil.ts | 6 + server/src/dao/settings.ts | 8 + server/src/server.ts | 2 - server/src/services/scheduler.ts | 126 +- server/src/tasks/BackupTask.ts | 58 + .../tasks/ReconcileProgramDurationsTask.ts | 1 - .../src/tasks/ScheduleDynamicChannelsTask.ts | 2 - server/src/tasks/Task.ts | 12 +- server/src/types/cron-parser.d.ts | 15 + server/src/util/asyncPool.ts | 8 +- server/src/util/dayjs.ts | 6 + server/src/util/schedulingUtil.test.ts | 13 + server/src/util/schedulingUtil.ts | 99 + types/src/SystemSettings.ts | 4 + types/src/api/index.ts | 21 +- types/src/schemas/settingsSchemas.ts | 35 + types/src/schemas/utilSchemas.ts | 31 + web/src/generated/client.ts | 3538 ----------------- web/src/hooks/useSystemSettings.ts | 8 +- .../pages/settings/GeneralSettingsPage.tsx | 233 +- 30 files changed, 955 insertions(+), 3763 deletions(-) create mode 100644 server/src/dao/backup/ArchiveDatabaseBackup.ts create mode 100644 server/src/dao/backup/DatabaseBackup.ts create mode 100644 server/src/dao/backup/DatabaseBackupStrategy.ts create mode 100644 server/src/dao/backup/SqliteDatabaseBackup.ts create mode 100644 server/src/dao/databaseDirectoryUtil.ts create mode 100644 server/src/tasks/BackupTask.ts create mode 100644 server/src/types/cron-parser.d.ts create mode 100644 server/src/util/dayjs.ts create mode 100644 server/src/util/schedulingUtil.test.ts create mode 100644 server/src/util/schedulingUtil.ts delete mode 100644 web/src/generated/client.ts diff --git a/README.md b/README.md index 03d35a124..5854097e2 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,14 @@ Tunarr has the following goals: - **NEW** Improvements to stream stability - **NEW** [Dark mode!](https://github.com/chrisbenincasa/tunarr/pull/34) - **NEW** Quickly find content you want for your channels with [advanced filtering and sorting](https://github.com/chrisbenincasa/tunarr/pull/210) +- **NEW** Scheduled, configurable backups - never lose your channels and configuration! - Spoofed [HDHR](https://www.silicondust.com/hdhomerun/) tuner and a IPTV channel list, providing a large amount of flexibility and easing integration with [xTeVe](https://github.com/xteve-project/xTeVe) and Plex - Customize channels with a logo, filler content ("commercials", music videos, prerolls, channel branding videos) between programming, and more! - Docker image and prepackaged binaries for Windows, Linux, and Mac OS - Use Nvidia for hardware encoding, including in Docker. - Source content from multiple Plex servers - Includes a WEB TV Guide where you can even play channels in your desktop by using your local media player. -- ~~Subtitle support~~ Subtitle support is currently in flux; it was removed to simplify the backend and stabilize the stream. Bringing this functionality back is tracked in #462. +- ~~Subtitle support~~ Subtitle support is currently in flux; it was removed to simplify the backend and stabilize the stream. Bringing this functionality back is tracked in [#462](https://github.com/chrisbenincasa/tunarr/issues/462). - Auto-deinterlace content ## Limitations diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da0c1cd64..79c729ea4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,9 +70,6 @@ importers: '@fastify/cors': specifier: ^8.4.1 version: 8.4.1 - '@fastify/middie': - specifier: ^8.3.0 - version: 8.3.0 '@fastify/multipart': specifier: ^8.1.0 version: 8.1.0 @@ -103,9 +100,9 @@ importers: '@tunarr/types': specifier: workspace:* version: link:../types - JSONStream: - specifier: 1.0.5 - version: 1.0.5 + archiver: + specifier: ^7.0.1 + version: 7.0.1 async-mutex: specifier: ^0.5.0 version: 0.5.0 @@ -124,15 +121,12 @@ importers: chokidar: specifier: ^3.6.0 version: 3.6.0 - cors: - specifier: ^2.8.5 - version: 2.8.5 + cron-parser: + specifier: ^4.9.0 + version: 4.9.0 dayjs: specifier: ^1.11.10 version: 1.11.10 - fast-json-stringify: - specifier: ^5.9.1 - version: 5.9.1 fast-xml-parser: specifier: ^4.3.5 version: 4.3.5 @@ -154,9 +148,6 @@ importers: lowdb: specifier: ^7.0.0 version: 7.0.1 - morgan: - specifier: ^1.10.0 - version: 1.10.0 node-cache: specifier: ^5.1.2 version: 5.1.2 @@ -193,9 +184,6 @@ importers: uuid: specifier: ^9.0.1 version: 9.0.1 - xml-writer: - specifier: ^1.7.0 - version: 1.7.0 yargs: specifier: ^17.7.2 version: 17.7.2 @@ -209,6 +197,9 @@ importers: '@mikro-orm/reflection': specifier: ^6.2.0 version: 6.2.0(@mikro-orm/core@6.2.0) + '@types/archiver': + specifier: ^6.0.2 + version: 6.0.2 '@types/async-retry': specifier: ^1.4.8 version: 1.4.8 @@ -257,9 +248,6 @@ importers: '@yao-pkg/pkg': specifier: ^5.11.5 version: 5.11.5 - archiver: - specifier: ^7.0.1 - version: 7.0.1 copyfiles: specifier: ^2.2.0 version: 2.2.0 @@ -1707,15 +1695,6 @@ packages: fast-json-stringify: 5.9.1 dev: false - /@fastify/middie@8.3.0: - resolution: {integrity: sha512-h+zBxCzMlkEkh4fM7pZaSGzqS7P9M0Z6rXnWPdUEPfe7x1BCj++wEk/pQ5jpyYY4pF8AknFqb77n7uwh8HdxEA==} - dependencies: - '@fastify/error': 3.4.1 - fastify-plugin: 4.5.1 - path-to-regexp: 6.2.1 - reusify: 1.0.4 - dev: false - /@fastify/multipart@8.1.0: resolution: {integrity: sha512-sRX9X4ZhAqRbe2kDvXY2NK7i6Wf1Rm2g/CjpGYYM7+Np8E6uWQXcj761j08qPfPO8PJXM+vJ7yrKbK1GPB+OeQ==} dependencies: @@ -1844,7 +1823,6 @@ packages: strip-ansi-cjs: /strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: /wrap-ansi@7.0.0 - dev: true /@jercle/yargonaut@1.1.5: resolution: {integrity: sha512-zBp2myVvBHp1UaJsNTyS6q4UDKT7eRiqTS4oNTS6VQMd6mpxYOdbeK4pY279cDCdakGy6hG0J3ejoXZVsPwHqw==} @@ -2468,7 +2446,6 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} requiresBuild: true - dev: true optional: true /@popperjs/core@2.11.8: @@ -2838,6 +2815,12 @@ packages: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} dev: true + /@types/archiver@6.0.2: + resolution: {integrity: sha512-KmROQqbQzKGuaAbmK+ZcytkJ51+YqDa7NmbXjmtC5YBLSyQYo21YaUnQ3HbaPFKL1ooo6RQ6OPYPIDyxfpDDXw==} + dependencies: + '@types/readdir-glob': 1.1.5 + dev: true + /@types/argparse@1.0.38: resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} @@ -3047,6 +3030,12 @@ packages: '@types/scheduler': 0.16.6 csstype: 3.1.3 + /@types/readdir-glob@1.1.5: + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + dependencies: + '@types/node': 20.8.9 + dev: true + /@types/responselike@1.0.3: resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} dependencies: @@ -3741,6 +3730,7 @@ packages: dependencies: jsonparse: 1.3.1 through: 2.3.8 + dev: true /abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -3751,6 +3741,7 @@ packages: engines: {node: '>=6.5'} dependencies: event-target-shim: 5.0.1 + dev: false /abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -3894,7 +3885,6 @@ packages: /ansi-regex@6.0.1: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} - dev: true /ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} @@ -3916,7 +3906,6 @@ packages: /ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} - dev: true /any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -3983,7 +3972,7 @@ packages: lodash: 4.17.21 normalize-path: 3.0.0 readable-stream: 4.4.2 - dev: true + dev: false /archiver@5.3.2: resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} @@ -4009,7 +3998,7 @@ packages: readdir-glob: 1.1.3 tar-stream: 3.1.7 zip-stream: 6.0.1 - dev: true + dev: false /archy@1.0.0: resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} @@ -4171,7 +4160,6 @@ packages: /async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} - dev: true /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -4215,7 +4203,7 @@ packages: /b4a@1.6.6: resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} - dev: true + dev: false /babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} @@ -4232,19 +4220,12 @@ packages: /bare-events@2.3.1: resolution: {integrity: sha512-sJnSOTVESURZ61XgEleqmP255T6zTYwHPwE4r6SssIh0U9/uDvfpdoJYpVUerJJZH2fueO+CdT8ZT+OC/7aZDA==} requiresBuild: true - dev: true + dev: false optional: true /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - /basic-auth@2.0.1: - resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} - engines: {node: '>= 0.8'} - dependencies: - safe-buffer: 5.1.2 - dev: false - /better-sqlite3@9.1.1: resolution: {integrity: sha512-FhW7bS7cXwkB2SFnPJrSGPmQerVSCzwBgmQ1cIRcYKxLsyiKjljzCbyEqqhYXo5TTBqt5BISiBj2YE2Sy2ynaA==} requiresBuild: true @@ -4485,7 +4466,7 @@ packages: /buffer-crc32@1.0.0: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} - dev: true + dev: false /buffer-fill@1.0.0: resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} @@ -4517,6 +4498,7 @@ packages: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + dev: false /builtin-status-codes@3.0.0: resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} @@ -4874,7 +4856,7 @@ packages: is-stream: 2.0.1 normalize-path: 3.0.0 readable-stream: 4.4.2 - dev: true + dev: false /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -4941,15 +4923,6 @@ packages: /core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - dev: true - - /cors@2.8.5: - resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} - engines: {node: '>= 0.10'} - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - dev: false /cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} @@ -4966,7 +4939,6 @@ packages: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} hasBin: true - dev: true /crc32-stream@4.0.3: resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} @@ -4982,7 +4954,7 @@ packages: dependencies: crc-32: 1.2.2 readable-stream: 4.4.2 - dev: true + dev: false /create-ecdh@4.0.4: resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==} @@ -5030,7 +5002,6 @@ packages: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - dev: true /crypto-browserify@3.12.0: resolution: {integrity: sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==} @@ -5100,17 +5071,6 @@ packages: resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} dev: false - /debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.0.0 - dev: false - /debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -5434,11 +5394,6 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - dev: true - - /ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - dev: false /electron-to-chromium@1.4.615: resolution: {integrity: sha512-/bKPPcgZVUziECqDc+0HkT87+0zhaWSZHNXqF8FLd2lQcptpmUFwoCSWjCdOng9Gdq+afKArPdEg/0ZW461Eng==} @@ -5474,7 +5429,6 @@ packages: /emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - dev: true /emojis-list@3.0.0: resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} @@ -5963,6 +5917,7 @@ packages: /event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + dev: false /eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -6055,7 +6010,7 @@ packages: /fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - dev: true + dev: false /fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} @@ -6319,7 +6274,6 @@ packages: dependencies: cross-spawn: 7.0.3 signal-exit: 4.1.0 - dev: true /form-data-encoder@2.1.4: resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} @@ -6532,7 +6486,6 @@ packages: minimatch: 9.0.3 minipass: 7.0.4 path-scurry: 1.10.1 - dev: true /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} @@ -7208,7 +7161,6 @@ packages: /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - dev: true /is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} @@ -7265,7 +7217,6 @@ packages: /isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - dev: true /isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -7273,7 +7224,6 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - dev: true /isobject@3.0.1: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} @@ -7305,7 +7255,6 @@ packages: '@isaacs/cliui': 8.0.2 optionalDependencies: '@pkgjs/parseargs': 0.11.0 - dev: true /jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} @@ -7419,6 +7368,7 @@ packages: /jsonparse@1.3.1: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + dev: true /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} @@ -7552,7 +7502,6 @@ packages: engines: {node: '>= 0.6.3'} dependencies: readable-stream: 2.3.8 - dev: true /levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} @@ -7813,7 +7762,6 @@ packages: /lru-cache@10.2.0: resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} engines: {node: 14 || >=16.14} - dev: true /lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -8010,7 +7958,6 @@ packages: engines: {node: '>=16 || 14 >=14.17'} dependencies: brace-expansion: 2.0.1 - dev: true /minimist-options@3.0.2: resolution: {integrity: sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ==} @@ -8026,7 +7973,6 @@ packages: /minipass@7.0.4: resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} engines: {node: '>=16 || 14 >=14.17'} - dev: true /mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -8087,23 +8033,6 @@ packages: xtend: 4.0.2 dev: true - /morgan@1.10.0: - resolution: {integrity: sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==} - engines: {node: '>= 0.8.0'} - dependencies: - basic-auth: 2.0.1 - debug: 2.6.9 - depd: 2.0.0 - on-finished: 2.3.0 - on-headers: 1.0.2 - transitivePeerDependencies: - - supports-color - dev: false - - /ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - dev: false - /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} @@ -8380,18 +8309,6 @@ packages: engines: {node: '>=14.0.0'} dev: false - /on-finished@2.3.0: - resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} - engines: {node: '>= 0.8'} - dependencies: - ee-first: 1.1.1 - dev: false - - /on-headers@1.0.2: - resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} - engines: {node: '>= 0.8'} - dev: false - /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -8704,7 +8621,6 @@ packages: /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - dev: true /path-key@4.0.0: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} @@ -8725,11 +8641,6 @@ packages: dependencies: lru-cache: 10.2.0 minipass: 7.0.4 - dev: true - - /path-to-regexp@6.2.1: - resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} - dev: false /path-type@3.0.0: resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} @@ -9052,7 +8963,6 @@ packages: /process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - dev: true /process-warning@2.3.0: resolution: {integrity: sha512-N6mp1+2jpQr3oCFMz6SeHRGbv6Slb20bRhj4v3xR99HqNToAcOe1MFOp4tytyzOfJn+QtN8Rf7U/h2KAn4kC6g==} @@ -9151,7 +9061,7 @@ packages: /queue-tick@1.0.1: resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} - dev: true + dev: false /quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -9346,7 +9256,6 @@ packages: safe-buffer: 5.1.2 string_decoder: 1.1.1 util-deprecate: 1.0.2 - dev: true /readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} @@ -9365,12 +9274,12 @@ packages: events: 3.3.0 process: 0.11.10 string_decoder: 1.3.0 + dev: false /readdir-glob@1.1.3: resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} dependencies: minimatch: 5.1.6 - dev: true /readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} @@ -9775,12 +9684,10 @@ packages: engines: {node: '>=8'} dependencies: shebang-regex: 3.0.0 - dev: true /shebang-regex@3.0.0: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - dev: true /shell-quote@1.8.1: resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} @@ -9806,7 +9713,6 @@ packages: /signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - dev: true /simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -10047,7 +9953,7 @@ packages: text-decoder: 1.1.0 optionalDependencies: bare-events: 2.3.1 - dev: true + dev: false /strict-uri-encode@1.1.0: resolution: {integrity: sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==} @@ -10092,7 +9998,6 @@ packages: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 strip-ansi: 7.1.0 - dev: true /string-width@7.1.0: resolution: {integrity: sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==} @@ -10156,7 +10061,6 @@ packages: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} dependencies: safe-buffer: 5.1.2 - dev: true /string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -10181,7 +10085,6 @@ packages: engines: {node: '>=12'} dependencies: ansi-regex: 6.0.1 - dev: true /strip-bom@2.0.0: resolution: {integrity: sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==} @@ -10360,7 +10263,7 @@ packages: b4a: 1.6.6 fast-fifo: 1.3.2 streamx: 2.18.0 - dev: true + dev: false /tarn@3.0.2: resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} @@ -10414,7 +10317,7 @@ packages: resolution: {integrity: sha512-TmLJNj6UgX8xcUZo4UDStGQtDiTzF7BzWlzn9g7UWrjkpHr5uJTK1ld16wZ3LXb2vb6jH8qU89dW5whuMdXYdw==} dependencies: b4a: 1.6.6 - dev: true + dev: false /text-decoding@1.0.0: resolution: {integrity: sha512-/0TJD42KDnVwKmDK6jj3xP7E2MG7SHAOG4tyTgyUCRPdHwvkquYNLEQltmdMa3owq3TkddCVcTsoctJI8VQNKA==} @@ -10459,6 +10362,7 @@ packages: /through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + dev: true /tildify@2.0.0: resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==} @@ -11143,11 +11047,6 @@ packages: resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==} engines: {node: '>= 0.10'} - /vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - dev: false - /vite-node@1.2.0: resolution: {integrity: sha512-ETnQTHeAbbOxl7/pyBck9oAPZZZo+kYnFt1uQDD+hPReOc+wCjXw4r4jHriBRuVDB5isHmPXxrfc1yJnfBERqg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -11592,7 +11491,6 @@ packages: requiresBuild: true dependencies: isexe: 2.0.0 - dev: true /why-is-node-running@2.2.2: resolution: {integrity: sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==} @@ -11635,7 +11533,6 @@ packages: ansi-styles: 6.2.1 string-width: 5.1.2 strip-ansi: 7.1.0 - dev: true /wrap-ansi@9.0.0: resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} @@ -11649,11 +11546,6 @@ packages: /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - /xml-writer@1.7.0: - resolution: {integrity: sha512-elFVMRiV5jb59fbc87zzVa0C01QLBEWP909mRuWqFqrYC5wNTH5QW4AaKMNv7d6zAsuOulkD7wnztZNLQW0Nfg==} - engines: {node: '>=0.4.0'} - dev: false - /xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -11793,7 +11685,7 @@ packages: archiver-utils: 5.0.2 compress-commons: 6.0.2 readable-stream: 4.4.2 - dev: true + dev: false /zod-to-json-schema@3.21.4(zod@3.22.4): resolution: {integrity: sha512-fjUZh4nQ1s6HMccgIeE0VP4QG/YRGPmyjO9sAh890aQKPEk3nqbfUXhMFaC+Dr5KvYBm8BCyvfpZf2jY9aGSsw==} diff --git a/server/package.json b/server/package.json index 0e5a30220..880be5f1c 100644 --- a/server/package.json +++ b/server/package.json @@ -29,7 +29,6 @@ }, "dependencies": { "@fastify/cors": "^8.4.1", - "@fastify/middie": "^8.3.0", "@fastify/multipart": "^8.1.0", "@fastify/static": "^6.12.0", "@fastify/swagger": "^8.12.1", @@ -40,16 +39,15 @@ "@mikro-orm/migrations": "6.2.0", "@tunarr/shared": "workspace:*", "@tunarr/types": "workspace:*", - "JSONStream": "1.0.5", + "archiver": "^7.0.1", "async-mutex": "^0.5.0", "async-retry": "^1.3.3", "axios": "^1.6.0", "better-sqlite3": "^9.1.1", "chalk": "^5.3.0", "chokidar": "^3.6.0", - "cors": "^2.8.5", + "cron-parser": "^4.9.0", "dayjs": "^1.11.10", - "fast-json-stringify": "^5.9.1", "fast-xml-parser": "^4.3.5", "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", @@ -57,7 +55,6 @@ "fastify-type-provider-zod": "^1.1.9", "lodash-es": "^4.17.21", "lowdb": "^7.0.0", - "morgan": "^1.10.0", "node-cache": "^5.1.2", "node-schedule": "^2.1.1", "node-ssdp": "^4.0.0", @@ -70,13 +67,13 @@ "retry": "^0.13.1", "tslib": "^2.6.2", "uuid": "^9.0.1", - "xml-writer": "^1.7.0", "yargs": "^17.7.2", "zod": "^3.22.4" }, "devDependencies": { "@mikro-orm/cli": "^6.2.0", "@mikro-orm/reflection": "^6.2.0", + "@types/archiver": "^6.0.2", "@types/async-retry": "^1.4.8", "@types/better-sqlite3": "^7.6.8", "@types/express": "^4.17.20", @@ -93,7 +90,6 @@ "@types/uuid": "^9.0.6", "@types/yargs": "^17.0.29", "@yao-pkg/pkg": "^5.11.5", - "archiver": "^7.0.1", "copyfiles": "^2.2.0", "del-cli": "^3.0.0", "dotenv-cli": "^7.4.1", diff --git a/server/src/api/debugApi.ts b/server/src/api/debugApi.ts index 7f767e064..2b7e9b1fd 100644 --- a/server/src/api/debugApi.ts +++ b/server/src/api/debugApi.ts @@ -25,6 +25,8 @@ import { StreamProgramCalculator, generateChannelContext, } from '../stream/StreamProgramCalculator.js'; +import { ArchiveDatabaseBackup } from '../dao/backup/ArchiveDatabaseBackup.js'; +import os from 'node:os'; const ChannelQuerySchema = { querystring: z.object({ @@ -341,4 +343,15 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => { ); }, ); + + fastify.get('/debug/db/backup', async (req, res) => { + await new ArchiveDatabaseBackup(req.serverCtx.settings, { + type: 'file', + outputPath: os.tmpdir(), + archiveFormat: 'tar', + gzip: true, + maxBackups: 3, + }).backup(); + return res.send(); + }); }; diff --git a/server/src/api/systemSettingsApi.ts b/server/src/api/systemSettingsApi.ts index 4f8bf78d4..15aa4ab53 100644 --- a/server/src/api/systemSettingsApi.ts +++ b/server/src/api/systemSettingsApi.ts @@ -1,13 +1,19 @@ +import { LoggingSettings, SystemSettings } from '@tunarr/types'; import { + SystemSettingsResponse, SystemSettingsResponseSchema, + UpdateBackupSettingsRequestSchema, UpdateSystemSettingsRequestSchema, } from '@tunarr/types/api'; +import { BackupSettings, BackupSettingsSchema } from '@tunarr/types/schemas'; +import { isUndefined } from 'lodash-es'; +import { DeepReadonly, Writable } from 'ts-essentials'; +import { scheduleBackupJobs } from '../services/scheduler'; import { RouterPluginAsyncCallback } from '../types/serverType'; import { getDefaultLogLevel, getEnvironmentLogLevel, } from '../util/logging/LoggerFactory'; -import { SystemSettings } from '@tunarr/types'; export const systemSettingsRouter: RouterPluginAsyncCallback = async ( fastify, @@ -39,30 +45,66 @@ export const systemSettingsRouter: RouterPluginAsyncCallback = async ( }, }, async (req, res) => { + let backupSettingsPotentiallyChanged = false; await req.serverCtx.settings.directUpdate((file) => { const { system } = file; - system.logging.useEnvVarLevel = req.body.useEnvVarLevel ?? true; + system.logging.useEnvVarLevel = + req.body.logging?.useEnvVarLevel ?? true; if (system.logging.useEnvVarLevel) { system.logging.logLevel = getDefaultLogLevel(false); } else { system.logging.logLevel = - req.body.logLevel ?? getDefaultLogLevel(false); + req.body.logging?.logLevel ?? getDefaultLogLevel(false); } + + if (!isUndefined(req.body.backup)) { + backupSettingsPotentiallyChanged = true; + system.backup = req.body.backup; + } + return file; }); + const refreshedSettings = req.serverCtx.settings.systemSettings(); + + if (backupSettingsPotentiallyChanged) { + scheduleBackupJobs(refreshedSettings.backup); + } + + return res.send(getSystemSettingsResponse(refreshedSettings)); + }, + ); + + fastify.put( + '/system/settings/backup', + { + schema: { + body: UpdateBackupSettingsRequestSchema, + response: { + 200: BackupSettingsSchema, + }, + }, + }, + async (req, res) => { + await req.serverCtx.settings.directUpdate((settings) => { + settings.system.backup = req.body; + return settings; + }); + return res.send( - getSystemSettingsResponse(req.serverCtx.settings.systemSettings()), + req.serverCtx.settings.backup as Writable, ); }, ); - function getSystemSettingsResponse(settings: SystemSettings) { + function getSystemSettingsResponse( + settings: DeepReadonly, + ): SystemSettingsResponse { const envLogLevel = getEnvironmentLogLevel(); return { - ...settings, + ...(settings as Writable), logging: { - ...settings.logging, + ...(settings.logging as Writable), environmentLogLevel: envLogLevel, }, }; diff --git a/server/src/api/tasksApi.ts b/server/src/api/tasksApi.ts index 58c3de6fc..fd7615ad0 100644 --- a/server/src/api/tasksApi.ts +++ b/server/src/api/tasksApi.ts @@ -1,7 +1,7 @@ import { BaseErrorSchema } from '@tunarr/types/api'; import { TaskSchema } from '@tunarr/types/schemas'; import dayjs from 'dayjs'; -import ld, { isNil } from 'lodash-es'; +import ld, { isEmpty, isNil } from 'lodash-es'; import { z } from 'zod'; import { GlobalScheduler } from '../services/scheduler.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; @@ -23,11 +23,15 @@ export const tasksApiRouter: RouterPluginAsyncCallback = async (fastify) => { async (_, res) => { const result = ld .chain(GlobalScheduler.scheduledJobsById) - .map((task, id) => { - if (isNil(task)) { + .map((tasks, id) => { + if (isNil(tasks) || isEmpty(tasks)) { return; } + // TODO: We're goingn to have to figure out a better way + // to represnt this in the API + const task = tasks[0]; + const lastExecution = task.lastExecution ? dayjs(task.lastExecution) : undefined; @@ -70,14 +74,12 @@ export const tasksApiRouter: RouterPluginAsyncCallback = async (fastify) => { }, }, async (req, res) => { - const task = GlobalScheduler.getScheduledJob(req.params.id); - if (isNil(task)) { + const tasks = GlobalScheduler.getScheduledJobs(req.params.id); + if (isNil(tasks) || isEmpty(tasks)) { return res.status(404).send(); } - if (isNil(task)) { - return res.status(404).send(); - } + const task = tasks[0]; if (task.running) { return res.status(400).send({ message: 'Task already running' }); diff --git a/server/src/dao/backup/ArchiveDatabaseBackup.ts b/server/src/dao/backup/ArchiveDatabaseBackup.ts new file mode 100644 index 000000000..c7b51b3e7 --- /dev/null +++ b/server/src/dao/backup/ArchiveDatabaseBackup.ts @@ -0,0 +1,151 @@ +import { FileBackupOutput } from '@tunarr/types/schemas'; +import archiver from 'archiver'; +import dayjs from 'dayjs'; +import { createWriteStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { LoggerFactory } from '../../util/logging/LoggerFactory'; +import { SettingsDB } from '../settings'; +import { BackupResult, DatabaseBackup } from './DatabaseBackup'; +import { SqliteDatabaseBackup } from './SqliteDatabaseBackup'; +import { getDatabasePath } from '../databaseDirectoryUtil'; +import { compact, isNull, map, sortBy, take } from 'lodash-es'; +import { asyncPool } from '../../util/asyncPool'; + +export class ArchiveDatabaseBackup extends DatabaseBackup { + #logger = LoggerFactory.child({ className: ArchiveDatabaseBackup.name }); + #config: FileBackupOutput; + + constructor(settings: SettingsDB, config: FileBackupOutput) { + super(settings); + this.#config = config; + } + + async backup(): Promise> { + const workingDirectory = path.join( + path.resolve(this.#config.tempDir ?? os.tmpdir()), + `tunarr-backup-`, + ); + + let tempDir: string; + try { + tempDir = await fs.mkdtemp(workingDirectory); + this.#logger.debug('Working on new backup at %s', tempDir); + } catch (e) { + this.#logger.error( + e, + 'Error creating temp directory at %s', + workingDirectory, + ); + // TODO Return a status?? + throw e; + } + + const isGzip = !!this.#config.gzip && this.#config.archiveFormat === 'tar'; + + const backupFileName = path.join( + this.outputPath, + `tunarr-backup-${dayjs().format('YYYYMMDD_HHmmss')}.${ + this.#config.archiveFormat + }${isGzip ? '.gz' : ''}`, + ); + + this.#logger.info(`Writing backup to ${backupFileName}`); + + const outStream = createWriteStream(backupFileName); + const archive = archiver(this.#config.archiveFormat, { gzip: isGzip }); + const finishedPromise = new Promise((resolve, reject) => { + archive.on('end', () => resolve(void 0)); + archive.on('error', reject); + }); + + archive.pipe(outStream); + + const sqlBackup = new SqliteDatabaseBackup(); + const sqlBackupFile = await sqlBackup.backup(path.join(tempDir, 'db.db')); + + archive + .file(sqlBackupFile, { name: 'db.db' }) + .file(getDatabasePath('settings.json'), { name: 'settings.json' }) + .directory(getDatabasePath('channel-lineups'), 'channel-lineups') + .directory(getDatabasePath('images'), 'images') + .directory(getDatabasePath('cache'), 'cache') + .glob(getDatabasePath('*.xml')); + await archive.finalize(); + + await fs.rm(tempDir, { recursive: true }); + + await this.deleteOldBackupIfNecessary(); + + return finishedPromise + .then(() => ({ type: 'success' as const, data: backupFileName })) + .catch((e) => { + this.#logger.error(e, 'Error creating backup'); + return { type: 'error' }; + }); + } + + private async deleteOldBackupIfNecessary() { + if (this.#config.maxBackups <= 0) { + return; + } + + const listings = await fs.readdir(this.outputPath); + const relevantListings = sortBy( + compact( + map(listings, (file) => { + const matchArr = file.match(/tunarr-backup-(\d{8}_\d{6})/); + if (isNull(matchArr) || matchArr.length === 0) { + return; + } + + const [date, time] = matchArr[1].split('_', 2); + const dateNum = parseInt(date); + const timeNum = parseInt(time); + // TODO: Log the bad case here + if (isNaN(dateNum) || isNaN(timeNum)) { + return; + } + + return [file, dateNum + timeNum] as const; + }), + ), + ([, sort]) => sort, + ); + + if (relevantListings.length > this.#config.maxBackups) { + this.#logger.debug( + 'Found %d old backups. The limit is %d', + relevantListings.length, + this.#config.maxBackups, + ); + const listingsToDelete = take( + relevantListings, + relevantListings.length - this.#config.maxBackups, + ); + + for await (const result of asyncPool( + listingsToDelete, + async ([file]) => fs.rm(path.join(this.outputPath, file)), + { concurrency: 3 }, + )) { + if (result.type === 'error') { + this.#logger.warn( + 'Unable to delete old backup file: %s', + result.input, + ); + } else { + this.#logger.debug( + 'Successfully deleted old backup file %s', + result.input[0], + ); + } + } + } + } + + private get outputPath() { + return path.resolve(this.#config.outputPath); + } +} diff --git a/server/src/dao/backup/DatabaseBackup.ts b/server/src/dao/backup/DatabaseBackup.ts new file mode 100644 index 000000000..9282d310e --- /dev/null +++ b/server/src/dao/backup/DatabaseBackup.ts @@ -0,0 +1,20 @@ +import { SettingsDB } from '../settings'; + +export type SuccessfulBackupResult = { + type: 'success'; + data: T; +}; + +export type FailureBackupResult = { + type: 'error'; +}; + +export type BackupResult = + | SuccessfulBackupResult + | FailureBackupResult; + +export abstract class DatabaseBackup { + constructor(protected settings: SettingsDB) {} + + abstract backup(): Promise>; +} diff --git a/server/src/dao/backup/DatabaseBackupStrategy.ts b/server/src/dao/backup/DatabaseBackupStrategy.ts new file mode 100644 index 000000000..c98bf2510 --- /dev/null +++ b/server/src/dao/backup/DatabaseBackupStrategy.ts @@ -0,0 +1 @@ +export abstract class DatabaseBackupStrategy {} diff --git a/server/src/dao/backup/SqliteDatabaseBackup.ts b/server/src/dao/backup/SqliteDatabaseBackup.ts new file mode 100644 index 000000000..342f91bef --- /dev/null +++ b/server/src/dao/backup/SqliteDatabaseBackup.ts @@ -0,0 +1,30 @@ +import BetterSqlite3 from 'better-sqlite3'; +import { dbOptions } from '../../globals'; +import { LoggerFactory } from '../../util/logging/LoggerFactory'; + +export class SqliteDatabaseBackup { + #logger = LoggerFactory.child({ className: SqliteDatabaseBackup.name }); + + async backup(outFile: string) { + const conn = BetterSqlite3(dbOptions().dbName!, { + fileMustExist: true, + }); + + try { + await conn.backup(outFile, { + progress: (info) => { + this.#logger.trace( + 'Backed up %d pages of DB, with %d reminaing', + info.totalPages - info.remainingPages, + info.remainingPages, + ); + return 100; + }, + }); + } finally { + conn.close(); + } + + return outFile; + } +} diff --git a/server/src/dao/databaseDirectoryUtil.ts b/server/src/dao/databaseDirectoryUtil.ts new file mode 100644 index 000000000..22548509e --- /dev/null +++ b/server/src/dao/databaseDirectoryUtil.ts @@ -0,0 +1,6 @@ +import path from 'node:path'; +import { globalOptions } from '../globals'; + +export function getDatabasePath(dbPath: string) { + return path.join(globalOptions().databaseDirectory, dbPath); +} diff --git a/server/src/dao/settings.ts b/server/src/dao/settings.ts index 6b654cbf0..89bb98957 100644 --- a/server/src/dao/settings.ts +++ b/server/src/dao/settings.ts @@ -20,6 +20,7 @@ import { v4 as uuidv4 } from 'uuid'; import { globalOptions } from '../globals.js'; import { z } from 'zod'; import { + BackupSettings, FfmpegSettingsSchema, HdhrSettingsSchema, PlexStreamSettingsSchema, @@ -93,6 +94,9 @@ export const defaultSettings = (dbBasePath: string): SettingsFile => ({ ffmpeg: defaultFfmpegSettings, }, system: { + backup: { + configurations: [], + }, logging: { logLevel: getDefaultLogLevel(), logsDirectory: getDefaultLogDirectory(), @@ -128,6 +132,10 @@ export class SettingsDB extends ITypedEventEmitter { return this.db.data.migration; } + get backup(): DeepReadonly { + return this.db.data.system.backup; + } + clientId(): string { return this.db.data.settings.clientId; } diff --git a/server/src/server.ts b/server/src/server.ts index f864fda0f..596b637ef 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,5 +1,4 @@ import cors from '@fastify/cors'; -import middie from '@fastify/middie'; import fastifyMultipart from '@fastify/multipart'; import fpStatic from '@fastify/static'; import fastifySwagger from '@fastify/swagger'; @@ -173,7 +172,6 @@ export async function initServer(opts: ServerOptions) { ? join(dirname(process.argv[1]), 'static') : undefined, }) - .register(middie) .register(cors, { origin: '*', // Testing }) diff --git a/server/src/services/scheduler.ts b/server/src/services/scheduler.ts index 46a3a8922..6525660fe 100644 --- a/server/src/services/scheduler.ts +++ b/server/src/services/scheduler.ts @@ -1,50 +1,72 @@ import type { Tag } from '@tunarr/types'; import dayjs, { type Dayjs } from 'dayjs'; -import ld, { isString, once, pickBy, values } from 'lodash-es'; +import ld, { filter, forEach, isString, once, reject, values } from 'lodash-es'; import { v4 } from 'uuid'; import { ServerContext } from '../serverContext.js'; +import { BackupTask } from '../tasks/BackupTask.js'; +import { CleanupSessionsTask } from '../tasks/CleanupSessionsTask.js'; import { OneOffTask } from '../tasks/OneOffTask.js'; import { ReconcileProgramDurationsTask } from '../tasks/ReconcileProgramDurationsTask.js'; +import { ScheduleDynamicChannelsTask } from '../tasks/ScheduleDynamicChannelsTask.js'; import { ScheduledTask } from '../tasks/ScheduledTask.js'; import { Task, TaskId } from '../tasks/Task.js'; -import { CleanupSessionsTask } from '../tasks/CleanupSessionsTask.js'; -import { ScheduleDynamicChannelsTask } from '../tasks/ScheduleDynamicChannelsTask.js'; import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { typedProperty } from '../types/path.js'; import { Maybe } from '../types/util.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; +import { parseEveryScheduleRule } from '../util/schedulingUtil.js'; +import { BackupSettings } from '@tunarr/types/schemas'; +import { DeepReadonly } from 'ts-essentials'; const { isDayjs } = dayjs; class Scheduler { - #scheduledJobsById: Record = {}; + #scheduledJobsById: Record = {}; // TaskId values always have an associated task (after server startup) - getScheduledJob< + getScheduledJobs< Id extends TaskId, OutType = Id extends Tag ? Out : unknown, - >(id: TaskId): ScheduledTask; - getScheduledJob(id: string): Maybe>; - getScheduledJob( + >(id: TaskId): ScheduledTask[]; + getScheduledJobs( + id: string, + ): Maybe[]>; + getScheduledJobs( id: Task | string, - ): Maybe> { + ): Maybe[]> { if (isString(id)) { - return this.#scheduledJobsById[id] as Maybe>; + return this.#scheduledJobsById[id] as Maybe[]>; } else { - return this.getScheduledJob(id.ID); + return this.getScheduledJobs(id.ID); } } - scheduleTask( - id: string, - task: ScheduledTask, - overwrite: boolean = true, - ): boolean { - if (!overwrite && this.#scheduledJobsById[id]) { - return false; + // Used for jobs where we know: + // 1. there is only one instance of them + // 2. they are always scheduled (below) + // TODO: There is probably a better way to handle the jobs that always + // exists with a single instance vs. one-off jobs that are triggered + // around the codebase (dynamic jobs) + getScheduledJob< + Id extends TaskId, + OutType = Id extends Tag ? Out : unknown, + >(id: TaskId): ScheduledTask { + return this.getScheduledJobs(id)[0]; + } + + // Clears all scheduled tasks for an ID and cancels them + clearTasks(id: string) { + if (this.#scheduledJobsById[id]) { + forEach(this.#scheduledJobsById[id], (task) => { + task.cancel(); + }); + + this.#scheduledJobsById[id] = []; } + } - this.#scheduledJobsById[id] = task; + scheduleTask(id: string, task: ScheduledTask): boolean { + this.insertTask(id, task); return true; } @@ -53,16 +75,34 @@ class Scheduler { when: Dayjs | Date | number, task: Task, ) { - const id = `one_off_${name}_${v4()}`; + const id = `one_off_${name}`; + const scheduledTaskName = `${name}_${v4()}`; task.addOnCompleteListener(() => { - delete this.#scheduledJobsById[id]; + this.#scheduledJobsById[id] = reject( + this.#scheduledJobsById[id] ?? [], + (j) => j.name === scheduledTaskName, + ); }); const ts = isDayjs(when) ? when.toDate() : when; - this.#scheduledJobsById[id] = new OneOffTask(name, ts, () => task); + this.insertTask(id, new OneOffTask(scheduledTaskName, ts, () => task)); + } + + private insertTask(id: string, task: ScheduledTask) { + if (!this.#scheduledJobsById[id]) { + this.#scheduledJobsById[id] = []; + } + this.#scheduledJobsById[id].push(task); } - get scheduledJobsById(): Record { - return pickBy(this.#scheduledJobsById, typedProperty('visible')); + get scheduledJobsById(): Record { + const ret: Record = {}; + forEach(this.#scheduledJobsById, (tasks, key) => { + const visibleTasks = filter(tasks, typedProperty('visible')); + if (visibleTasks.length > 0) { + ret[key] = visibleTasks; + } + }); + return ret; } } @@ -112,7 +152,10 @@ export const scheduleJobs = once((serverContext: ServerContext) => { ), ); + scheduleBackupJobs(serverContext.settings.backup); + ld.chain(values(GlobalScheduler.scheduledJobsById)) + .flatten() .filter((job) => job.runAtStartup) .forEach((job) => { job @@ -127,6 +170,41 @@ export const scheduleJobs = once((serverContext: ServerContext) => { }); }); +export function scheduleBackupJobs( + backupConfig: BackupSettings | DeepReadonly, +) { + GlobalScheduler.clearTasks(BackupTask.name); + const backupConfigs = backupConfig.configurations; + forEach( + filter( + backupConfigs, + (config) => config.enabled && config.outputs.length > 0, + ), + (config) => { + let cronSchedule: string; + switch (config.schedule.type) { + case 'every': { + cronSchedule = parseEveryScheduleRule(config.schedule); + break; + } + case 'cron': { + cronSchedule = config.schedule.cron; + break; + } + } + + GlobalScheduler.scheduleTask( + BackupTask.name, + new ScheduledTask( + BackupTask.name, + cronSchedule, + () => new BackupTask(config), + ), + ); + }, + ); +} + function hoursCrontab(hours: number): string { return `0 0 */${hours} * * *`; } diff --git a/server/src/tasks/BackupTask.ts b/server/src/tasks/BackupTask.ts new file mode 100644 index 000000000..d88c3515f --- /dev/null +++ b/server/src/tasks/BackupTask.ts @@ -0,0 +1,58 @@ +import { Tag } from '@tunarr/types'; +import { Task, TaskId } from './Task'; +import { BackupConfiguration } from '@tunarr/types/schemas'; +import { DeepReadonly } from 'ts-essentials'; +import { partition } from 'lodash-es'; +import { ArchiveDatabaseBackup } from '../dao/backup/ArchiveDatabaseBackup'; +import { getSettings } from '../dao/settings'; + +export class BackupTask extends Task { + public ID: string | Tag = BackupTask.name; + + constructor(private config: DeepReadonly) { + super(); + } + + protected async runInternal(): Promise { + if (!this.config.enabled) { + this.logger.debug('Skipping backup configuration which is disabled'); + return; + } + + const [validOutputs, invalidOutputs] = partition( + this.config.outputs, + (output) => output.type === 'file', + ); + + if (invalidOutputs.length > 0) { + this.logger.warn( + 'Found invalid backup output options: %O', + invalidOutputs, + ); + } + + if (validOutputs.length === 0) { + this.logger.warn( + 'Found no valid backup output options in this configuration: %O', + this.config, + ); + return; + } + + for (const output of validOutputs) { + try { + const result = await new ArchiveDatabaseBackup( + getSettings(), + output, + ).backup(); + if (result.type === 'success') { + this.logger.info('Successfully generated backup to %s', result.data); + } + } catch (e) { + this.logger.error(e, 'Error creating backup'); + } + } + + return; + } +} diff --git a/server/src/tasks/ReconcileProgramDurationsTask.ts b/server/src/tasks/ReconcileProgramDurationsTask.ts index 3a208ec4a..bf4221ee6 100644 --- a/server/src/tasks/ReconcileProgramDurationsTask.ts +++ b/server/src/tasks/ReconcileProgramDurationsTask.ts @@ -25,7 +25,6 @@ export class ReconcileProgramDurationsTask extends Task { static ID = ReconcileProgramDurationsTask.name; ID = ReconcileProgramDurationsTask.ID; - taskName = ReconcileProgramDurationsTask.name; // Optionally provide the channel ID that was updated on the triggering // operation, since theoretically we don't have to check it. diff --git a/server/src/tasks/ScheduleDynamicChannelsTask.ts b/server/src/tasks/ScheduleDynamicChannelsTask.ts index 10fba1dbf..58e3ac1a7 100644 --- a/server/src/tasks/ScheduleDynamicChannelsTask.ts +++ b/server/src/tasks/ScheduleDynamicChannelsTask.ts @@ -17,7 +17,6 @@ export class ScheduleDynamicChannelsTask extends Task { #taskFactory: DynamicChannelUpdaterFactory; public ID = ScheduleDynamicChannelsTask.ID; - public taskName = ScheduleDynamicChannelsTask.name; static create(channelsDb: ChannelDB) { return new ScheduleDynamicChannelsTask(channelsDb); @@ -66,7 +65,6 @@ class DynamicChannelUpdaterFactory { // This won't always be anonymous return new (class extends Task { public ID = contentSourceDef.updater._id; - public taskName = `AnonymousTest_` + contentSourceDef.updater._id; // eslint-disable-next-line @typescript-eslint/require-await protected async runInternal() { return ContentSourceUpdaterFactory.getUpdater( diff --git a/server/src/tasks/Task.ts b/server/src/tasks/Task.ts index 478dd4acd..f8e124b17 100644 --- a/server/src/tasks/Task.ts +++ b/server/src/tasks/Task.ts @@ -37,10 +37,10 @@ export abstract class Task { const error = isError(e) ? e : new Error(isString(e) ? e : 'Unknown'); const duration = round(performance.now() - start, 2); this.logger.warn( + error, 'Task %s ran in %d ms and failed. Error = %O', this.constructor.name, duration, - error, ); return; } finally { @@ -59,7 +59,9 @@ export abstract class Task { return this.running_; } - abstract get taskName(): string; + get taskName(): string { + return this.ID; + } addOnCompleteListener(listener: () => void) { return this.onCompleteListeners.add(listener); @@ -72,7 +74,11 @@ export function AnonymousTask( ): Task { return new (class extends Task { public ID = id; - public taskName = `AnonymousTest_` + id; + + get taskName() { + return `AnonymousTest_` + id; + } + // eslint-disable-next-line @typescript-eslint/require-await protected async runInternal() { return runnable(); diff --git a/server/src/types/cron-parser.d.ts b/server/src/types/cron-parser.d.ts new file mode 100644 index 000000000..d99b2daa2 --- /dev/null +++ b/server/src/types/cron-parser.d.ts @@ -0,0 +1,15 @@ +declare module 'cron-parser/lib/expression' { + namespace CronExpression { + const map: ['second', 'minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek']; + const constraints: [ + { min: 0; max: 59; chars: [] }, // Second + { min: 0; max: 59; chars: [] }, // Minute + { min: 0; max: 23; chars: [] }, // Hour + { min: 1; max: 31; chars: ['L'] }, // Day of month + { min: 1; max: 12; chars: [] }, // Month + { min: 0; max: 7; chars: ['L'] }, // Day of week + ]; + } + + export default CronExpression; +} diff --git a/server/src/util/asyncPool.ts b/server/src/util/asyncPool.ts index dc36dbe0b..67ea0698a 100644 --- a/server/src/util/asyncPool.ts +++ b/server/src/util/asyncPool.ts @@ -18,10 +18,11 @@ export async function* asyncPool( async function consume() { try { - const [, result] = await Promise.race(executing); + const [input, result] = await Promise.race(executing); return { type: 'success' as const, result, + input, }; } catch (e) { return e as Failure; @@ -65,9 +66,10 @@ type Failure = { input: In; }; -type Success = { +type Success = { type: 'success'; result: R; + input: In; }; -type Result = Success | Failure; +type Result = Success | Failure; diff --git a/server/src/util/dayjs.ts b/server/src/util/dayjs.ts new file mode 100644 index 000000000..201f463dd --- /dev/null +++ b/server/src/util/dayjs.ts @@ -0,0 +1,6 @@ +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; + +dayjs.extend(duration); + +export default dayjs; diff --git a/server/src/util/schedulingUtil.test.ts b/server/src/util/schedulingUtil.test.ts new file mode 100644 index 000000000..cfe3f02e1 --- /dev/null +++ b/server/src/util/schedulingUtil.test.ts @@ -0,0 +1,13 @@ +import { EverySchedule } from '@tunarr/types/schemas'; +import dayjs from './dayjs'; +import { parseEveryScheduleRule } from './schedulingUtil'; +test('should parse every schedules', () => { + const schedule: EverySchedule = { + type: 'every', + offsetMs: dayjs.duration(4, 'hours').asMilliseconds(), + increment: 1, + unit: 'hour', + }; + + expect(parseEveryScheduleRule(schedule)).toEqual('0 4-23 * * *'); +}); diff --git a/server/src/util/schedulingUtil.ts b/server/src/util/schedulingUtil.ts new file mode 100644 index 000000000..8718e9947 --- /dev/null +++ b/server/src/util/schedulingUtil.ts @@ -0,0 +1,99 @@ +import { EverySchedule } from '@tunarr/types/schemas'; +import { + CronFields, + DayOfTheMonthRange, + HourRange, + SixtyRange, +} from 'cron-parser'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { range, reduce } from 'lodash-es'; +import { run } from '.'; +import CronExpression from 'cron-parser/lib/expression'; +import parser from 'cron-parser'; +import { TupleToUnion } from '../types/util'; + +dayjs.extend(duration); + +const Order: Record = { + second: 0, + minute: 1, + hour: 2, + day: 3, + week: 4, +}; + +type Constraints = TupleToUnion; + +const constraintsByType: Record = run(() => { + return reduce( + CronExpression.map, + (acc, key, idx) => { + const constraint = CronExpression.constraints[idx]; + return { + ...acc, + [key]: constraint, + }; + }, + {} as Record, + ); +}); + +const defaultCronFields: CronFields = run(() => { + return reduce( + CronExpression.map, + (acc, key, idx) => { + const constraint = CronExpression.constraints[idx]; + return { + ...acc, + [key]: range(constraint.min, constraint.max + 1), + }; + }, + {} as Partial, + ) as Required; +}); + +export function parseEveryScheduleRule(schedule: EverySchedule) { + const offset = dayjs.duration(schedule.offsetMs); + + function getRange( + everyUnit: EverySchedule['unit'], + cronUnit: keyof CronFields, + ) { + const isSmallerUnit = Order[everyUnit] < Order[schedule.unit]; + let rangeEnd: number; + if (schedule.unit === everyUnit) { + rangeEnd = constraintsByType[cronUnit].max + 1; + } else if (isSmallerUnit) { + // a smaller unit takes into account offset + rangeEnd = + Math.min( + Math.max(offset.get(everyUnit), constraintsByType[cronUnit].min), + constraintsByType[cronUnit].max, + ) + 1; + } else { + // a larger unit is "all" + rangeEnd = constraintsByType[cronUnit].max + 1; + } + + return range( + Math.min( + Math.max(constraintsByType[cronUnit].min, offset.get(everyUnit)), + constraintsByType[cronUnit].max, + ), + rangeEnd, + schedule.unit === everyUnit ? schedule.increment : undefined, + ); + } + + return parser + .fieldsToExpression({ + month: defaultCronFields.month, + dayOfMonth: getRange('day', 'dayOfMonth') as DayOfTheMonthRange[], + dayOfWeek: defaultCronFields.dayOfWeek, + hour: getRange('hour', 'hour') as HourRange[], + minute: getRange('minute', 'minute') as SixtyRange[], + second: getRange('second', 'second') as SixtyRange[], + }) + .stringify(); +} diff --git a/types/src/SystemSettings.ts b/types/src/SystemSettings.ts index 230246a87..7c207dd54 100644 --- a/types/src/SystemSettings.ts +++ b/types/src/SystemSettings.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { TupleToUnion } from './util.js'; +import { BackupSettingsSchema } from './schemas/settingsSchemas.js'; export const LogLevelsSchema = z.union([ z.literal('silent'), @@ -31,7 +32,10 @@ export const LoggingSettingsSchema = z.object({ useEnvVarLevel: z.boolean().default(true), }); +export type LoggingSettings = z.infer; + export const SystemSettingsSchema = z.object({ + backup: BackupSettingsSchema, logging: LoggingSettingsSchema, }); diff --git a/types/src/api/index.ts b/types/src/api/index.ts index 6fdca4f91..9640d9730 100644 --- a/types/src/api/index.ts +++ b/types/src/api/index.ts @@ -4,7 +4,10 @@ import { ContentProgramSchema, CustomProgramSchema, } from '../schemas/programmingSchema.js'; -import { PlexServerSettingsSchema } from '../schemas/settingsSchemas.js'; +import { + BackupSettingsSchema, + PlexServerSettingsSchema, +} from '../schemas/settingsSchemas.js'; import { RandomSlotScheduleSchema, TimeSlotScheduleSchema, @@ -173,11 +176,19 @@ export const SystemSettingsResponseSchema = SystemSettingsSchema.extend({ }), }); -export const UpdateSystemSettingsRequestSchema = - SystemSettingsSchema.shape.logging - .pick({ logLevel: true, useEnvVarLevel: true }) - .partial(); +export type SystemSettingsResponse = z.infer< + typeof SystemSettingsResponseSchema +>; + +export const UpdateSystemSettingsRequestSchema = z.object({ + logging: LoggingSettingsSchema.pick({ logLevel: true, useEnvVarLevel: true }) + .partial() + .optional(), + backup: BackupSettingsSchema.optional(), +}); export type UpdateSystemSettingsRequest = z.infer< typeof UpdateSystemSettingsRequestSchema >; + +export const UpdateBackupSettingsRequestSchema = BackupSettingsSchema; diff --git a/types/src/schemas/settingsSchemas.ts b/types/src/schemas/settingsSchemas.ts index 2e22b4e93..f823ec17b 100644 --- a/types/src/schemas/settingsSchemas.ts +++ b/types/src/schemas/settingsSchemas.ts @@ -1,6 +1,7 @@ import z from 'zod'; import { ResolutionSchema } from './miscSchemas.js'; import { TupleToUnion } from '../util.js'; +import { ScheduleSchema } from './utilSchemas.js'; export const XmlTvSettingsSchema = z.object({ programmingHours: z.number().default(12), @@ -132,3 +133,37 @@ export const HdhrSettingsSchema = z.object({ autoDiscoveryEnabled: z.boolean().default(true), tunerCount: z.number().default(2), }); + +export const FileBackupOutputSchema = z.object({ + type: z.literal('file'), + outputPath: z.string(), + archiveFormat: z.union([z.literal('zip'), z.literal('tar')]).default('tar'), + gzip: z.boolean().optional(), // Only valid if archive format is tar + tempDir: z.string().optional(), // Defaults to OS-specific temp dir, can be relative path + maxBackups: z.number().positive().default(3), +}); + +export type FileBackupOutput = z.infer; + +export const BackupOutputSchema = z.discriminatedUnion('type', [ + FileBackupOutputSchema, +]); + +export const BackupConfigurationSchema = z.object({ + enabled: z.boolean().default(true), + schedule: ScheduleSchema.default({ + type: 'every', + increment: 1, + unit: 'day', + offsetMs: 4 * 60 * 60 * 1000, + }), + outputs: z.array(BackupOutputSchema), +}); + +export type BackupConfiguration = z.infer; + +export const BackupSettingsSchema = z.object({ + configurations: z.array(BackupConfigurationSchema), +}); + +export type BackupSettings = z.infer; diff --git a/types/src/schemas/utilSchemas.ts b/types/src/schemas/utilSchemas.ts index 4f5f560a5..1fdd80f79 100644 --- a/types/src/schemas/utilSchemas.ts +++ b/types/src/schemas/utilSchemas.ts @@ -41,3 +41,34 @@ export const ChannelIconSchema = z.object({ duration: z.number(), position: z.string(), }); + +export const TimeUnitSchema = z.union([ + z.literal('second'), + z.literal('minute'), + z.literal('hour'), + z.literal('day'), + z.literal('week'), +]); + +export const CronScheduleSchema = z.object({ + type: z.literal('cron'), + cron: z.string(), +}); + +export const EveryScheduleSchema = z.object({ + type: z.literal('every'), + increment: z.number().positive(), + unit: TimeUnitSchema, + offsetMs: z + .number() + .min(0) + .max(1000 * 60 * 60 * 24 - 1) + .default(0), +}); + +export type EverySchedule = z.infer; + +export const ScheduleSchema = z.discriminatedUnion('type', [ + CronScheduleSchema, + EveryScheduleSchema, +]); diff --git a/web/src/generated/client.ts b/web/src/generated/client.ts deleted file mode 100644 index 0a6488cf5..000000000 --- a/web/src/generated/client.ts +++ /dev/null @@ -1,3538 +0,0 @@ -import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core'; -import { z } from 'zod'; - -const createChannelV2_Body = z.object({ - number: z.number(), - name: z.string(), - startTime: z.number(), - watermark: z - .object({ - url: z.string().optional(), - enabled: z.boolean(), - position: z.string(), - width: z.number(), - verticalMargin: z.number(), - horizontalMargin: z.number(), - duration: z.number(), - fixedSize: z.boolean(), - animated: z.boolean(), - }) - .optional(), - icon: z.object({ - path: z.string(), - width: z.number(), - duration: z.number(), - position: z.string(), - }), - guideMinimumDurationSeconds: z.number().optional().default(300), - groupTitle: z.string().optional().default('Tunarr'), - disableFillerOverlay: z.boolean().optional(), - offline: z - .object({ - picture: z.string().optional(), - soundtrack: z.string().optional(), - mode: z.union([z.literal('pic'), z.literal('clip')]), - }) - .optional() - .default({ mode: 'pic' }), - stealth: z.boolean().optional(), - guideFlexPlaceholder: z.string().optional(), - duration: z.number().optional(), -}); -const postApiv2channelsNumberprogramming_Body = z.array( - z.union([ - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z.array(z.object({ tag: z.string() })).optional(), - Country: z.array(z.object({ tag: z.string() })).optional(), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('custom'), - id: z.string(), - program: z - .object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z.array(z.object({ tag: z.string() })).optional(), - Country: z.array(z.object({ tag: z.string() })).optional(), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - }) - .optional(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('redirect'), - channel: z.number(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('flex'), - }), - ]), -); -const batchGetProgramsByExternalIds_Body = z.object({ - externalIds: z.array(z.string()), -}); - -export const schemas = { - createChannelV2_Body, - postApiv2channelsNumberprogramming_Body, - batchGetProgramsByExternalIds_Body, -}; - -const endpoints = makeApi([ - { - method: 'delete', - path: '/api/cache/images/', - alias: 'deleteApicacheimages', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'delete', - path: '/api/channel', - alias: 'deleteApichannel', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'post', - path: '/api/channel-tools/random-slots', - alias: 'postApichannelToolsrandomSlots', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'post', - path: '/api/channel-tools/time-slots', - alias: 'postApichannelToolstimeSlots', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/channel/:number', - alias: 'getApichannelNumber', - requestFormat: 'json', - parameters: [ - { - name: 'number', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/channel/description/:number', - alias: 'getApichanneldescriptionNumber', - requestFormat: 'json', - parameters: [ - { - name: 'number', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/channel/programless/:number', - alias: 'getApichannelprogramlessNumber', - requestFormat: 'json', - parameters: [ - { - name: 'number', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/channel/programs/:number', - alias: 'getApichannelprogramsNumber', - requestFormat: 'json', - parameters: [ - { - name: 'number', - type: 'Path', - schema: z.string(), - }, - ], - response: z.array( - z.object({ - channel: z.number().optional(), - customOrder: z.number().optional(), - customShowId: z.string().optional(), - customShowName: z.string().optional(), - date: z.string().optional(), - duration: z.number(), - episode: z.number().optional(), - episodeIcon: z.string().optional(), - file: z.string().optional(), - id: z.string(), - icon: z.string().optional(), - isOffline: z.boolean(), - key: z.string().optional(), - plexFile: z.string().optional(), - rating: z.string().optional(), - ratingKey: z.string().optional(), - season: z.number().optional(), - seasonIcon: z.string().optional(), - serverKey: z.string().optional(), - showIcon: z.string().optional(), - showTitle: z.string().optional(), - summary: z.string().optional(), - title: z.string().optional(), - type: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - z.literal('redirect'), - z.literal('custom'), - z.literal('flex'), - ]), - year: z.number().optional(), - }), - ), - }, - { - method: 'get', - path: '/api/channelNumbers', - alias: 'getApichannelNumbers', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/channels', - alias: 'getApichannels', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/channels.m3u', - alias: 'getApichannels_m3u', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/events', - alias: 'getApievents', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/ffmpeg-settings', - alias: 'getApiffmpegSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'put', - path: '/api/ffmpeg-settings', - alias: 'putApiffmpegSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'post', - path: '/api/ffmpeg-settings', - alias: 'postApiffmpegSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'put', - path: '/api/filler', - alias: 'putApifiller', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/filler/:id', - alias: 'getApifillerId', - requestFormat: 'json', - parameters: [ - { - name: 'id', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'post', - path: '/api/filler/:id', - alias: 'postApifillerId', - requestFormat: 'json', - parameters: [ - { - name: 'id', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'delete', - path: '/api/filler/:id', - alias: 'deleteApifillerId', - requestFormat: 'json', - parameters: [ - { - name: 'id', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/filler/:id/channels', - alias: 'getApifillerIdchannels', - requestFormat: 'json', - parameters: [ - { - name: 'id', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/fillers', - alias: 'getApifillers', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/guide/channels', - alias: 'getApiguidechannels', - requestFormat: 'json', - parameters: [ - { - name: 'dateFrom', - type: 'Query', - schema: z.string().datetime({ offset: true }), - }, - { - name: 'dateTo', - type: 'Query', - schema: z.string().datetime({ offset: true }), - }, - ], - response: z.record( - z.object({ - icon: z - .object({ - path: z.string(), - width: z.number(), - duration: z.number(), - position: z.string(), - }) - .optional(), - name: z.string().optional(), - number: z.number().optional(), - programs: z.array( - z.union([ - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z.array(z.object({ tag: z.string() })).optional(), - Country: z.array(z.object({ tag: z.string() })).optional(), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('custom'), - id: z.string(), - program: z - .object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z - .array(z.object({ tag: z.string() })) - .optional(), - Writer: z - .array(z.object({ tag: z.string() })) - .optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z - .array(z.object({ tag: z.string() })) - .optional(), - Country: z - .array(z.object({ tag: z.string() })) - .optional(), - Director: z - .array(z.object({ tag: z.string() })) - .optional(), - Writer: z - .array(z.object({ tag: z.string() })) - .optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - }) - .optional(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('redirect'), - channel: z.number(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('flex'), - }), - ]), - ), - }), - ), - }, - { - method: 'get', - path: '/api/guide/channels/:number', - alias: 'getApiguidechannelsNumber', - requestFormat: 'json', - parameters: [ - { - name: 'number', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/guide/debug', - alias: 'getApiguidedebug', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/guide/status', - alias: 'getApiguidestatus', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/hdhr-settings', - alias: 'getApihdhrSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'put', - path: '/api/hdhr-settings', - alias: 'putApihdhrSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'post', - path: '/api/hdhr-settings', - alias: 'postApihdhrSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/plex', - alias: 'getApiplex', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/plex-servers', - alias: 'getApiplexServers', - requestFormat: 'json', - response: z.unknown(), - errors: [ - { - status: 500, - description: `Default Response`, - schema: z.string(), - }, - ], - }, - { - method: 'delete', - path: '/api/plex-servers', - alias: 'deleteApiplexServers', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'put', - path: '/api/plex-servers', - alias: 'putApiplexServers', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'post', - path: '/api/plex-servers', - alias: 'postApiplexServers', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'post', - path: '/api/plex-servers/foreignstatus', - alias: 'postApiplexServersforeignstatus', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'post', - path: '/api/plex-servers/status', - alias: 'postApiplexServersstatus', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/plex-settings', - alias: 'getApiplexSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'put', - path: '/api/plex-settings', - alias: 'putApiplexSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'post', - path: '/api/plex-settings', - alias: 'postApiplexSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'put', - path: '/api/show', - alias: 'putApishow', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/show/:id', - alias: 'getApishowId', - requestFormat: 'json', - parameters: [ - { - name: 'id', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'post', - path: '/api/show/:id', - alias: 'postApishowId', - requestFormat: 'json', - parameters: [ - { - name: 'id', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'delete', - path: '/api/show/:id', - alias: 'deleteApishowId', - requestFormat: 'json', - parameters: [ - { - name: 'id', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/shows', - alias: 'getApishows', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'post', - path: '/api/upload/image', - alias: 'postApiuploadimage', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/v1/debug/helpers/create_guide', - alias: 'getApiv1debughelperscreate_guide', - requestFormat: 'json', - parameters: [ - { - name: 'channel', - type: 'Query', - schema: z.number(), - }, - { - name: 'live', - type: 'Query', - schema: z.boolean().optional(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/v1/debug/helpers/create_stream_lineup', - alias: 'getApiv1debughelperscreate_stream_lineup', - requestFormat: 'json', - parameters: [ - { - name: 'channel', - type: 'Query', - schema: z.number(), - }, - { - name: 'live', - type: 'Query', - schema: z.boolean().optional(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/v1/debug/helpers/current_program', - alias: 'getApiv1debughelperscurrent_program', - requestFormat: 'json', - parameters: [ - { - name: 'channel', - type: 'Query', - schema: z.number(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/v1/debug/helpers/random_filler', - alias: 'getApiv1debughelpersrandom_filler', - requestFormat: 'json', - parameters: [ - { - name: 'channel', - type: 'Query', - schema: z.number(), - }, - { - name: 'live', - type: 'Query', - schema: z.boolean().optional(), - }, - { - name: 'maxDuration', - type: 'Query', - schema: z.number(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/v1/debug/plex', - alias: 'getApiv1debugplex', - requestFormat: 'json', - parameters: [ - { - name: 'channel', - type: 'Query', - schema: z.number(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/v1/debug/plex-transcoder/video-stats', - alias: 'getApiv1debugplexTranscodervideoStats', - requestFormat: 'json', - parameters: [ - { - name: 'channel', - type: 'Query', - schema: z.number(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/v1/jobs', - alias: 'getApiv1jobs', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/v2/channels', - alias: 'getChannelsV2', - requestFormat: 'json', - response: z.array( - z.object({ - number: z.number(), - watermark: z - .object({ - url: z.string().optional(), - enabled: z.boolean(), - position: z.string(), - width: z.number(), - verticalMargin: z.number(), - horizontalMargin: z.number(), - duration: z.number(), - fixedSize: z.boolean(), - animated: z.boolean(), - }) - .optional(), - fillerCollections: z - .array( - z.object({ - id: z.string(), - weight: z.number(), - cooldownSeconds: z.number(), - }), - ) - .optional(), - programs: z.array( - z.object({ - channel: z.number().optional(), - customOrder: z.number().optional(), - customShowId: z.string().optional(), - customShowName: z.string().optional(), - date: z.string().optional(), - duration: z.number(), - episode: z.number().optional(), - episodeIcon: z.string().optional(), - file: z.string().optional(), - id: z.string(), - icon: z.string().optional(), - isOffline: z.boolean(), - key: z.string().optional(), - plexFile: z.string().optional(), - rating: z.string().optional(), - ratingKey: z.string().optional(), - season: z.number().optional(), - seasonIcon: z.string().optional(), - serverKey: z.string().optional(), - showIcon: z.string().optional(), - showTitle: z.string().optional(), - summary: z.string().optional(), - title: z.string().optional(), - type: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - z.literal('redirect'), - z.literal('custom'), - z.literal('flex'), - ]), - year: z.number().optional(), - }), - ), - icon: z.object({ - path: z.string(), - width: z.number(), - duration: z.number(), - position: z.string(), - }), - guideMinimumDurationSeconds: z.number(), - groupTitle: z.string(), - disableFillerOverlay: z.boolean(), - startTime: z.number(), - offline: z.object({ - picture: z.string().optional(), - soundtrack: z.string().optional(), - mode: z.union([z.literal('pic'), z.literal('clip')]), - }), - name: z.string(), - transcoding: z - .object({ - targetResolution: z.object({ - widthPx: z.number(), - heightPx: z.number(), - }), - videoBitrate: z.number().optional(), - videoBufferSize: z.number().optional(), - }) - .optional(), - duration: z.number(), - fallback: z - .array( - z.object({ - channel: z.number().optional(), - customOrder: z.number().optional(), - customShowId: z.string().optional(), - customShowName: z.string().optional(), - date: z.string().optional(), - duration: z.number(), - episode: z.number().optional(), - episodeIcon: z.string().optional(), - file: z.string().optional(), - id: z.string(), - icon: z.string().optional(), - isOffline: z.boolean(), - key: z.string().optional(), - plexFile: z.string().optional(), - rating: z.string().optional(), - ratingKey: z.string().optional(), - season: z.number().optional(), - seasonIcon: z.string().optional(), - serverKey: z.string().optional(), - showIcon: z.string().optional(), - showTitle: z.string().optional(), - summary: z.string().optional(), - title: z.string().optional(), - type: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - z.literal('redirect'), - z.literal('custom'), - z.literal('flex'), - ]), - year: z.number().optional(), - }), - ) - .optional(), - stealth: z.boolean(), - guideFlexPlaceholder: z.string().optional(), - fillerRepeatCooldown: z.number().optional(), - }), - ), - errors: [ - { - status: 500, - description: `Default Response`, - schema: z.literal('error'), - }, - ], - }, - { - method: 'post', - path: '/api/v2/channels', - alias: 'createChannelV2', - requestFormat: 'json', - parameters: [ - { - name: 'body', - type: 'Body', - schema: createChannelV2_Body, - }, - ], - response: z.object({ id: z.string() }), - errors: [ - { - status: 500, - description: `Default Response`, - schema: z.object({}).partial(), - }, - ], - }, - { - method: 'get', - path: '/api/v2/channels/:number', - alias: 'getChannelsByNumberV2', - requestFormat: 'json', - parameters: [ - { - name: 'number', - type: 'Path', - schema: z.number(), - }, - ], - response: z.object({ - number: z.number(), - watermark: z - .object({ - url: z.string().optional(), - enabled: z.boolean(), - position: z.string(), - width: z.number(), - verticalMargin: z.number(), - horizontalMargin: z.number(), - duration: z.number(), - fixedSize: z.boolean(), - animated: z.boolean(), - }) - .optional(), - fillerCollections: z - .array( - z.object({ - id: z.string(), - weight: z.number(), - cooldownSeconds: z.number(), - }), - ) - .optional(), - programs: z.array( - z.object({ - channel: z.number().optional(), - customOrder: z.number().optional(), - customShowId: z.string().optional(), - customShowName: z.string().optional(), - date: z.string().optional(), - duration: z.number(), - episode: z.number().optional(), - episodeIcon: z.string().optional(), - file: z.string().optional(), - id: z.string(), - icon: z.string().optional(), - isOffline: z.boolean(), - key: z.string().optional(), - plexFile: z.string().optional(), - rating: z.string().optional(), - ratingKey: z.string().optional(), - season: z.number().optional(), - seasonIcon: z.string().optional(), - serverKey: z.string().optional(), - showIcon: z.string().optional(), - showTitle: z.string().optional(), - summary: z.string().optional(), - title: z.string().optional(), - type: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - z.literal('redirect'), - z.literal('custom'), - z.literal('flex'), - ]), - year: z.number().optional(), - }), - ), - icon: z.object({ - path: z.string(), - width: z.number(), - duration: z.number(), - position: z.string(), - }), - guideMinimumDurationSeconds: z.number(), - groupTitle: z.string(), - disableFillerOverlay: z.boolean(), - startTime: z.number(), - offline: z.object({ - picture: z.string().optional(), - soundtrack: z.string().optional(), - mode: z.union([z.literal('pic'), z.literal('clip')]), - }), - name: z.string(), - transcoding: z - .object({ - targetResolution: z.object({ - widthPx: z.number(), - heightPx: z.number(), - }), - videoBitrate: z.number().optional(), - videoBufferSize: z.number().optional(), - }) - .optional(), - duration: z.number(), - fallback: z - .array( - z.object({ - channel: z.number().optional(), - customOrder: z.number().optional(), - customShowId: z.string().optional(), - customShowName: z.string().optional(), - date: z.string().optional(), - duration: z.number(), - episode: z.number().optional(), - episodeIcon: z.string().optional(), - file: z.string().optional(), - id: z.string(), - icon: z.string().optional(), - isOffline: z.boolean(), - key: z.string().optional(), - plexFile: z.string().optional(), - rating: z.string().optional(), - ratingKey: z.string().optional(), - season: z.number().optional(), - seasonIcon: z.string().optional(), - serverKey: z.string().optional(), - showIcon: z.string().optional(), - showTitle: z.string().optional(), - summary: z.string().optional(), - title: z.string().optional(), - type: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - z.literal('redirect'), - z.literal('custom'), - z.literal('flex'), - ]), - year: z.number().optional(), - }), - ) - .optional(), - stealth: z.boolean(), - guideFlexPlaceholder: z.string().optional(), - fillerRepeatCooldown: z.number().optional(), - }), - errors: [ - { - status: 404, - description: `Default Response`, - schema: z.unknown(), - }, - { - status: 500, - description: `Default Response`, - schema: z.unknown(), - }, - ], - }, - { - method: 'put', - path: '/api/v2/channels/:number', - alias: 'putApiv2channelsNumber', - requestFormat: 'json', - parameters: [ - { - name: 'body', - type: 'Body', - schema: createChannelV2_Body, - }, - { - name: 'number', - type: 'Path', - schema: z.number(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/api/v2/channels/:number/lineup', - alias: 'getApiv2channelsNumberlineup', - requestFormat: 'json', - parameters: [ - { - name: 'from', - type: 'Query', - schema: z.string().datetime({ offset: true }).optional(), - }, - { - name: 'to', - type: 'Query', - schema: z.string().datetime({ offset: true }).optional(), - }, - { - name: 'includePrograms', - type: 'Query', - schema: z.boolean().optional(), - }, - { - name: 'number', - type: 'Path', - schema: z.number(), - }, - ], - response: z.object({ - icon: z - .object({ - path: z.string(), - width: z.number(), - duration: z.number(), - position: z.string(), - }) - .optional(), - name: z.string().optional(), - number: z.number().optional(), - programs: z.array( - z.union([ - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z.array(z.object({ tag: z.string() })).optional(), - Country: z.array(z.object({ tag: z.string() })).optional(), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - start: z.number(), - stop: z.number(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('custom'), - id: z.string(), - program: z - .object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z - .array(z.object({ tag: z.string() })) - .optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z.array(z.object({ tag: z.string() })).optional(), - Country: z - .array(z.object({ tag: z.string() })) - .optional(), - Director: z - .array(z.object({ tag: z.string() })) - .optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - }) - .optional(), - start: z.number(), - stop: z.number(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('redirect'), - channel: z.number(), - start: z.number(), - stop: z.number(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('flex'), - start: z.number(), - stop: z.number(), - }), - ]), - ), - }), - errors: [ - { - status: 404, - description: `Default Response`, - schema: z.object({ error: z.string() }), - }, - ], - }, - { - method: 'get', - path: '/api/v2/channels/:number/programming', - alias: 'getApiv2channelsNumberprogramming', - requestFormat: 'json', - parameters: [ - { - name: 'number', - type: 'Path', - schema: z.number(), - }, - ], - response: z.object({ - icon: z - .object({ - path: z.string(), - width: z.number(), - duration: z.number(), - position: z.string(), - }) - .optional(), - name: z.string().optional(), - number: z.number().optional(), - programs: z.array( - z.union([ - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z.array(z.object({ tag: z.string() })).optional(), - Country: z.array(z.object({ tag: z.string() })).optional(), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('custom'), - id: z.string(), - program: z - .object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z - .array(z.object({ tag: z.string() })) - .optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z.array(z.object({ tag: z.string() })).optional(), - Country: z - .array(z.object({ tag: z.string() })) - .optional(), - Director: z - .array(z.object({ tag: z.string() })) - .optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - }) - .optional(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('redirect'), - channel: z.number(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('flex'), - }), - ]), - ), - }), - errors: [ - { - status: 404, - description: `Default Response`, - schema: z.object({ error: z.string() }), - }, - ], - }, - { - method: 'post', - path: '/api/v2/channels/:number/programming', - alias: 'postApiv2channelsNumberprogramming', - requestFormat: 'json', - parameters: [ - { - name: 'body', - type: 'Body', - schema: postApiv2channelsNumberprogramming_Body, - }, - { - name: 'number', - type: 'Path', - schema: z.number(), - }, - ], - response: z.object({ - icon: z - .object({ - path: z.string(), - width: z.number(), - duration: z.number(), - position: z.string(), - }) - .optional(), - name: z.string().optional(), - number: z.number().optional(), - programs: z.array( - z.union([ - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z.array(z.object({ tag: z.string() })).optional(), - Country: z.array(z.object({ tag: z.string() })).optional(), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('custom'), - id: z.string(), - program: z - .object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z - .array(z.object({ tag: z.string() })) - .optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z.array(z.object({ tag: z.string() })).optional(), - Country: z - .array(z.object({ tag: z.string() })) - .optional(), - Director: z - .array(z.object({ tag: z.string() })) - .optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - }) - .optional(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('redirect'), - channel: z.number(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('flex'), - }), - ]), - ), - }), - errors: [ - { - status: 404, - description: `Default Response`, - schema: z.unknown(), - }, - ], - }, - { - method: 'get', - path: '/api/v2/channels/:number/programs', - alias: 'getApiv2channelsNumberprograms', - requestFormat: 'json', - parameters: [ - { - name: 'number', - type: 'Path', - schema: z.number(), - }, - ], - response: z.unknown(), - errors: [ - { - status: 404, - description: `Default Response`, - schema: z.unknown(), - }, - ], - }, - { - method: 'get', - path: '/api/v2/channels/all/lineups', - alias: 'getApiv2channelsalllineups', - requestFormat: 'json', - parameters: [ - { - name: 'from', - type: 'Query', - schema: z.string().datetime({ offset: true }).optional(), - }, - { - name: 'to', - type: 'Query', - schema: z.string().datetime({ offset: true }).optional(), - }, - { - name: 'includePrograms', - type: 'Query', - schema: z.boolean().optional(), - }, - ], - response: z.array( - z.object({ - icon: z - .object({ - path: z.string(), - width: z.number(), - duration: z.number(), - position: z.string(), - }) - .optional(), - name: z.string().optional(), - number: z.number().optional(), - programs: z.array( - z.union([ - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z.array(z.object({ tag: z.string() })).optional(), - Country: z.array(z.object({ tag: z.string() })).optional(), - Director: z.array(z.object({ tag: z.string() })).optional(), - Writer: z.array(z.object({ tag: z.string() })).optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - start: z.number(), - stop: z.number(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('custom'), - id: z.string(), - program: z - .object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('content'), - subtype: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - ]), - id: z.string().optional(), - summary: z.string().optional(), - date: z.string().optional(), - rating: z.string().optional(), - title: z.string(), - episodeTitle: z.string().optional(), - seasonNumber: z.number().optional(), - episodeNumber: z.number().optional(), - originalProgram: z - .union([ - z.object({ - addedAt: z.number(), - art: z.string().optional(), - audienceRating: z.number().optional(), - audienceRatingImage: z.string().optional(), - chapterSource: z.string().optional(), - contentRating: z.string().optional(), - duration: z.number(), - grandparentArt: z.string().optional(), - grandparentGuid: z.string(), - grandparentKey: z.string(), - grandparentRatingKey: z.string(), - grandparentTheme: z.string().optional(), - grandparentThumb: z.string().optional(), - grandparentTitle: z.string(), - guid: z.string(), - index: z.number(), - key: z.string(), - originallyAvailableAt: z.string().optional(), - parentGuid: z.string(), - parentIndex: z.number(), - parentKey: z.string(), - parentRatingKey: z.string(), - parentThumb: z.string().optional(), - parentTitle: z.string(), - ratingKey: z.string(), - summary: z.string().optional(), - thumb: z.string(), - title: z.string(), - titleSort: z.string().optional(), - type: z.literal('episode'), - updatedAt: z.number(), - year: z.number().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Director: z - .array(z.object({ tag: z.string() })) - .optional(), - Writer: z - .array(z.object({ tag: z.string() })) - .optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - z.object({ - ratingKey: z.string(), - key: z.string(), - guid: z.string(), - editionTitle: z.string(), - studio: z.string().optional(), - type: z.literal('movie'), - title: z.string(), - titleSort: z.string(), - contentRating: z.string().optional(), - summary: z.string().optional(), - rating: z.number(), - audienceRating: z.number().optional(), - year: z.number().optional(), - tagline: z.string().optional(), - thumb: z.string(), - art: z.string().optional(), - duration: z.number(), - originallyAvailableAt: z.string(), - addedAt: z.number(), - updatedAt: z.number(), - audienceRatingImage: z.string(), - chapterSource: z.string(), - primaryExtraKey: z.string(), - ratingImage: z.string().optional(), - Media: z.array( - z.object({ - id: z.number(), - duration: z.number(), - bitrate: z.number(), - width: z.number(), - height: z.number(), - aspectRatio: z.number(), - audioChannels: z.number(), - audioCodec: z.string(), - videoCodec: z.string(), - videoResolution: z.string(), - container: z.string(), - videoFrameRate: z.string(), - audioProfile: z.string().optional(), - videoProfile: z.string().optional(), - Part: z.array( - z.object({ - id: z.number(), - key: z.string(), - duration: z.number(), - file: z.string(), - size: z.number(), - audioProfile: z.string().optional(), - container: z.string(), - videoProfile: z.string(), - }), - ), - }), - ), - Genre: z - .array(z.object({ tag: z.string() })) - .optional(), - Country: z - .array(z.object({ tag: z.string() })) - .optional(), - Director: z - .array(z.object({ tag: z.string() })) - .optional(), - Writer: z - .array(z.object({ tag: z.string() })) - .optional(), - Role: z.array(z.object({ tag: z.string() })).optional(), - directory: z.unknown().optional(), - }), - ]) - .optional(), - externalSourceType: z.literal('plex').optional(), - externalSourceName: z.string().optional(), - }) - .optional(), - start: z.number(), - stop: z.number(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('redirect'), - channel: z.number(), - start: z.number(), - stop: z.number(), - }), - z.object({ - persisted: z.boolean(), - duration: z.number(), - icon: z.string().optional(), - type: z.literal('flex'), - start: z.number(), - stop: z.number(), - }), - ]), - ), - }), - ), - }, - { - method: 'get', - path: '/api/v2/custom-shows', - alias: 'getApiv2customShows', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/v2/jobs', - alias: 'getApiv2jobs', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/v2/plex/status', - alias: 'getApiv2plexstatus', - requestFormat: 'json', - parameters: [ - { - name: 'serverName', - type: 'Query', - schema: z.string(), - }, - ], - response: z.object({ healthy: z.boolean() }), - errors: [ - { - status: 404, - description: `Default Response`, - schema: z.object({ message: z.string() }), - }, - { - status: 500, - description: `Default Response`, - schema: z.object({ message: z.string() }), - }, - ], - }, - { - method: 'get', - path: '/api/v2/programming/:externalId', - alias: 'getProgramByExternalId', - requestFormat: 'json', - parameters: [ - { - name: 'externalId', - type: 'Path', - schema: z.string(), - }, - ], - response: z.object({ - channel: z.number().optional(), - customOrder: z.number().optional(), - customShowId: z.string().optional(), - customShowName: z.string().optional(), - date: z.string().optional(), - duration: z.number(), - episode: z.number().optional(), - episodeIcon: z.string().optional(), - file: z.string().optional(), - id: z.string(), - icon: z.string().optional(), - isOffline: z.boolean(), - key: z.string().optional(), - plexFile: z.string().optional(), - rating: z.string().optional(), - ratingKey: z.string().optional(), - season: z.number().optional(), - seasonIcon: z.string().optional(), - serverKey: z.string().optional(), - showIcon: z.string().optional(), - showTitle: z.string().optional(), - summary: z.string().optional(), - title: z.string().optional(), - type: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - z.literal('redirect'), - z.literal('custom'), - z.literal('flex'), - ]), - year: z.number().optional(), - }), - errors: [ - { - status: 400, - description: `Default Response`, - schema: z.object({ message: z.string() }), - }, - { - status: 404, - description: `Default Response`, - schema: z.unknown(), - }, - ], - }, - { - method: 'post', - path: '/api/v2/programming/batch/lookup', - alias: 'batchGetProgramsByExternalIds', - requestFormat: 'json', - parameters: [ - { - name: 'body', - type: 'Body', - schema: batchGetProgramsByExternalIds_Body, - }, - ], - response: z.array( - z.object({ - channel: z.number().optional(), - customOrder: z.number().optional(), - customShowId: z.string().optional(), - customShowName: z.string().optional(), - date: z.string().optional(), - duration: z.number(), - episode: z.number().optional(), - episodeIcon: z.string().optional(), - file: z.string().optional(), - id: z.string(), - icon: z.string().optional(), - isOffline: z.boolean(), - key: z.string().optional(), - plexFile: z.string().optional(), - rating: z.string().optional(), - ratingKey: z.string().optional(), - season: z.number().optional(), - seasonIcon: z.string().optional(), - serverKey: z.string().optional(), - showIcon: z.string().optional(), - showTitle: z.string().optional(), - summary: z.string().optional(), - title: z.string().optional(), - type: z.union([ - z.literal('movie'), - z.literal('episode'), - z.literal('track'), - z.literal('redirect'), - z.literal('custom'), - z.literal('flex'), - ]), - year: z.number().optional(), - }), - ), - }, - { - method: 'get', - path: '/api/version', - alias: 'getApiversion', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/xmltv-last-refresh', - alias: 'getApixmltvLastRefresh', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/xmltv-settings', - alias: 'getApixmltvSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'put', - path: '/api/xmltv-settings', - alias: 'putApixmltvSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'post', - path: '/api/xmltv-settings', - alias: 'postApixmltvSettings', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/api/xmltv.xml', - alias: 'getApixmltv_xml', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'post', - path: '/api/xmltv/refresh', - alias: 'postApixmltvrefresh', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/cache/images/:hash', - alias: 'getCacheimagesHash', - requestFormat: 'json', - parameters: [ - { - name: 'hash', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/device.xml', - alias: 'getDevice_xml', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/discover.json', - alias: 'getDiscover_json', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/lineup_status.json', - alias: 'getLineup_status_json', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/lineup.json', - alias: 'getLineup_json', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/m3u8', - alias: 'getM3u8', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/media-player/:number.m3u', - alias: 'getMediaPlayerNumber_m3u', - requestFormat: 'json', - parameters: [ - { - name: 'number', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/media-player/fast/:number.m3u', - alias: 'getMediaPlayerfastNumber_m3u', - requestFormat: 'json', - parameters: [ - { - name: 'number', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/media-player/radio/:number.m3u', - alias: 'getMediaPlayerradioNumber_m3u', - requestFormat: 'json', - parameters: [ - { - name: 'number', - type: 'Path', - schema: z.string(), - }, - ], - response: z.void(), - }, - { - method: 'get', - path: '/playlist', - alias: 'getPlaylist', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/radio', - alias: 'getRadio', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/setup', - alias: 'getSetup', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/stream', - alias: 'getStream', - requestFormat: 'json', - response: z.void(), - }, - { - method: 'get', - path: '/video', - alias: 'getVideo', - requestFormat: 'json', - response: z.void(), - }, -]); - -export const api = new Zodios(endpoints); - -export function createApiClient(baseUrl: string, options?: ZodiosOptions) { - return new Zodios(baseUrl, endpoints, options); -} diff --git a/web/src/hooks/useSystemSettings.ts b/web/src/hooks/useSystemSettings.ts index eedea79b2..b8da92cfc 100644 --- a/web/src/hooks/useSystemSettings.ts +++ b/web/src/hooks/useSystemSettings.ts @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { UpdateSystemSettingsRequest } from '@tunarr/types/api'; import { useApiQuery } from './useApiQuery.ts'; import { useTunarrApi } from './useTunarrApi.ts'; -import { LogLevel } from '@tunarr/types'; export const useSystemSettings = () => useApiQuery({ @@ -11,16 +11,12 @@ export const useSystemSettings = () => queryKey: ['system', 'settings'], }); -type UpdateSystemSettingsArgs = { - logLevel?: LogLevel; -}; - export const useUpdateSystemSettings = () => { const apiClient = useTunarrApi(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: (payload: UpdateSystemSettingsArgs) => + mutationFn: (payload: UpdateSystemSettingsRequest) => apiClient.updateSystemSettings(payload), onSuccess: (response) => queryClient.setQueryData(['system', 'settings'], response), diff --git a/web/src/pages/settings/GeneralSettingsPage.tsx b/web/src/pages/settings/GeneralSettingsPage.tsx index 8fa8753da..89b5ccc7f 100644 --- a/web/src/pages/settings/GeneralSettingsPage.tsx +++ b/web/src/pages/settings/GeneralSettingsPage.tsx @@ -1,13 +1,16 @@ import { CloudDoneOutlined, CloudOff } from '@mui/icons-material'; import { Box, + Checkbox, Divider, FormControl, + FormControlLabel, FormHelperText, InputAdornment, InputLabel, MenuItem, Select, + SelectChangeEvent, Snackbar, TextField, Typography, @@ -15,9 +18,18 @@ import { import Button from '@mui/material/Button'; import Stack from '@mui/material/Stack'; import { LogLevel, LogLevels, SystemSettings } from '@tunarr/types'; -import { attempt, isEmpty, isError, map, trim, trimEnd } from 'lodash-es'; -import { useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; +import { + attempt, + first, + isEmpty, + isError, + isNull, + map, + trim, + trimEnd, +} from 'lodash-es'; +import { useCallback, useState } from 'react'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { RotatingLoopIcon } from '../../components/base/LoadingIcon.tsx'; import DarkModeButton from '../../components/settings/DarkModeButton.tsx'; import { @@ -28,10 +40,17 @@ import { useVersion } from '../../hooks/useVersion.ts'; import { setBackendUri } from '../../store/settings/actions.ts'; import { useSettings } from '../../store/settings/selectors.ts'; import { UpdateSystemSettingsRequest } from '@tunarr/types/api'; +import { BackupSettings, EverySchedule } from '@tunarr/types/schemas'; +import dayjs from 'dayjs'; +import { NumericFormControllerText } from '../../components/util/TypedController.tsx'; +import Grid2 from '@mui/material/Unstable_Grid2/Grid2'; +import pluralize from 'pluralize'; +import { TimePicker } from '@mui/x-date-pickers'; type GeneralSettingsFormData = { backendUri: string; logLevel: LogLevel | 'env'; + backup: BackupSettings; }; type GeneralSetingsFormProps = { @@ -72,6 +91,7 @@ function GeneralSettingsForm({ systemSettings }: GeneralSetingsFormProps) { logLevel: systemSettings.logging.useEnvVarLevel ? 'env' : systemSettings.logging.logLevel, + backup: systemSettings.backup, }); const { @@ -79,18 +99,34 @@ function GeneralSettingsForm({ systemSettings }: GeneralSetingsFormProps) { handleSubmit, reset, formState: { isDirty, isValid, isSubmitting }, + watch, + setValue, } = useForm({ - reValidateMode: 'onBlur', + reValidateMode: 'onChange', defaultValues: getBaseFormValues(systemSettings), }); + const { remove, append } = useFieldArray({ + control, + name: 'backup.configurations', + }); + + const backupsValue = watch('backup'); + const backupsEnabled = backupsValue.configurations.length > 0; + const currentBackupSchedule = first(backupsValue.configurations)?.schedule as + | EverySchedule + | undefined; + const onSave = (data: GeneralSettingsFormData) => { const newBackendUri = trimEnd(trim(data.backendUri), '/'); setBackendUri(newBackendUri); setSnackStatus(true); const updateReq: UpdateSystemSettingsRequest = { - logLevel: data.logLevel === 'env' ? undefined : data.logLevel, - useEnvVarLevel: data.logLevel === 'env', + logging: { + logLevel: data.logLevel === 'env' ? undefined : data.logLevel, + useEnvVarLevel: data.logLevel === 'env', + }, + backup: data.backup, }; updateSystemSettings.mutate(updateReq, { onSuccess(data) { @@ -99,6 +135,181 @@ function GeneralSettingsForm({ systemSettings }: GeneralSetingsFormProps) { }); }; + const toggleBackupEnabled = useCallback(() => { + if (backupsEnabled) { + remove(); + } else { + append({ + enabled: true, + outputs: [ + { + type: 'file', + archiveFormat: 'tar', + maxBackups: 3, + outputPath: '', + }, + ], + schedule: { + type: 'every', + increment: 1, + unit: 'day', + offsetMs: dayjs.duration({ hours: 3 }).asMilliseconds(), + }, + }); + } + }, [append, backupsEnabled, remove]); + + function handleArchiveFormatUpdate(ev: SelectChangeEvent) { + if (ev.target.value === 'zip' || ev.target.value === 'tar') { + setValue( + 'backup.configurations.0.outputs.0.archiveFormat', + ev.target.value, + { shouldDirty: true }, + ); + setValue('backup.configurations.0.outputs.0.gzip', false, { + shouldDirty: true, + }); + } else if (ev.target.value === 'targz') { + setValue('backup.configurations.0.outputs.0.archiveFormat', 'tar', { + shouldDirty: true, + }); + setValue('backup.configurations.0.outputs.0.gzip', true, { + shouldDirty: true, + }); + } + } + + function renderBackupsForm() { + return ( + + + + + } + label="Enable Backups" + /> + + + {backupsEnabled && ( + <> + + ( + + )} + /> + + + + + + + Archive Format + ( + + )} + /> + + + + + Every + + ( + + )} + /> + + {currentBackupSchedule!.unit === 'day' && ( + + setValue( + 'backup.configurations.0.schedule.offsetMs', + isNull(value) + ? 0 + : value + .mod(dayjs.duration(1, 'day')) + .asMilliseconds(), + { shouldDirty: true }, + ) + } + /> + )} + + + + )} + + ); + } + return ( {map(LogLevelChoices, ({ value, description }) => ( - {description} + + {description} + ))} )} @@ -172,6 +385,12 @@ function GeneralSettingsForm({ systemSettings }: GeneralSetingsFormProps) { + + + Backups + + {renderBackupsForm()} + {isDirty && (