From f52df44ef0f0fa74ef4710f99b3b79b0b470a7e9 Mon Sep 17 00:00:00 2001 From: Christian Benincasa Date: Thu, 22 Aug 2024 07:41:33 -0400 Subject: [PATCH] feat!: add support for Jellyfin media (#633) This commit includes a huge amount of changes, including support for adding Jellyfin servers as media sources and streaming content from them. These are breaking changes and touch almost every corner of the code, but also pave the way for a lot more flexibility on the backend for addinng different sources. The commit also includes performance improvements to the inline modal, lots of code cleanup, and a few bug fixes I found along the way. Fixes #24 --- CONTRIBUTING.md | 7 + pnpm-lock.yaml | 330 +++++- server/mikro-orm.base.config.ts | 14 +- server/package.json | 10 +- server/src/api/debug/debugJellyfinApi.ts | 65 ++ server/src/api/debugApi.ts | 144 ++- server/src/api/index.ts | 50 +- server/src/api/jellyfinApi.ts | 149 +++ .../{plexServersApi.ts => mediaSourceApi.ts} | 173 +-- server/src/api/metadataApi.ts | 33 +- server/src/api/programmingApi.ts | 206 +++- .../dao/LegacyProgramGroupingCalculator.ts | 622 ----------- server/src/dao/ProgramGroupingCalculator.ts | 321 +++++- .../dao/custom_types/ProgramExternalIdType.ts | 40 + .../src/dao/custom_types/ProgramSourceType.ts | 20 + server/src/dao/derived_types/StreamLineup.ts | 3 + server/src/dao/direct/programQueryHelpers.ts | 2 +- server/src/dao/entities/MediaSource.ts | 60 ++ server/src/dao/entities/PlexServerSettings.ts | 42 - server/src/dao/entities/Program.ts | 15 + server/src/dao/entities/ProgramExternalId.ts | 11 +- server/src/dao/entities/ProgramGrouping.ts | 18 + server/src/dao/{fillerDb.ts => fillerDB.ts} | 0 .../dao/legacy_migration/legacyDbMigration.ts | 10 +- .../dao/legacy_migration/metadataBackfill.ts | 31 +- .../dao/{plexServerDb.ts => mediaSourceDB.ts} | 138 ++- server/src/dao/programHelpers.ts | 453 ++++++-- server/src/external/BaseApiClient.ts | 216 ++++ server/src/external/MediaSourceApiFactory.ts | 144 +++ server/src/external/PlexApiFactory.ts | 79 -- .../external/jellyfin/JellyfinApiClient.ts | 191 ++++ server/src/external/plex.ts | 595 ----------- server/src/external/plex/PlexApiClient.ts | 310 ++++++ server/src/external/plex/PlexQueryCache.ts | 56 + server/src/ffmpeg/ffmpeg.ts | 11 +- server/src/migrations/.snapshot-db.db.json | 571 +++++++--- .../src/migrations/Migration20240719145409.ts | 23 + .../src/migrations/Migration20240805185042.ts | 89 ++ server/src/serverContext.ts | 8 +- server/src/services/PlexItemEnumerator.ts | 8 +- .../PlexContentSourceUpdater.ts | 14 +- server/src/stream/ProgramPlayer.ts | 29 +- server/src/stream/StreamProgramCalculator.ts | 94 +- server/src/stream/VideoStream.ts | 136 ++- server/src/stream/jellyfin/JellyfinPlayer.ts | 191 ++++ .../stream/jellyfin/JellyfinStreamDetails.ts | 255 +++++ server/src/stream/plex/PlexPlayer.ts | 14 +- server/src/stream/plex/PlexStreamDetails.ts | 26 +- server/src/stream/plex/PlexTranscoder.ts | 775 -------------- server/src/stream/types.ts | 32 + .../tasks/ReconcileProgramDurationsTask.ts | 2 +- server/src/tasks/TaskQueue.ts | 6 + server/src/tasks/UpdateXmlTvTask.ts | 8 +- .../fixers/BackfillProgramExternalIds.ts | 17 +- server/src/tasks/fixers/addPlexServerIds.ts | 13 +- .../tasks/fixers/backfillProgramGroupings.ts | 18 +- .../tasks/fixers/missingSeasonNumbersFixer.ts | 22 +- .../SaveJellyfinProgramExternalIdsTask.ts | 99 ++ .../SaveJellyfinProgramGroupingsTask.ts | 41 + .../SavePlexProgramExternalIdsTask.ts | 29 +- .../plex/SavePlexProgramGroupingsTask.ts | 7 +- .../{ => plex}/UpdatePlexPlayStatusTask.ts | 20 +- server/src/types/serverType.ts | 29 + server/src/types/util.ts | 2 + server/src/util/ProgramMinter.ts | 225 +++- server/src/util/axios.ts | 48 + server/src/util/index.ts | 5 + shared/src/index.ts | 16 +- shared/src/util/index.ts | 8 + types/package.json | 4 + ...PlexSettings.ts => MediaSourceSettings.ts} | 8 + types/src/api/index.ts | 57 +- types/src/index.ts | 2 +- types/src/jellyfin/index.ts | 996 ++++++++++++++++++ types/src/plex/index.ts | 14 +- types/src/schemas/programmingSchema.ts | 33 +- types/src/schemas/settingsSchemas.ts | 31 +- types/src/schemas/utilSchemas.ts | 8 +- types/src/util.ts | 13 + types/tsup.config.ts | 1 + web/package.json | 1 + web/src/assets/jellyfin.svg | 1 + web/src/components/GridContainerTabPanel.tsx | 49 + web/src/components/InlineModal.tsx | 216 ++-- web/src/components/ProgramDetailsDialog.tsx | 180 +++- web/src/components/TabPanel.tsx | 63 +- .../channel_config/AddSelectedMediaButton.tsx | 39 +- .../channel_config/ChannelProgrammingList.tsx | 4 +- .../channel_config/JellyfinGridItem.tsx | 168 +++ .../channel_config/JellyfinListItem.tsx | 172 +++ .../JellyfinProgrammingSelector.tsx | 205 ++++ .../channel_config/MediaGridItem.tsx | 249 +++++ .../channel_config/MediaItemGrid.tsx | 296 ++++++ .../channel_config/PlexDirectoryListItem.tsx | 26 +- .../channel_config/PlexGridItem.tsx | 363 +++---- .../channel_config/PlexListItem.tsx | 17 +- .../PlexProgrammingSelector.tsx | 588 +++-------- .../channel_config/PlexSortField.tsx | 14 +- .../channel_config/ProgrammingSelector.tsx | 238 ++++- .../SelectedProgrammingActions.tsx | 16 +- .../SelectedProgrammingList.tsx | 96 +- .../custom-shows/EditCustomShowForm.tsx | 22 +- .../components/filler/EditFillerListForm.tsx | 23 +- web/src/components/settings/AddPlexServer.tsx | 13 +- web/src/components/settings/ConnectPlex.tsx | 4 +- .../JelllyfinServerEditDialog.tsx | 536 ++++++++++ .../MediaSourceDeleteDialog.tsx} | 16 +- .../MediaSourceTableRow.tsx} | 46 +- .../PlexServerEditDialog.tsx | 25 +- web/src/external/api.ts | 46 +- web/src/external/jellyfinApi.ts | 38 + web/src/external/settingsApi.ts | 83 +- web/src/helpers/inlineModalUtil.ts | 49 +- web/src/helpers/plexLogin.ts | 3 +- web/src/helpers/plexUtil.ts | 5 +- web/src/helpers/util.ts | 36 + web/src/hooks/jellyfin/jellyfinHookUtil.ts | 85 ++ web/src/hooks/jellyfin/useJellyfinApi.ts | 113 ++ .../jellyfin/useJellyfinBackendStatus.ts | 51 + web/src/hooks/jellyfin/useJellyfinLogin.ts | 32 + .../useMediaSourceBackendStatus.ts} | 28 +- web/src/hooks/plex/plexHookUtil.ts | 19 +- web/src/hooks/plex/usePlex.ts | 29 +- web/src/hooks/plex/usePlexCollections.ts | 4 +- web/src/hooks/plex/usePlexFilters.ts | 37 +- web/src/hooks/plex/usePlexPlaylists.ts | 13 +- web/src/hooks/plex/usePlexSearch.ts | 7 +- web/src/hooks/plex/usePlexServerStatus.ts | 9 +- web/src/hooks/plex/usePlexTags.ts | 6 +- web/src/hooks/settingsHooks.ts | 10 +- web/src/hooks/useDebouncedState.ts | 10 +- .../channels/ProgrammingSelectorPage.tsx | 9 +- ...gsPage.tsx => MediaSourceSettingsPage.tsx} | 238 +++-- web/src/pages/settings/SettingsLayout.tsx | 6 +- web/src/pages/welcome/WelcomePage.tsx | 5 +- web/src/routeTree.gen.ts | 24 +- .../channels_/$channelId/programming/add.tsx | 19 +- .../routes/settings/{plex.tsx => sources.tsx} | 6 +- web/src/store/channelEditor/actions.ts | 63 +- web/src/store/customShowEditor/actions.ts | 6 +- web/src/store/fillerListEditor/action.ts | 6 +- .../store/programmingSelector/KnownMedia.ts | 57 + web/src/store/programmingSelector/actions.ts | 167 ++- .../store/programmingSelector/selectors.ts | 90 ++ web/src/store/programmingSelector/store.ts | 67 +- web/src/types/index.ts | 13 +- web/vite.config.ts | 2 + 147 files changed, 9504 insertions(+), 4289 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 server/src/api/debug/debugJellyfinApi.ts create mode 100644 server/src/api/jellyfinApi.ts rename server/src/api/{plexServersApi.ts => mediaSourceApi.ts} (61%) delete mode 100644 server/src/dao/LegacyProgramGroupingCalculator.ts create mode 100644 server/src/dao/entities/MediaSource.ts delete mode 100644 server/src/dao/entities/PlexServerSettings.ts rename server/src/dao/{fillerDb.ts => fillerDB.ts} (100%) rename server/src/dao/{plexServerDb.ts => mediaSourceDB.ts} (60%) create mode 100644 server/src/external/BaseApiClient.ts create mode 100644 server/src/external/MediaSourceApiFactory.ts delete mode 100644 server/src/external/PlexApiFactory.ts create mode 100644 server/src/external/jellyfin/JellyfinApiClient.ts delete mode 100644 server/src/external/plex.ts create mode 100644 server/src/external/plex/PlexApiClient.ts create mode 100644 server/src/external/plex/PlexQueryCache.ts create mode 100644 server/src/migrations/Migration20240719145409.ts create mode 100644 server/src/migrations/Migration20240805185042.ts create mode 100644 server/src/stream/jellyfin/JellyfinPlayer.ts create mode 100644 server/src/stream/jellyfin/JellyfinStreamDetails.ts delete mode 100644 server/src/stream/plex/PlexTranscoder.ts create mode 100644 server/src/tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.ts create mode 100644 server/src/tasks/jellyfin/SaveJellyfinProgramGroupingsTask.ts rename server/src/tasks/{ => plex}/SavePlexProgramExternalIdsTask.ts (65%) rename server/src/tasks/{ => plex}/UpdatePlexPlayStatusTask.ts (85%) create mode 100644 server/src/util/axios.ts rename types/src/{PlexSettings.ts => MediaSourceSettings.ts} (60%) create mode 100644 types/src/jellyfin/index.ts create mode 100644 web/src/assets/jellyfin.svg create mode 100644 web/src/components/GridContainerTabPanel.tsx create mode 100644 web/src/components/channel_config/JellyfinGridItem.tsx create mode 100644 web/src/components/channel_config/JellyfinListItem.tsx create mode 100644 web/src/components/channel_config/JellyfinProgrammingSelector.tsx create mode 100644 web/src/components/channel_config/MediaGridItem.tsx create mode 100644 web/src/components/channel_config/MediaItemGrid.tsx create mode 100644 web/src/components/settings/media_source/JelllyfinServerEditDialog.tsx rename web/src/components/settings/{plex/PlexServerDeleteDialog.tsx => media_source/MediaSourceDeleteDialog.tsx} (73%) rename web/src/components/settings/{plex/PlexServerRow.tsx => media_source/MediaSourceTableRow.tsx} (64%) rename web/src/components/settings/{plex => media_source}/PlexServerEditDialog.tsx (94%) create mode 100644 web/src/external/jellyfinApi.ts create mode 100644 web/src/hooks/jellyfin/jellyfinHookUtil.ts create mode 100644 web/src/hooks/jellyfin/useJellyfinApi.ts create mode 100644 web/src/hooks/jellyfin/useJellyfinBackendStatus.ts create mode 100644 web/src/hooks/jellyfin/useJellyfinLogin.ts rename web/src/hooks/{plex/usePlexBackendStatus.ts => media-sources/useMediaSourceBackendStatus.ts} (57%) rename web/src/pages/settings/{PlexSettingsPage.tsx => MediaSourceSettingsPage.tsx} (68%) rename web/src/routes/settings/{plex.tsx => sources.tsx} (61%) create mode 100644 web/src/store/programmingSelector/KnownMedia.ts create mode 100644 web/src/store/programmingSelector/selectors.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d76f3391 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing to Tunarr + +## Setting up the dev environment + +## Coding Standard + +## diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cd81c54..1ca16dfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,14 +100,14 @@ importers: specifier: ^1.0.1 version: 1.0.1 '@mikro-orm/better-sqlite': - specifier: ^6.2.0 - version: 6.2.0(@mikro-orm/core@6.2.0) + specifier: ^6.3.3 + version: 6.3.3(@mikro-orm/core@6.3.3) '@mikro-orm/core': - specifier: ^6.2.0 - version: 6.2.0 + specifier: ^6.3.3 + version: 6.3.3 '@mikro-orm/migrations': - specifier: 6.2.0 - version: 6.2.0(@mikro-orm/core@6.2.0)(@types/node@20.11.1)(better-sqlite3@9.4.5) + specifier: 6.3.3 + version: 6.3.3(@mikro-orm/core@6.3.3)(@types/node@20.11.1)(better-sqlite3@9.4.5) '@tunarr/shared': specifier: workspace:* version: link:../shared @@ -209,11 +209,11 @@ importers: version: 3.22.4 devDependencies: '@mikro-orm/cli': - specifier: ^6.2.0 - version: 6.2.0(better-sqlite3@9.4.5) + specifier: ^6.3.3 + version: 6.3.3(better-sqlite3@9.4.5) '@mikro-orm/reflection': - specifier: ^6.2.0 - version: 6.2.0(@mikro-orm/core@6.2.0) + specifier: ^6.3.3 + version: 6.3.3(@mikro-orm/core@6.3.3) '@types/archiver': specifier: ^6.0.2 version: 6.0.2 @@ -601,6 +601,9 @@ importers: vite: specifier: ^5.4.1 version: 5.4.1(@types/node@20.11.1) + vite-plugin-svgr: + specifier: ^4.2.0 + version: 4.2.0(typescript@5.4.3)(vite@5.4.1) vitest: specifier: ^2.0.5 version: 2.0.5(@types/node@20.11.1) @@ -2571,18 +2574,20 @@ packages: resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==} dev: true - /@mikro-orm/better-sqlite@6.2.0(@mikro-orm/core@6.2.0): - resolution: {integrity: sha512-ixSZ9QRQJo408YSZFmnTMEm3jsmQRRDHEcq6lnz2YwYC/RJh8t5XObhHP4dvV7vdP9l9Y9M+AJ2UDZrRoRz24g==} + /@mikro-orm/better-sqlite@6.3.3(@mikro-orm/core@6.3.3): + resolution: {integrity: sha512-W0wyijGR8o9DvT+vVOOVH67OA94d8PT7goA4JF3Ty7Pcw2RkWUFi6/8kbKiGEI3SWU64IA5VHdFsx21vNEyi5g==} engines: {node: '>= 18.12.0'} peerDependencies: '@mikro-orm/core': ^6.0.0 dependencies: - '@mikro-orm/core': 6.2.0 - '@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5) + '@mikro-orm/core': 6.3.3 + '@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5) better-sqlite3: 9.4.5 fs-extra: 11.2.0 sqlstring-sqlite: 0.1.1 transitivePeerDependencies: + - libsql + - mariadb - mysql - mysql2 - pg @@ -2592,19 +2597,21 @@ packages: - tedious dev: false - /@mikro-orm/cli@6.2.0(better-sqlite3@9.4.5): - resolution: {integrity: sha512-R3JwXOdCT0YOMjoPckxW45izIYhA/9+WvFNXlzA5p58lUzQ3eGgITaPPTuZkf0/j7t1uwGM5fq7UD2tpGorz8Q==} + /@mikro-orm/cli@6.3.3(better-sqlite3@9.4.5): + resolution: {integrity: sha512-TbGuPDBt1V98c1g1hwjg+FizqdaGQ0kCAn8CccfO0nfvVYs0lBaLtRgt3w5jDot7eli7bfQ+DxfZsXRFUnmMyA==} engines: {node: '>= 18.12.0'} hasBin: true dependencies: '@jercle/yargonaut': 1.1.5 - '@mikro-orm/core': 6.2.0 - '@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5) + '@mikro-orm/core': 6.3.3 + '@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5) fs-extra: 11.2.0 tsconfig-paths: 4.2.0 yargs: 17.7.2 transitivePeerDependencies: - better-sqlite3 + - libsql + - mariadb - mysql - mysql2 - pg @@ -2614,8 +2621,8 @@ packages: - tedious dev: true - /@mikro-orm/core@6.2.0: - resolution: {integrity: sha512-Kqsb3S+ab1dbo2joVFLQtdCPTyqSXqiRUnVoMOgAV5uS35nC15SUwDpQMivjLi+8Auz+vcjlbJNK5PrsV+lROQ==} + /@mikro-orm/core@6.3.3: + resolution: {integrity: sha512-P4kqaRIKDmgmtfn1RfUYPCdjkWRsHKEo41gi5G81Ia5Jb7toQ6O3T6GOeBckGt/rZfUNMGDPMeyYqAR93NVV5w==} engines: {node: '>= 18.12.0'} dependencies: dataloader: 2.2.2 @@ -2623,21 +2630,31 @@ packages: esprima: 4.0.1 fs-extra: 11.2.0 globby: 11.1.0 - mikro-orm: 6.2.0 + mikro-orm: 6.3.3 reflect-metadata: 0.2.2 - /@mikro-orm/knex@6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5): - resolution: {integrity: sha512-jo/KKtIkwqCrfBmU0TLe4Y6kCQIbyio8PcvLtphBiMoLca6Utd8cEednsLYW5OdNyzTF3Vw/XHkm7T68MiT/cQ==} + /@mikro-orm/knex@6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5): + resolution: {integrity: sha512-eG3SW4GFQKA7SVj5VCkQQyxlQMC/ZUKHWhFwSDgbyF0sYTAV/78Ir0XX1m69nxK1zD+KJXA+4nf0g5HAoWQxUQ==} engines: {node: '>= 18.12.0'} peerDependencies: '@mikro-orm/core': ^6.0.0 + better-sqlite3: 9.4.5 + libsql: '*' + mariadb: '*' + peerDependenciesMeta: + better-sqlite3: + optional: true + libsql: + optional: true + mariadb: + optional: true dependencies: - '@mikro-orm/core': 6.2.0 + '@mikro-orm/core': 6.3.3 + better-sqlite3: 9.4.5 fs-extra: 11.2.0 knex: 3.1.0(better-sqlite3@9.4.5) sqlstring: 2.3.3 transitivePeerDependencies: - - better-sqlite3 - mysql - mysql2 - pg @@ -2646,19 +2663,21 @@ packages: - supports-color - tedious - /@mikro-orm/migrations@6.2.0(@mikro-orm/core@6.2.0)(@types/node@20.11.1)(better-sqlite3@9.4.5): - resolution: {integrity: sha512-9Nl46QdHBto0fWXdpdfF8rqqG416uc7csA+wi0H+qvY3m4cjiCWYQcsRcmI7FkMbpJq/USkmcDS5yUSU8Sic9Q==} + /@mikro-orm/migrations@6.3.3(@mikro-orm/core@6.3.3)(@types/node@20.11.1)(better-sqlite3@9.4.5): + resolution: {integrity: sha512-wdUG1+zrofNetlsxS+qJfM+hvDuuqblElt/BgZEkPWy41B/IxPRKU+hiSUqznjD69BrqNoLrj7sIdacZjFjIBg==} engines: {node: '>= 18.12.0'} peerDependencies: '@mikro-orm/core': ^6.0.0 dependencies: - '@mikro-orm/core': 6.2.0 - '@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5) + '@mikro-orm/core': 6.3.3 + '@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5) fs-extra: 11.2.0 - umzug: 3.8.0(@types/node@20.11.1) + umzug: 3.8.1(@types/node@20.11.1) transitivePeerDependencies: - '@types/node' - better-sqlite3 + - libsql + - mariadb - mysql - mysql2 - pg @@ -2668,15 +2687,15 @@ packages: - tedious dev: false - /@mikro-orm/reflection@6.2.0(@mikro-orm/core@6.2.0): - resolution: {integrity: sha512-KkepzpY/u/67wfR59990EQvvDNF2HoWeI3vusS7xEHfHLa/hzwjGcj5SG7MRhdYBvjG626FlsXaYFCIwBB9S+g==} + /@mikro-orm/reflection@6.3.3(@mikro-orm/core@6.3.3): + resolution: {integrity: sha512-tNwKb1EDco7Wb1BuVrUkGPRNnGNfeJPWe/y9aFmf/77qORPonzXTQQLcW4qzq0nLsoId5VXe4G9mmlhWbP3dEQ==} engines: {node: '>= 18.12.0'} peerDependencies: '@mikro-orm/core': ^6.0.0 dependencies: - '@mikro-orm/core': 6.2.0 + '@mikro-orm/core': 6.3.3 globby: 11.1.0 - ts-morph: 22.0.0 + ts-morph: 23.0.0 dev: true /@mui/base@5.0.0-beta.23(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0): @@ -2983,6 +3002,20 @@ packages: resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==} dev: false + /@rollup/pluginutils@5.1.0: + resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.5 + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + /@rollup/rollup-android-arm-eabi@4.20.0: resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==} cpu: [arm] @@ -3274,6 +3307,132 @@ packages: engines: {node: '>=14.16'} dev: true + /@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.24.7): + resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-preset@8.1.0(@babel/core@7.24.7): + resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.24.7) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.24.7) + dev: true + + /@svgr/core@8.1.0(typescript@5.4.3): + resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} + engines: {node: '>=14'} + dependencies: + '@babel/core': 7.24.7 + '@svgr/babel-preset': 8.1.0(@babel/core@7.24.7) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@5.4.3) + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@svgr/hast-util-to-babel-ast@8.0.0: + resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==} + engines: {node: '>=14'} + dependencies: + '@babel/types': 7.24.7 + entities: 4.5.0 + dev: true + + /@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0): + resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + dependencies: + '@babel/core': 7.24.7 + '@svgr/babel-preset': 8.1.0(@babel/core@7.24.7) + '@svgr/core': 8.1.0(typescript@5.4.3) + '@svgr/hast-util-to-babel-ast': 8.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + dev: true + /@swc/core-darwin-arm64@1.3.96: resolution: {integrity: sha512-8hzgXYVd85hfPh6mJ9yrG26rhgzCmcLO0h1TIl8U31hwmTbfZLzRitFQ/kqMJNbIBCwmNH1RU2QcJnL3d7f69A==} engines: {node: '>=10'} @@ -3562,11 +3721,11 @@ packages: resolution: {integrity: sha512-vd2A2TnM5lbnWZnHi9B+L2gPtkSeOtJOAw358JqokIH1+v2J7vUAzFVPwB/wrye12RFOurffXu33plm4uQ+JBQ==} dev: false - /@ts-morph/common@0.23.0: - resolution: {integrity: sha512-m7Lllj9n/S6sOkCkRftpM7L24uvmfXQFedlW/4hENcuJH1HHm9u5EgxZb9uVjQSCGrbBWBkOGgcTxNg36r6ywA==} + /@ts-morph/common@0.24.0: + resolution: {integrity: sha512-c1xMmNHWpNselmpIqursHeOHHBTIsJLbB+NuovbTTRCNiTLEr/U9dbJ8qy0jd/O2x5pc3seWuOUN5R2IoOTp8A==} dependencies: fast-glob: 3.3.2 - minimatch: 9.0.3 + minimatch: 9.0.5 mkdirp: 3.0.1 path-browserify: 1.0.1 dev: true @@ -5434,6 +5593,11 @@ packages: engines: {node: '>=6'} dev: true + /camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + dev: true + /caniuse-lite@1.0.30001570: resolution: {integrity: sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw==} dev: true @@ -5864,6 +6028,22 @@ packages: yaml: 1.10.2 dev: false + /cosmiconfig@8.3.6(typescript@5.4.3): + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + typescript: 5.4.3 + dev: true + /cosmiconfig@9.0.0(typescript@5.4.3): resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -6338,6 +6518,13 @@ packages: engines: {node: '>=0.4', npm: '>=1.2'} dev: true + /dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + dev: true + /dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -6470,6 +6657,11 @@ packages: strip-ansi: 6.0.1 dev: true + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: true + /env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -6889,6 +7081,10 @@ packages: engines: {node: '>=4.0'} dev: true + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + /estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} dependencies: @@ -8920,6 +9116,12 @@ packages: steno: 4.0.2 dev: false + /lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + dependencies: + tslib: 2.6.2 + dev: true + /lowercase-keys@1.0.0: resolution: {integrity: sha512-RPlX0+PHuvxVDZ7xX+EBVAp4RsVxP/TdDSN2mJYdiq1Lc4Hz7EUSjUI7RZrKKlmrIzVhf6Jo2stj7++gVarS0A==} engines: {node: '>=0.10.0'} @@ -9089,8 +9291,8 @@ packages: braces: 3.0.2 picomatch: 2.3.1 - /mikro-orm@6.2.0: - resolution: {integrity: sha512-/cjVKrpjtIG1bUm5BvumO6DRo0lkH52C4Waw5qzUqKrg8wl/rigXKYLoLVBPPi7bB0XNwyQbxeh5bRcWJD+y4Q==} + /mikro-orm@6.3.3: + resolution: {integrity: sha512-Hpm/LdpU8c0jNSJNnp6hJ+zwB13Db/fDCni95SJvuR3H4tFtixTrvxPRtz8iIWdLHXt/7kVdbBZdOLKj249r5A==} engines: {node: '>= 18.12.0'} /miller-rabin@4.0.1: @@ -9178,6 +9380,13 @@ packages: dependencies: brace-expansion: 2.0.1 + /minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + 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==} engines: {node: '>= 4'} @@ -9342,6 +9551,13 @@ packages: - webpack-dev-server dev: true + /no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + dependencies: + lower-case: 2.0.2 + tslib: 2.6.2 + dev: true + /node-abi@3.51.0: resolution: {integrity: sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA==} engines: {node: '>=10'} @@ -11149,6 +11365,13 @@ packages: is-fullwidth-code-point: 5.0.0 dev: true + /snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.6.2 + dev: true + /sonic-boom@3.7.0: resolution: {integrity: sha512-IudtNvSqA/ObjN97tfgNmOKyDOs4dNcg4cUUsHDebqsgb8wGBBwb31LIgShNO8fye0dFI52X1+tFoKKI6Rq1Gg==} dependencies: @@ -11591,6 +11814,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + /svg-parser@2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + dev: true + /syntax-error@1.4.0: resolution: {integrity: sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==} dependencies: @@ -11960,10 +12187,10 @@ packages: webpack: 5.91.0(esbuild@0.21.5)(webpack-cli@5.1.4) dev: true - /ts-morph@22.0.0: - resolution: {integrity: sha512-M9MqFGZREyeb5fTl6gNHKZLqBQA0TjA1lea+CR48R8EBTDuWrNqW6ccC5QvjNR4s6wDumD3LTCjOFSp9iwlzaw==} + /ts-morph@23.0.0: + resolution: {integrity: sha512-FcvFx7a9E8TUe6T3ShihXJLiJOiqyafzFKUO4aqIHDUCIvADdGNShcbc2W5PMr3LerXRv7mafvFZ9lRENxJmug==} dependencies: - '@ts-morph/common': 0.23.0 + '@ts-morph/common': 0.24.0 code-block-writer: 13.0.1 dev: true @@ -12338,8 +12565,8 @@ packages: hasBin: true dev: true - /umzug@3.8.0(@types/node@20.11.1): - resolution: {integrity: sha512-FRBvdZxllW3eUzsqG3CIfgOVChUONrKNZozNOJfvmcfBn5pMKcJjICuMMEsDLHYa/aqd7a2NtXfYEG86XHe1lQ==} + /umzug@3.8.1(@types/node@20.11.1): + resolution: {integrity: sha512-k0HjOc3b/s8vH24BUTvnaFiKhfWI9UQAGpqHDG+3866CGlBTB83Xs5wZ1io1mwYLj/GHvQ34AxKhbpYnWtkRJg==} engines: {node: '>=12'} dependencies: '@rushstack/ts-command-line': 4.19.1(@types/node@20.11.1) @@ -12533,6 +12760,21 @@ packages: - terser dev: true + /vite-plugin-svgr@4.2.0(typescript@5.4.3)(vite@5.4.1): + resolution: {integrity: sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==} + peerDependencies: + vite: ^2.6.0 || 3 || 4 || 5 + dependencies: + '@rollup/pluginutils': 5.1.0 + '@svgr/core': 8.1.0(typescript@5.4.3) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0) + vite: 5.4.1(@types/node@20.11.1) + transitivePeerDependencies: + - rollup + - supports-color + - typescript + dev: true + /vite@5.0.11(@types/node@20.11.1): resolution: {integrity: sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==} engines: {node: ^18.0.0 || >=20.0.0} diff --git a/server/mikro-orm.base.config.ts b/server/mikro-orm.base.config.ts index 4a27b24d..d28c84f4 100644 --- a/server/mikro-orm.base.config.ts +++ b/server/mikro-orm.base.config.ts @@ -12,7 +12,7 @@ import { CustomShow } from './src/dao/entities/CustomShow.js'; import { CustomShowContent } from './src/dao/entities/CustomShowContent.js'; import { FillerListContent } from './src/dao/entities/FillerListContent.js'; import { FillerShow } from './src/dao/entities/FillerShow.js'; -import { PlexServerSettings } from './src/dao/entities/PlexServerSettings.js'; +import { MediaSource } from './src/dao/entities/MediaSource.js'; import { Program } from './src/dao/entities/Program.js'; import { DATABASE_LOCATION_ENV_VAR } from './src/util/constants.js'; import { Migration20240124115044 } from './src/migrations/Migration20240124115044.js'; @@ -29,6 +29,8 @@ import { Migration20240603204620 } from './src/migrations/Migration2024060320462 import { Migration20240603204638 } from './src/migrations/Migration20240603204638.js'; import { Migration20240618005544 } from './src/migrations/Migration20240618005544.js'; import { LoggerFactory } from './src/util/logging/LoggerFactory.js'; +import { Migration20240719145409 } from './src/migrations/Migration20240719145409.js'; +import { Migration20240805185042 } from './src/migrations/Migration20240805185042.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -49,7 +51,7 @@ export default defineConfig({ CustomShowContent, FillerListContent, FillerShow, - PlexServerSettings, + MediaSource, Program, ], flushMode: 'commit', @@ -127,6 +129,14 @@ export default defineConfig({ name: 'Force regenerate program_external_id table', class: Migration20240618005544, }, + { + name: 'rename_plex_server_settings_table', + class: Migration20240719145409, + }, + { + name: 'add_jellyfin_sources', + class: Migration20240805185042, + }, ], }, extensions: [Migrator], diff --git a/server/package.json b/server/package.json index 089f4f16..686a0609 100644 --- a/server/package.json +++ b/server/package.json @@ -36,9 +36,9 @@ "@fastify/swagger": "^8.12.1", "@fastify/swagger-ui": "^4.0.0", "@iptv/xmltv": "^1.0.1", - "@mikro-orm/better-sqlite": "^6.2.0", - "@mikro-orm/core": "^6.2.0", - "@mikro-orm/migrations": "6.2.0", + "@mikro-orm/better-sqlite": "^6.3.3", + "@mikro-orm/core": "^6.3.3", + "@mikro-orm/migrations": "6.3.3", "@tunarr/shared": "workspace:*", "@tunarr/types": "workspace:*", "archiver": "^7.0.1", @@ -74,8 +74,8 @@ "zod": "^3.22.4" }, "devDependencies": { - "@mikro-orm/cli": "^6.2.0", - "@mikro-orm/reflection": "^6.2.0", + "@mikro-orm/cli": "^6.3.3", + "@mikro-orm/reflection": "^6.3.3", "@types/archiver": "^6.0.2", "@types/async-retry": "^1.4.8", "@types/better-sqlite3": "^7.6.8", diff --git a/server/src/api/debug/debugJellyfinApi.ts b/server/src/api/debug/debugJellyfinApi.ts new file mode 100644 index 00000000..c6aef2ea --- /dev/null +++ b/server/src/api/debug/debugJellyfinApi.ts @@ -0,0 +1,65 @@ +import { isNil } from 'lodash-es'; +import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient'; +import { RouterPluginAsyncCallback } from '../../types/serverType'; +import { z } from 'zod'; +import { Nilable } from '../../types/util'; + +export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async ( + fastify, + // eslint-disable-next-line @typescript-eslint/require-await +) => { + fastify.get( + '/jellyfin/libraries', + { + schema: { + querystring: z.object({ + userId: z.string(), + uri: z.string().url(), + apiKey: z.string(), + }), + }, + }, + async (req, res) => { + const client = new JellyfinApiClient({ + url: req.query.uri, + apiKey: req.query.apiKey, + }); + + await res.send(await client.getUserLibraries(req.query.userId)); + }, + ); + + fastify.get( + '/jellyfin/library/items', + { + schema: { + querystring: z + .object({ + uri: z.string().url(), + parentId: z.string().nullable().optional(), + offset: z.coerce.number().nonnegative().optional(), + limit: z.coerce.number().positive().optional(), + apiKey: z.string(), + }) + .refine(({ offset, limit }) => { + return isNil(offset) === isNil(limit); + }, 'offset/limit must either both be defined, or neither'), + }, + }, + async (req, res) => { + const client = new JellyfinApiClient({ + url: req.query.uri, + apiKey: req.query.apiKey, + }); + + let pageParams: Nilable<{ offset: number; limit: number }> = null; + if (!isNil(req.query.limit) && !isNil(req.query.offset)) { + pageParams = { offset: req.query.offset, limit: req.query.limit }; + } + + await res.send( + await client.getItems(null, req.query.parentId, [], [], pageParams), + ); + }, + ); +}; diff --git a/server/src/api/debugApi.ts b/server/src/api/debugApi.ts index c4948c05..df5b42e7 100644 --- a/server/src/api/debugApi.ts +++ b/server/src/api/debugApi.ts @@ -4,28 +4,29 @@ import { ChannelLineupQuery } from '@tunarr/types/api'; import { ChannelLineupSchema } from '@tunarr/types/schemas'; import dayjs from 'dayjs'; import { FastifyRequest } from 'fastify'; -import { compact, first, isNil, isUndefined } from 'lodash-es'; +import { compact, isNil, reject, some, map } from 'lodash-es'; import os from 'node:os'; import z from 'zod'; import { ArchiveDatabaseBackup } from '../dao/backup/ArchiveDatabaseBackup.js'; import { getEm } from '../dao/dataSource.js'; -import { - StreamLineupItem, - isContentBackedLineupIteam, -} from '../dao/derived_types/StreamLineup.js'; +import { StreamLineupItem } from '../dao/derived_types/StreamLineup.js'; import { Channel } from '../dao/entities/Channel.js'; import { LineupCreator } from '../services/dynamic_channels/LineupCreator.js'; import { PlayerContext } from '../stream/Player.js'; import { generateChannelContext } from '../stream/StreamProgramCalculator.js'; import { PlexPlayer } from '../stream/plex/PlexPlayer.js'; -import { PlexTranscoder } from '../stream/plex/PlexTranscoder.js'; import { StreamContextChannel } from '../stream/types.js'; -import { SavePlexProgramExternalIdsTask } from '../tasks/SavePlexProgramExternalIdsTask.js'; +import { SavePlexProgramExternalIdsTask } from '../tasks/plex/SavePlexProgramExternalIdsTask.js'; import { PlexTaskQueue } from '../tasks/TaskQueue.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { Maybe } from '../types/util.js'; import { ifDefined, mapAsyncSeq } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; +import { DebugJellyfinApiRouter } from './debug/debugJellyfinApi.js'; +import { jsonArrayFrom } from 'kysely/helpers/sqlite'; +import { directDbAccess } from '../dao/direct/directDbAccess.js'; +import { MediaSourceType } from '../dao/entities/MediaSource.js'; +import { enumValues } from '../util/enumUtil.js'; const ChannelQuerySchema = { querystring: z.object({ @@ -33,10 +34,13 @@ const ChannelQuerySchema = { }), }; -// eslint-disable-next-line @typescript-eslint/require-await export const debugApi: RouterPluginAsyncCallback = async (fastify) => { const logger = LoggerFactory.child({ caller: import.meta }); + await fastify.register(DebugJellyfinApiRouter, { + prefix: '/debug', + }); + fastify.get( '/debug/plex', { schema: ChannelQuerySchema }, @@ -86,67 +90,6 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => { }, ); - fastify.get( - '/debug/plex-transcoder/video-stats', - { schema: ChannelQuerySchema }, - async (req, res) => { - const channel = await req.serverCtx.channelDB.getChannelAndProgramsSLOW( - req.query.channelId, - ); - - if (!channel) { - return res.status(404).send('No channel found'); - } - - const lineupItem = await getLineupItemForDebug( - req, - channel, - new Date().getTime(), - ); - - if (isUndefined(lineupItem)) { - return res - .status(500) - .send('Couldnt get a lineup item for this channel'); - } - - if (!isContentBackedLineupIteam(lineupItem)) { - return res - .status(500) - .send( - `Needed lineup item of type commercial or program, but got "${lineupItem.type}"`, - ); - } - - // TODO use plex server from item. - const plexServer = await req.serverCtx.plexServerDB.getAll().then(first); - - if (isNil(plexServer)) { - return res.status(404).send('Could not find plex server'); - } - - const plexSettings = req.serverCtx.settings.plexSettings(); - - const combinedChannel: StreamContextChannel = { - ...generateChannelContext(channel), - transcoding: channel?.transcoding, - }; - - const transcoder = new PlexTranscoder( - `debug-${new Date().getTime()}`, - plexServer, - plexSettings, - combinedChannel, - lineupItem, - ); - - transcoder.setTranscodingArgs(false, true, false, false); - await transcoder.getDecision(false); - - return res.send(transcoder.getVideoStats()); - }, - ); - async function getLineupItemForDebug( req: FastifyRequest, channel: Loaded, @@ -403,17 +346,54 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => { return res.send(); }); - fastify.get('/debug/db/test_direct_access', async (_req, res) => { - // const result = await directDbAccess() - // .selectFrom('channel_programs') - // .where('channel_uuid', '=', '0ff3ec64-1022-4afd-9178-3f27f1121d47') - // .innerJoin('program', 'channel_programs.program_uuid', 'program.uuid') - // .leftJoin('program_grouping', join => { - // join.onRef('') - // }) - // .select(['program']) - // .execute(); - // return res.send(result); - return res.send(); - }); + fastify.get( + '/debug/db/test_direct_access', + { + schema: { + querystring: z.object({ + id: z.string(), + }), + }, + }, + async (_req, res) => { + const mediaSource = (await _req.serverCtx.mediaSourceDB.getById( + _req.query.id, + ))!; + + const knownProgramIds = await directDbAccess() + .selectFrom('programExternalId as p1') + .where(({ eb, and }) => + and([ + eb('p1.externalSourceId', '=', mediaSource.name), + eb('p1.sourceType', '=', mediaSource.type), + ]), + ) + .selectAll('p1') + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('programExternalId as p2') + .whereRef('p2.programUuid', '=', 'p1.programUuid') + .whereRef('p2.uuid', '!=', 'p1.uuid') + .select([ + 'p2.sourceType', + 'p2.externalSourceId', + 'p2.externalKey', + ]), + ).as('otherExternalIds'), + ) + .groupBy('p1.uuid') + .execute(); + + const mediaSourceTypes = map(enumValues(MediaSourceType), (typ) => + typ.toString(), + ); + const danglingPrograms = reject(knownProgramIds, (program) => { + some(program.otherExternalIds, (eid) => + mediaSourceTypes.includes(eid.sourceType), + ); + }); + return res.send(danglingPrograms); + }, + ); }; diff --git a/server/src/api/index.ts b/server/src/api/index.ts index e4f2670f..96146a06 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -4,28 +4,14 @@ import { isError, isNil } from 'lodash-es'; import path from 'path'; import { pipeline } from 'stream/promises'; import { z } from 'zod'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; -import { Plex } from '../external/plex.js'; +import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js'; +import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js'; import { FFMPEGInfo } from '../ffmpeg/ffmpegInfo.js'; import { serverOptions } from '../globals.js'; import { GlobalScheduler } from '../services/scheduler.js'; import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { fileExists } from '../util/fsUtil.js'; -import { LoggerFactory } from '../util/logging/LoggerFactory.js'; -import { channelsApi } from './channelsApi.js'; -import { customShowsApiV2 } from './customShowsApi.js'; -import { debugApi } from './debugApi.js'; -import { fillerListsApi } from './fillerListsApi.js'; -import { metadataApiRouter } from './metadataApi.js'; -import { programmingApi } from './programmingApi.js'; -import { tasksApiRouter } from './tasksApi.js'; -import { ffmpegSettingsRouter } from './ffmpegSettingsApi.js'; -import { guideRouter } from './guideApi.js'; -import { hdhrSettingsRouter } from './hdhrSettingsApi.js'; -import { plexServersRouter } from './plexServersApi.js'; -import { plexSettingsRouter } from './plexSettingsApi.js'; -import { xmlTvSettingsRouter } from './xmltvSettingsApi.js'; import { isEdgeBuild, isNonEmptyString, @@ -33,7 +19,22 @@ import { run, tunarrBuild, } from '../util/index.js'; +import { LoggerFactory } from '../util/logging/LoggerFactory.js'; +import { channelsApi } from './channelsApi.js'; +import { customShowsApiV2 } from './customShowsApi.js'; +import { debugApi } from './debugApi.js'; +import { ffmpegSettingsRouter } from './ffmpegSettingsApi.js'; +import { fillerListsApi } from './fillerListsApi.js'; +import { guideRouter } from './guideApi.js'; +import { hdhrSettingsRouter } from './hdhrSettingsApi.js'; +import { jellyfinApiRouter } from './jellyfinApi.js'; +import { mediaSourceRouter } from './mediaSourceApi.js'; +import { metadataApiRouter } from './metadataApi.js'; +import { plexSettingsRouter } from './plexSettingsApi.js'; +import { programmingApi } from './programmingApi.js'; import { systemSettingsRouter } from './systemSettingsApi.js'; +import { tasksApiRouter } from './tasksApi.js'; +import { xmlTvSettingsRouter } from './xmltvSettingsApi.js'; import { getTunarrVersion } from '../util/version.js'; export const apiRouter: RouterPluginAsyncCallback = async (fastify) => { @@ -56,13 +57,14 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => { .register(programmingApi) .register(debugApi) .register(metadataApiRouter) - .register(plexServersRouter) + .register(mediaSourceRouter) .register(ffmpegSettingsRouter) .register(plexSettingsRouter) .register(xmlTvSettingsRouter) .register(hdhrSettingsRouter) .register(systemSettingsRouter) - .register(guideRouter); + .register(guideRouter) + .register(jellyfinApiRouter); fastify.get( '/version', @@ -214,21 +216,21 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => { '/plex', { schema: { - querystring: z.object({ name: z.string(), path: z.string() }), + querystring: z.object({ id: z.string(), path: z.string() }), }, }, async (req, res) => { const server = await req.entityManager - .repo(PlexServerSettings) - .findOne({ name: req.query.name }); + .repo(MediaSource) + .findOne({ uuid: req.query.id, type: MediaSourceType.Plex }); if (isNil(server)) { return res .status(404) - .send({ error: 'No server found with name: ' + req.query.name }); + .send({ error: 'No server found with id: ' + req.query.id }); } - const plex = new Plex(server); - return res.send(await plex.doGet(req.query.path)); + const plex = MediaSourceApiFactory().get(server); + return res.send(await plex.doGetPath(req.query.path)); }, ); }; diff --git a/server/src/api/jellyfinApi.ts b/server/src/api/jellyfinApi.ts new file mode 100644 index 00000000..d2fe2f9c --- /dev/null +++ b/server/src/api/jellyfinApi.ts @@ -0,0 +1,149 @@ +import { JellyfinLoginRequest } from '@tunarr/types/api'; +import { + RouterPluginCallback, + ZodFastifyRequest, +} from '../types/serverType.js'; +import { JellyfinApiClient } from '../external/jellyfin/JellyfinApiClient.js'; +import { isDefined, nullToUndefined } from '../util/index.js'; +import { z } from 'zod'; +import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js'; +import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js'; +import { isNull } from 'lodash-es'; +import { isQueryError } from '../external/BaseApiClient.js'; +import { + JellyfinItemKind, + JellyfinLibraryItemsResponse, +} from '@tunarr/types/jellyfin'; +import { FastifyReply } from 'fastify/types/reply.js'; + +const mediaSourceParams = z.object({ + mediaSourceId: z.string(), +}); + +export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => { + fastify.post( + '/jellyfin/login', + { + schema: { + body: JellyfinLoginRequest, + }, + }, + async (req, res) => { + const response = await JellyfinApiClient.login( + { url: req.body.url, name: 'Unknown' }, + req.body.username, + req.body.password, + ); + + return res.send({ accessToken: nullToUndefined(response.AccessToken) }); + }, + ); + + fastify.get( + '/jellyfin/:mediaSourceId/user_libraries', + { + schema: { + params: mediaSourceParams, + // querystring: z.object({}) + }, + }, + (req, res) => + withJellyfinMediaSource(req, res, async (mediaSource) => { + const api = MediaSourceApiFactory().getJellyfinClient({ + ...mediaSource, + url: mediaSource.uri, + apiKey: mediaSource.accessToken, + }); + + const response = await api.getUserViews(); + + if (isQueryError(response)) { + throw response; + } + + return res.send(response.data); + }), + ); + + fastify.get( + '/jellyfin/:mediaSourceId/libraries/:libraryId/items', + { + schema: { + params: mediaSourceParams.extend({ + libraryId: z.string(), + }), + querystring: z.object({ + offset: z.coerce.number().nonnegative().optional(), + limit: z.coerce.number().positive().optional(), + itemTypes: z + .string() + .optional() + .transform((s) => s?.split(',')) + .pipe(JellyfinItemKind.array().optional()), + }), + response: { + 200: JellyfinLibraryItemsResponse, + }, + }, + }, + (req, res) => + withJellyfinMediaSource(req, res, async (mediaSource) => { + console.log(req.query.itemTypes); + const api = MediaSourceApiFactory().getJellyfinClient({ + ...mediaSource, + url: mediaSource.uri, + apiKey: mediaSource.accessToken, + }); + + const pageParams = + isDefined(req.query.offset) && isDefined(req.query.limit) + ? { offset: req.query.offset, limit: req.query.limit } + : null; + const response = await api.getItems( + null, + req.params.libraryId, + req.query.itemTypes ?? [], + ['ChildCount', 'RecursiveItemCount'], + pageParams, + ); + + if (isQueryError(response)) { + throw response; + } + + return res.send(response.data); + }), + ); + + async function withJellyfinMediaSource< + Req extends ZodFastifyRequest<{ + params: typeof mediaSourceParams; + }>, + >( + req: Req, + res: FastifyReply, + cb: (m: MediaSource) => Promise, + ) { + const mediaSource = await req.serverCtx.mediaSourceDB.getById( + req.params.mediaSourceId, + ); + + if (isNull(mediaSource)) { + return res + .status(400) + .send(`No media source with ID ${req.params.mediaSourceId} found.`); + } + + if (mediaSource.type !== MediaSourceType.Jellyfin) { + return res + .status(400) + .send( + `Media source with ID = ${req.params.mediaSourceId} is not a Jellyfin server.`, + ); + } + + return cb(mediaSource); + } + + done(); +}; diff --git a/server/src/api/plexServersApi.ts b/server/src/api/mediaSourceApi.ts similarity index 61% rename from server/src/api/plexServersApi.ts rename to server/src/api/mediaSourceApi.ts index 313f0923..ce1ddf84 100644 --- a/server/src/api/plexServersApi.ts +++ b/server/src/api/mediaSourceApi.ts @@ -1,40 +1,41 @@ import { BaseErrorSchema, BasicIdParamSchema, - InsertPlexServerRequestSchema, - UpdatePlexServerRequestSchema, + InsertMediaSourceRequestSchema, + UpdateMediaSourceRequestSchema, } from '@tunarr/types/api'; -import { PlexServerSettingsSchema } from '@tunarr/types/schemas'; +import { MediaSourceSettingsSchema } from '@tunarr/types/schemas'; import { isError, isNil, isObject } from 'lodash-es'; import z from 'zod'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; -import { Plex } from '../external/plex.js'; -import { PlexApiFactory } from '../external/PlexApiFactory.js'; +import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js'; +import { PlexApiClient } from '../external/plex/PlexApiClient.js'; +import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js'; import { GlobalScheduler } from '../services/scheduler.js'; import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { firstDefined, wait } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; +import { JellyfinApiClient } from '../external/jellyfin/JellyfinApiClient.js'; -export const plexServersRouter: RouterPluginAsyncCallback = async ( +export const mediaSourceRouter: RouterPluginAsyncCallback = async ( fastify, // eslint-disable-next-line @typescript-eslint/require-await ) => { const logger = LoggerFactory.child({ caller: import.meta }); fastify.get( - '/plex-servers', + '/media-sources', { schema: { response: { - 200: z.array(PlexServerSettingsSchema), + 200: z.array(MediaSourceSettingsSchema), 500: z.string(), }, }, }, async (req, res) => { try { - const servers = await req.serverCtx.plexServerDB.getAll(); + const servers = await req.serverCtx.mediaSourceDB.getAll(); const dtos = servers.map((server) => server.toDTO()); return res.send(dtos); } catch (err) { @@ -45,7 +46,7 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( ); fastify.get( - '/plex-servers/:id/status', + '/media-sources/:id/status', { schema: { params: BasicIdParamSchema, @@ -60,27 +61,44 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }, async (req, res) => { try { - const server = await req.serverCtx.plexServerDB.getById(req.params.id); + const server = await req.serverCtx.mediaSourceDB.getById(req.params.id); if (isNil(server)) { return res.status(404).send(); } - const plex = new Plex(server); + let healthyPromise: Promise; + switch (server.type) { + case MediaSourceType.Plex: { + const plex = new PlexApiClient(server); + healthyPromise = plex.checkServerStatus(); + break; + } + case MediaSourceType.Jellyfin: { + const jellyfin = new JellyfinApiClient({ + url: server.uri, + apiKey: server.accessToken, + name: server.name, + }); + healthyPromise = jellyfin + .getSystemInfo() + .then(() => true) + .catch(() => false); + break; + } + } - const s: 1 | -1 = await Promise.race([ - (async () => { - return await plex.checkServerStatus(); - })(), - new Promise<-1>((resolve) => { + const status = await Promise.race([ + healthyPromise, + new Promise((resolve) => { setTimeout(() => { - resolve(-1); + resolve(false); }, 60000); }), ]); return res.send({ - healthy: s === 1, + healthy: status, }); } catch (err) { logger.error(err); @@ -90,13 +108,15 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( ); fastify.post( - '/plex-servers/foreignstatus', + '/media-sources/foreignstatus', { schema: { body: z.object({ name: z.string().optional(), accessToken: z.string(), uri: z.string(), + type: z.union([z.literal('plex'), z.literal('jellyfin')]), + username: z.string().optional(), }), response: { 200: z.object({ @@ -108,36 +128,46 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }, }, async (req, res) => { - try { - const plex = new Plex({ - ...req.body, - name: req.body.name ?? 'unknown', - }); + let healthyPromise: Promise; + switch (req.body.type) { + case 'plex': { + const plex = new PlexApiClient({ + ...req.body, + name: req.body.name ?? 'unknown', + }); - const s: boolean = await Promise.race([ - (async () => { - const res = await plex.checkServerStatus(); - return res === 1; - })(), - new Promise((resolve) => { - setTimeout(() => { - resolve(false); - }, 60000); - }), - ]); + healthyPromise = plex.checkServerStatus(); + break; + } + case 'jellyfin': { + const jellyfin = new JellyfinApiClient({ + url: req.body.uri, + name: req.body.name ?? 'unknown', + apiKey: req.body.accessToken, + }); - return res.send({ - healthy: s, - }); - } catch (err) { - logger.error('%O', err); - return res.status(500).send(); + healthyPromise = jellyfin.ping(); + break; + } } + + const healthy = await Promise.race([ + healthyPromise, + new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }, 60000); + }), + ]); + + return res.send({ + healthy, + }); }, ); fastify.delete( - '/plex-servers/:id', + '/media-sources/:id', { schema: { params: BasicIdParamSchema, @@ -149,15 +179,14 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }, async (req, res) => { try { - const { deletedServer } = await req.serverCtx.plexServerDB.deleteServer( - req.params.id, - ); + const { deletedServer } = + await req.serverCtx.mediaSourceDB.deleteMediaSource(req.params.id); // Are these useful? What do they even do? req.serverCtx.eventService.push({ type: 'settings-update', - message: `Plex server ${deletedServer.name} removed.`, - module: 'plex-server', + message: `Media source ${deletedServer.name} removed.`, + module: 'media-source', detail: { serverId: req.params.id, serverName: deletedServer.name, @@ -180,8 +209,8 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( logger.error('Error %O', err); req.serverCtx.eventService.push({ type: 'settings-update', - message: 'Error deleting plex server.', - module: 'plex-server', + message: 'Error deleting media-source.', + module: 'media-source', detail: { action: 'delete', serverId: req.params.id, @@ -196,11 +225,11 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( ); fastify.put( - '/plex-servers/:id', + '/media-sources/:id', { schema: { params: BasicIdParamSchema, - body: UpdatePlexServerRequestSchema, + body: UpdateMediaSourceRequestSchema, response: { 200: z.void(), 500: z.void(), @@ -209,7 +238,9 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }, async (req, res) => { try { - const report = await req.serverCtx.plexServerDB.updateServer(req.body); + const report = await req.serverCtx.mediaSourceDB.updateMediaSource( + req.body, + ); let modifiedPrograms = 0; let destroyedPrograms = 0; report.forEach((r) => { @@ -220,8 +251,8 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }); req.serverCtx.eventService.push({ type: 'settings-update', - message: `Plex server ${req.body.name} updated. ${modifiedPrograms} programs modified, ${destroyedPrograms} programs deleted`, - module: 'plex-server', + message: `Media source ${req.body.name} updated. ${modifiedPrograms} programs modified, ${destroyedPrograms} programs deleted`, + module: 'media-source', detail: { serverName: req.body.name, action: 'update', @@ -231,11 +262,11 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( return res.status(200).send(); } catch (err) { - logger.error('Could not update plex server. ', err); + logger.error(err, 'Could not update plex server. '); req.serverCtx.eventService.push({ type: 'settings-update', - message: 'Error updating plex server.', - module: 'plex-server', + message: 'Error updating media source.', + module: 'media-source', detail: { action: 'update', serverName: firstDefined(req, 'body', 'name'), @@ -249,10 +280,10 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( ); fastify.post( - '/plex-servers', + '/media-sources', { schema: { - body: InsertPlexServerRequestSchema, + body: InsertMediaSourceRequestSchema, response: { 201: z.object({ id: z.string(), @@ -264,13 +295,13 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }, async (req, res) => { try { - const newServerId = await req.serverCtx.plexServerDB.addServer( + const newServerId = await req.serverCtx.mediaSourceDB.addMediaSource( req.body, ); req.serverCtx.eventService.push({ type: 'settings-update', - message: `Plex server ${req.body.name} added.`, - module: 'plex-server', + message: `Media source "${req.body.name}" added.`, + module: 'media-source', detail: { serverId: newServerId, serverName: req.body.name, @@ -280,19 +311,19 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }); return res.status(201).send({ id: newServerId }); } catch (err) { - logger.error('Could not add plex server.', err); + logger.error(err, 'Could not add media source'); req.serverCtx.eventService.push({ type: 'settings-update', - message: 'Error adding plex server.', + message: 'Error adding media source.', module: 'plex-server', detail: { action: 'add', - serverName: firstDefined(req, 'body', 'name'), + serverName: req.body.name, error: isError(err) ? firstDefined(err, 'message') : 'unknown', }, level: 'error', }); - return res.status(400).send('Could not add plex server.'); + return res.status(500).send('Could not add media source.'); } }, ); @@ -316,17 +347,17 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( async (req, res) => { try { const server = await req.entityManager - .repo(PlexServerSettings) + .repo(MediaSource) .findOne({ name: req.query.serverName }); if (isNil(server)) { return res.status(404).send({ message: 'Plex server not found.' }); } - const plex = PlexApiFactory().get(server); + const plex = MediaSourceApiFactory().get(server); const s = await Promise.race([ - plex.checkServerStatus().then((res) => res === 1), + plex.checkServerStatus(), wait(15000).then(() => false), ]); diff --git a/server/src/api/metadataApi.ts b/server/src/api/metadataApi.ts index e554eac9..b4531e9a 100644 --- a/server/src/api/metadataApi.ts +++ b/server/src/api/metadataApi.ts @@ -7,7 +7,7 @@ import { ProgramSourceType, programSourceTypeFromString, } from '../dao/custom_types/ProgramSourceType'; -import { PlexApiFactory } from '../external/PlexApiFactory'; +import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory'; import { RouterPluginAsyncCallback } from '../types/serverType'; const externalIdSchema = z @@ -70,6 +70,11 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => { switch (req.query.id.externalSourceType) { case ProgramSourceType.PLEX: { result = await handlePlexItem(req.query); + break; + } + case ProgramSourceType.JELLYFIN: { + result = await handleJellyfishItem(req.query); + break; } } @@ -113,7 +118,9 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => { ); async function handlePlexItem(query: ExternalMetadataQuery) { - const plexApi = await PlexApiFactory().getOrSet(query.id.externalSourceId); + const plexApi = await MediaSourceApiFactory().getOrSet( + query.id.externalSourceId, + ); if (isNil(plexApi)) { return null; @@ -130,4 +137,26 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => { return null; } + + async function handleJellyfishItem(query: ExternalMetadataQuery) { + const jellyfinClient = await MediaSourceApiFactory().getJellyfinByName( + query.id.externalSourceId, + ); + + if (isNil(jellyfinClient)) { + return null; + } + + if (query.asset === 'thumb') { + return jellyfinClient.getThumbUrl(query.id.externalItemId); + // return jellyfinClient.getThumbUrl({ + // itemKey: query.id.externalItemId, + // width: query.thumbOptions?.width, + // height: query.thumbOptions?.height, + // upscale: '1', + // }); + } + + return null; + } }; diff --git a/server/src/api/programmingApi.ts b/server/src/api/programmingApi.ts index 4d88d0eb..9b315d56 100644 --- a/server/src/api/programmingApi.ts +++ b/server/src/api/programmingApi.ts @@ -17,16 +17,23 @@ import z from 'zod'; import { ProgramSourceType, programSourceTypeFromString, + programSourceTypeToMediaSource, } from '../dao/custom_types/ProgramSourceType.js'; import { getEm } from '../dao/dataSource.js'; import { Program, ProgramType } from '../dao/entities/Program.js'; -import { Plex } from '../external/plex.js'; +import { PlexApiClient } from '../external/plex/PlexApiClient.js'; import { TruthyQueryParam } from '../types/schemas.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { ProgramGrouping } from '../dao/entities/ProgramGrouping.js'; -import { ifDefined } from '../util/index.js'; +import { ifDefined, isNonEmptyString } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; -import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js'; +import { + ProgramExternalIdType, + programExternalIdTypeFromSourceType, + programExternalIdTypeToMediaSourceType, +} from '../dao/custom_types/ProgramExternalIdType.js'; +import { MediaSource } from '../dao/entities/MediaSource.js'; +import { JellyfinApiClient } from '../external/jellyfin/JellyfinApiClient.js'; const LookupExternalProgrammingSchema = z.object({ externalId: z @@ -94,30 +101,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => { return res.status(404).send(); } - const handlePlexItem = async ( - externalKey: string | undefined, - externalSourceId: string, - ) => { - if (isNil(externalKey)) { - return res.status(500).send(); - } - - const server = - await req.serverCtx.plexServerDB.getByExternalid(externalSourceId); - - if (isNil(server)) { - return res.status(404).send(); - } - - const result = Plex.getThumbUrl({ - uri: server.uri, - itemKey: externalKey, - accessToken: server.accessToken, - height: req.query.height, - width: req.query.width, - upscale: req.query.upscale.toString(), - }); - + const handleResult = async (mediaSource: MediaSource, result: string) => { if (req.query.proxy) { try { const proxyRes = await axios.request({ @@ -141,8 +125,9 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => { } catch (e) { if (isAxiosError(e) && e.response?.status === 404) { logger.error( - 'Error retrieving thumb from Plex at url: %s. Status: 404', - result.replaceAll(server.accessToken, 'REDACTED_TOKEN'), + 'Error retrieving thumb from %s at url: %s. Status: 404', + mediaSource.type, + result.replaceAll(mediaSource.accessToken, 'REDACTED_TOKEN'), ); return res.status(404).send(); } @@ -154,37 +139,118 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => { }; if (!isNil(program)) { + const mediaSource = await req.serverCtx.mediaSourceDB.getByExternalId( + programSourceTypeToMediaSource(program.sourceType), + program.externalSourceId, + ); + + if (isNil(mediaSource)) { + return res.status(404).send(); + } + + let keyToUse = program.externalKey; + if (program.type === ProgramType.Track && !isNil(program.album)) { + ifDefined( + find( + program.album.$.externalRefs, + (ref) => + ref.sourceType === + programExternalIdTypeFromSourceType(program.sourceType) && + ref.externalSourceId === program.externalSourceId, + ), + (ref) => { + keyToUse = ref.externalKey; + }, + ); + } + + if (isNil(keyToUse)) { + return res.status(500).send(); + } + switch (program.sourceType) { case ProgramSourceType.PLEX: { - let keyToUse = program.externalKey; - if (program.type === ProgramType.Track && !isNil(program.album)) { - ifDefined( - find( - program.album.$.externalRefs, - (ref) => - ref.sourceType === ProgramExternalIdType.PLEX && - ref.externalSourceId === program.externalSourceId, - ), - (ref) => { - keyToUse = ref.externalKey; - }, - ); - } - return handlePlexItem(keyToUse, program.externalSourceId); + return handleResult( + mediaSource, + PlexApiClient.getThumbUrl({ + uri: mediaSource.uri, + itemKey: keyToUse, + accessToken: mediaSource.accessToken, + height: req.query.height, + width: req.query.width, + upscale: req.query.upscale.toString(), + }), + ); } + case ProgramSourceType.JELLYFIN: + return handleResult( + mediaSource, + JellyfinApiClient.getThumbUrl({ + uri: mediaSource.uri, + itemKey: keyToUse, + accessToken: mediaSource.accessToken, + height: req.query.height, + width: req.query.width, + upscale: req.query.upscale.toString(), + }), + ); default: return res.status(405).send(); } } else { // We can assume that we have a grouping here... // We only support Plex now - const source = find(grouping!.externalRefs, { - sourceType: ProgramExternalIdType.PLEX, - }); + const source = find( + grouping!.externalRefs, + (ref) => + ref.sourceType === ProgramExternalIdType.PLEX || + ref.sourceType === ProgramExternalIdType.JELLYFIN, + ); + if (isNil(source)) { return res.status(500).send(); + } else if (!isNonEmptyString(source.externalSourceId)) { + return res.status(500).send(); + } + + const mediaSource = await req.serverCtx.mediaSourceDB.getByExternalId( + programExternalIdTypeToMediaSourceType(source.sourceType)!, + source.externalSourceId, + ); + + if (isNil(mediaSource)) { + return res.status(404).send(); + } + + switch (source.sourceType) { + case ProgramExternalIdType.PLEX: + return handleResult( + mediaSource, + PlexApiClient.getThumbUrl({ + uri: mediaSource.uri, + itemKey: source.externalSourceId, + accessToken: mediaSource.accessToken, + height: req.query.height, + width: req.query.width, + upscale: req.query.upscale.toString(), + }), + ); + case ProgramExternalIdType.JELLYFIN: + return handleResult( + mediaSource, + JellyfinApiClient.getThumbUrl({ + uri: mediaSource.uri, + itemKey: source.externalSourceId, + accessToken: mediaSource.accessToken, + height: req.query.height, + width: req.query.width, + upscale: req.query.upscale.toString(), + }), + ); + default: + // Impossible + return res.status(500).send(); } - return handlePlexItem(source.externalKey, source.externalSourceId!); } }, ); @@ -206,21 +272,39 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => { }, async (req, res) => { const em = getEm(); - const program = await em.repo(Program).findOne({ uuid: req.params.id }); + const program = await em + .repo(Program) + .findOne({ uuid: req.params.id }, { populate: ['externalIds'] }); if (isNil(program)) { return res.status(404).send(); } - const plexServers = await req.serverCtx.plexServerDB.getAll(); + const mediaSources = await req.serverCtx.mediaSourceDB.getAll(); - switch (program.sourceType) { - case ProgramSourceType.PLEX: { - if (isNil(program.externalKey)) { - return res.status(500).send(); - } + const externalId = program.externalIds.$.find( + (eid) => + eid.sourceType === ProgramExternalIdType.JELLYFIN || + eid.sourceType === ProgramExternalIdType.PLEX, + ); - const server = find(plexServers, { name: program.externalSourceId }); - if (isNil(server) || isNil(server.clientIdentifier)) { + if (!externalId) { + return res.status(404).send(); + } + + const server = find( + mediaSources, + (source) => + source.uuid === externalId.externalSourceId || + source.name === externalId.externalSourceId, + ); + + if (isNil(server)) { + return res.status(404).send(); + } + + switch (externalId.sourceType) { + case ProgramExternalIdType.PLEX: { + if (isNil(server.clientIdentifier)) { return res.status(404).send(); } @@ -234,6 +318,14 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => { return res.send({ url }); } + return res.redirect(302, url).send(); + } + case ProgramExternalIdType.JELLYFIN: { + const url = `${server.uri}/web/#/details?id=${externalId.externalKey}`; + if (!req.query.forward) { + return res.send({ url }); + } + return res.redirect(302, url).send(); } } diff --git a/server/src/dao/LegacyProgramGroupingCalculator.ts b/server/src/dao/LegacyProgramGroupingCalculator.ts deleted file mode 100644 index a6cdd4c8..00000000 --- a/server/src/dao/LegacyProgramGroupingCalculator.ts +++ /dev/null @@ -1,622 +0,0 @@ -import { Loaded, ref } from '@mikro-orm/core'; -import { ContentProgram } from '@tunarr/types'; -import { - PlexEpisode, - PlexLibraryMusic, - PlexLibraryShows, - PlexMusicAlbumView, - PlexMusicTrack, - PlexSeasonView, - isPlexEpisode, - isPlexMusicTrack, -} from '@tunarr/types/plex'; -import * as ld from 'lodash-es'; -import { - chunk, - concat, - find, - flatten, - forEach, - groupBy, - has, - isEmpty, - isNil, - isUndefined, - keys, - map, - mapValues, - reduce, - reject, - round, - values, -} from 'lodash-es'; -import { PlexApiFactory } from '../external/PlexApiFactory.js'; -import { - flipMap, - groupByUniqFunc, - ifDefined, - isNonEmptyString, - mapAsyncSeq, - mapReduceAsyncSeq, -} from '../util/index.js'; -import { LoggerFactory } from '../util/logging/LoggerFactory.js'; -import { timeAsync } from '../util/perf.js'; -import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js'; -import { ProgramSourceType } from './custom_types/ProgramSourceType.js'; -import { getEm } from './dataSource.js'; -import { PlexServerSettings } from './entities/PlexServerSettings.js'; -import { Program, ProgramType } from './entities/Program.js'; -import { - ProgramGrouping, - ProgramGroupingType, -} from './entities/ProgramGrouping.js'; -import { ProgramGroupingExternalId } from './entities/ProgramGroupingExternalId.js'; - -type ProgramsBySource = Record< - NonNullable, - Record ->; - -type GroupingIdAndPlexInfo = { - uuid: string; - externalKey: string; - externalSourceId: string; -}; - -type ProgramGroupingsByType = Record< - ProgramGroupingType, - GroupingIdAndPlexInfo[] ->; - -function typedKeys< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - T extends Record, - KeyType = T extends Record ? K : never, ->(record: T): KeyType[] { - return keys(record) as KeyType[]; -} - -export class LegacyProgramGroupingCalculator { - private logger = LoggerFactory.child({ - className: LegacyProgramGroupingCalculator.name, - }); - - async calculateGroupings( - contentPrograms: ContentProgram[], - upsertedPrograms: Program[], - ) { - const em = getEm(); - - const programsBySource = ld - .chain(contentPrograms) - .filter((p) => p.subtype === 'episode' || p.subtype === 'track') - // TODO figure out a way to shim in a typed groupBy to lodash-es without - // breaking the whole world - .groupBy((cp) => cp.externalSourceType!) - .mapValues((programs) => groupBy(programs, (p) => p.externalSourceName!)) - .value() as ProgramsBySource; - - // This function potentially does a lot of work - // but we don't want to accidentally not do an upsert of a program. - // TODO: Probably want to do this step in the background... - const programGroupingsBySource = await timeAsync( - () => this.findAndUpdateProgramRelations(programsBySource), - { - onSuccess: (ms) => { - this.logger.debug( - 'findAndUpdateProgramRelations took %d (success)', - round(ms, 3), - ); - }, - onFailure: (ms) => { - this.logger.debug( - 'findAndUpdateProgramRelations took %d (failure)', - round(ms, 3), - ); - }, - }, - ); - - forEach(upsertedPrograms, (program) => { - if ( - program.type !== ProgramType.Episode && - program.type !== ProgramType.Track - ) { - return; - } - - const groupings = programGroupingsBySource[program.sourceType]; - if (groupings) { - switch (program.type) { - case ProgramType.Episode: { - if (program.grandparentExternalKey) { - ifDefined( - this.findMatchingGrouping( - groupings, - ProgramGroupingType.TvShow, - program.externalSourceId, - program.grandparentExternalKey, - ), - (show) => { - program.tvShow = ref( - em.getReference(ProgramGrouping, show.uuid), - ); - }, - ); - } - - if (program.parentExternalKey) { - ifDefined( - this.findMatchingGrouping( - groupings, - ProgramGroupingType.TvShowSeason, - program.externalSourceId, - program.parentExternalKey, - ), - (season) => { - program.season = ref( - em.getReference(ProgramGrouping, season.uuid), - ); - }, - ); - } - - break; - } - case ProgramType.Track: { - if (program.grandparentExternalKey) { - ifDefined( - this.findMatchingGrouping( - groupings, - ProgramGroupingType.MusicArtist, - program.externalSourceId, - program.grandparentExternalKey, - ), - (artist) => { - program.artist = ref( - em.getReference(ProgramGrouping, artist.uuid), - ); - }, - ); - } - - if (program.parentExternalKey) { - ifDefined( - this.findMatchingGrouping( - groupings, - ProgramGroupingType.MusicAlbum, - program.externalSourceId, - program.parentExternalKey, - ), - (album) => { - program.album = ref( - em.getReference(ProgramGrouping, album.uuid), - ); - }, - ); - } - break; - } - default: - return; - } - } - }); - } - - findMatchingGrouping( - mappings: ProgramGroupingsByType, - groupType: ProgramGroupingType, - sourceId: string, - externalKeyToMatch: string, - ) { - return find( - mappings[groupType], - (grouping) => - grouping.externalSourceId === sourceId && - grouping.externalKey === externalKeyToMatch, - ); - } - - // Consider making the UI pass this information in to make it potentially - // less error-prone. We could also do this asynchronously, but that's kinda - // mess as well - async findAndUpdateProgramRelations(programsBySource: ProgramsBySource) { - // Plex specific for now... - const ret: Record< - ProgramSourceType, - Record - > = { - plex: makeEmptyGroupMap(), - }; - - for (const source of typedKeys(programsBySource)) { - switch (source) { - case 'plex': - { - const programsByServer = programsBySource[source]; - for (const server of keys(programsByServer)) { - const result = await this.findAndUpdatePlexServerPrograms( - server, - programsByServer[server], - ); - if (result) { - ret[ProgramSourceType.PLEX] = mergeGroupings( - ret[ProgramSourceType.PLEX], - result, - ); - } - } - } - break; - } - } - - return ret; - } - - async findAndUpdatePlexServerPrograms( - plexServerName: string, - programs: ContentProgram[], - ) { - if (programs.length === 0) { - return; - } - - const logger = LoggerFactory.root; - const em = getEm().fork(); - - const plexServer = await em.findOne(PlexServerSettings, { - name: plexServerName, - }); - - if (isNil(plexServer)) { - // Rate limit this potentially noisy log - logger.warn( - 'Could not find server %s when attempting to update hierarchy', - plexServerName, - ); - return; - } - - const plexApi = PlexApiFactory().get(plexServer); - - const parentIdsByGrandparent = ld - .chain(programs) - .map('originalProgram') - .compact() - .map((p) => - (p.type === 'episode' || p.type === 'track') && - isNonEmptyString(p.grandparentRatingKey) && - isNonEmptyString(p.parentRatingKey) - ? ([p.grandparentRatingKey, p.parentRatingKey] as const) - : null, - ) - .compact() - .reduce( - (prev, [grandparent, parent]) => { - const last = prev[grandparent]; - if (last) { - return { ...prev, [grandparent]: last.add(parent) }; - } else { - return { ...prev, [grandparent]: new Set([parent]) }; - } - }, - {} as Record>, - ) - .value(); - - const grandparentsByParentId = flipMap(parentIdsByGrandparent); - - const allIds = ld - .chain(programs) - .map('originalProgram') - .filter( - (p): p is PlexEpisode | PlexMusicTrack => - isPlexEpisode(p) || isPlexMusicTrack(p), - ) - .flatMap((p) => [p.grandparentRatingKey, p.parentRatingKey]) - .uniq() - .compact() - .value(); - - const existingGroupings = flatten( - await mapAsyncSeq( - chunk(allIds, 25), - (idChunk) => { - const ors = map( - idChunk, - (id) => - ({ - sourceType: ProgramExternalIdType.PLEX, - externalKey: id, - externalSourceId: plexServerName, - }) as const, - ); - - return em.find( - ProgramGrouping, - { - externalRefs: { - $or: ors, - }, - }, - { - populate: ['externalRefs'], - fields: ['uuid', 'type'], - }, - ); - }, - { parallelism: 2 }, - ), - ); - - const existingGroupingsByType = reduce( - existingGroupings, - (prev, curr) => { - const existing = prev[curr.type] ?? []; - return { - ...prev, - [curr.type]: [...existing, curr], - }; - }, - makeEmptyGroupMap< - Loaded - >(), - ); - - const existingGroupingsByPlexIdByType = mapValues( - existingGroupingsByType, - (groupings) => - groupByUniqFunc( - groupings, - (eg) => - // This must exist because we just queried on it above - eg.externalRefs.find( - (er) => - er.sourceType === ProgramExternalIdType.PLEX && - er.externalSourceId === plexServerName, - )!.externalKey, - ), - ); - - const existingGroupingsByPlexId = reduce( - values(existingGroupingsByPlexIdByType), - (prev, curr) => ({ ...prev, ...curr }), - {} as Record< - string, - Loaded - >, - ); - - // 1. Accumulate different types of groupings - // 2. Check for dupes - // 3. Inter-relate them (shows<=>seasons, artist<=>album) - // 4. Persist them - // 5. Return mapping of the new or existing IDs to the previous function - // and update the mappings of the programs... - const newGroupings = await mapReduceAsyncSeq( - reject(allIds, (id) => has(existingGroupingsByPlexId, id) || isEmpty(id)), - async (id) => { - const metadata = await plexApi.doGet< - | PlexLibraryShows - | PlexSeasonView - | PlexLibraryMusic - | PlexMusicAlbumView - >(`/library/metadata/${id}`); - if (!isNil(metadata) && !isEmpty(metadata.Metadata)) { - const item = metadata.Metadata[0]; - - let grouping: ProgramGrouping; - const baseFields: Pick< - ProgramGrouping, - 'title' | 'summary' | 'icon' - > = { - title: item.title, - summary: item.summary, - icon: item.thumb, - }; - switch (item.type) { - // TODO Common function to mint a ProgramGrouping - case 'show': - grouping = em.create(ProgramGrouping, { - ...baseFields, - type: ProgramGroupingType.TvShow, - }); - break; - case 'season': - grouping = em.create(ProgramGrouping, { - ...baseFields, - type: ProgramGroupingType.TvShowSeason, - index: item.index, - }); - break; - case 'artist': - grouping = em.create(ProgramGrouping, { - ...baseFields, - type: ProgramGroupingType.MusicArtist, - index: item.index, - }); - break; - case 'album': - grouping = em.create(ProgramGrouping, { - ...baseFields, - type: ProgramGroupingType.MusicAlbum, - index: item.index, - }); - break; - } - - if (isUndefined(grouping)) { - return; - } - - const ref = em.create(ProgramGroupingExternalId, { - externalKey: item.ratingKey, - externalSourceId: plexServerName, - sourceType: ProgramExternalIdType.PLEX, - group: grouping, - }); - - grouping.externalRefs.add(ref); - em.persist([grouping, ref]); - - return grouping; - } - return; - }, - (prev, curr) => { - if (isUndefined(curr)) { - return prev; - } - - return { - ...prev, - [curr.type]: concat(prev[curr.type], curr), - }; - }, - makeEmptyGroupMap(), - { - parallelism: 2, - ms: 50, - }, - ); - - await em.flush(); - - // All new groupings will have exactly one externalRef already initialized - const newGroupingsByPlexIdByType = mapValues( - newGroupings, - (groupingsOfType) => - mapValues( - groupByUniqFunc( - groupingsOfType, - (grouping) => grouping.externalRefs[0].externalKey, - ), - (group) => group.uuid, - ), - ); - - function associateNewGroupings( - parentType: ProgramGroupingType, - childType: ProgramGroupingType, - relation: 'seasons' | 'albums', - ) { - forEach(newGroupings[parentType], (parentGrouping) => { - // New groupings will have exactly one externalKey right now - const plexId = parentGrouping.externalRefs[0].externalKey; - const parentIds = [...(parentIdsByGrandparent[plexId] ?? new Set())]; - const childGroupIds = map( - parentIds, - (id) => - existingGroupingsByPlexIdByType[childType][id]?.uuid ?? - newGroupingsByPlexIdByType[childType][id], - ); - parentGrouping[relation].set( - map(childGroupIds, (id) => em.getReference(ProgramGrouping, id)), - ); - }); - } - - function associateExistingGroupings( - parentType: ProgramGroupingType, - expectedGrandparent: ProgramGroupingType, - relation: 'show' | 'artist', - ) { - forEach(newGroupings[parentType], (grouping) => { - const grandparentId = - grandparentsByParentId[grouping.externalRefs[0].externalKey]; - if (isNonEmptyString(grandparentId)) { - ifDefined(existingGroupingsByPlexId[grandparentId], (gparent) => { - // Extra check just in case - if (gparent.type === expectedGrandparent) { - grouping[relation] = em.getReference( - ProgramGrouping, - gparent.uuid, - ); - } - }); - } - }); - } - - // Associate newly seen shows and artists to their - // season and album counterparts. We should never have a - // situation where we are seeing a show for the first time without - // any associated seasons. The opposite is not true though, we update - // new seasons/albums to their parents below. - associateNewGroupings( - ProgramGroupingType.TvShow, - ProgramGroupingType.TvShowSeason, - 'seasons', - ); - associateNewGroupings( - ProgramGroupingType.MusicArtist, - ProgramGroupingType.MusicAlbum, - 'albums', - ); - - associateExistingGroupings( - ProgramGroupingType.TvShowSeason, - ProgramGroupingType.TvShow, - 'show', - ); - associateExistingGroupings( - ProgramGroupingType.MusicAlbum, - ProgramGroupingType.MusicArtist, - 'artist', - ); - - await em.flush(); - - const finalMap: Record = - makeEmptyGroupMap(); - - forEach(existingGroupings, (grouping) => { - finalMap[grouping.type] = [ - ...finalMap[grouping.type], - { - uuid: grouping.uuid, - externalKey: grouping.externalRefs[0].externalKey, - externalSourceId: plexServerName, - }, - ]; - }); - - forEach(newGroupings, (groups, type) => { - finalMap[type] = [ - ...finalMap[type as ProgramGroupingType], - ...map(groups, (grouping) => ({ - uuid: grouping.uuid, - externalKey: grouping.externalRefs[0].externalKey, - externalSourceId: plexServerName, - })), - ]; - }); - - return finalMap; - } -} - -function makeEmptyGroupMap(): Record { - return { - [ProgramGroupingType.MusicAlbum]: [], - [ProgramGroupingType.MusicArtist]: [], - [ProgramGroupingType.TvShow]: [], - [ProgramGroupingType.TvShowSeason]: [], - }; -} - -function mergeGroupings( - l: Record, - r: Record, -): Record { - return reduce( - r, - (prev, curr, key) => ({ - ...prev, - [key]: [...prev[key as ProgramGroupingType], ...curr], - }), - l, - ); -} diff --git a/server/src/dao/ProgramGroupingCalculator.ts b/server/src/dao/ProgramGroupingCalculator.ts index f3d182e3..b6e6a81c 100644 --- a/server/src/dao/ProgramGroupingCalculator.ts +++ b/server/src/dao/ProgramGroupingCalculator.ts @@ -13,24 +13,31 @@ import { flatten, forEach, isEmpty, + isUndefined, map, partition, } from 'lodash-es'; -import { PlexQueryResult } from '../external/plex.js'; -import { PlexApiFactory } from '../external/PlexApiFactory.js'; +import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js'; import { Maybe } from '../types/util.js'; import { asyncPool } from '../util/asyncPool.js'; -import { groupByUniqAndMap, mapAsyncSeq } from '../util/index.js'; +import { + groupByUniqAndMap, + mapAsyncSeq, + nullToUndefined, +} from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js'; import { getEm } from './dataSource.js'; import { ProgramType } from './entities/Program.js'; import { ProgramGrouping, + programGroupingTypeForJellyfinType, programGroupingTypeForString, } from './entities/ProgramGrouping.js'; import { ProgramGroupingExternalId } from './entities/ProgramGroupingExternalId.js'; import { ProgramDB } from './programDB.js'; +import { QueryResult } from '../external/BaseApiClient.js'; +import { JellyfinItem, isJellyfinType } from '@tunarr/types/jellyfin'; export class ProgramGroupingCalculator { #logger = LoggerFactory.child({ className: ProgramGroupingCalculator.name }); @@ -134,7 +141,7 @@ export class ProgramGroupingCalculator { ); } - const plexApi = await PlexApiFactory().getOrSet(plexServerName); + const plexApi = await MediaSourceApiFactory().getOrSet(plexServerName); if (!plexApi) { return; @@ -245,6 +252,220 @@ export class ProgramGroupingCalculator { await em.flush(); } + async createHierarchyForManyFromJellyfin( + programType: ProgramType, + jellyfinServerName: string, + programIds: { + programId: string; + jellyfinItemId: string; + parentKey: string; + }[], + parentKeys: string[], + grandparentKey: string, + ) { + if ( + programType !== ProgramType.Episode && + programType !== ProgramType.Track + ) { + return; + } + + const parentKeyByProgramId = groupByUniqAndMap( + programIds, + 'programId', + ({ parentKey }) => parentKey, + ); + + const programs = await this.programDB.getProgramsByIds( + map(programIds, 'programId'), + ); + + const em = getEm(); + + const existingParents = flatten( + await mapAsyncSeq( + chunk([...parentKeys], 25), + (chunk) => { + const ors = map( + chunk, + (id) => + ({ + sourceType: ProgramExternalIdType.JELLYFIN, + externalKey: id, + externalSourceId: jellyfinServerName, + }) as const, + ); + + return em.find( + ProgramGrouping, + { + externalRefs: { + $or: ors, + }, + }, + { + populate: ['externalRefs'], + fields: ['uuid', 'type'], + }, + ); + }, + { parallelism: 2 }, + ), + ); + + const grandparent = await em.findOne( + ProgramGrouping, + { + externalRefs: { + sourceType: ProgramExternalIdType.JELLYFIN, + externalKey: grandparentKey, + externalSourceId: jellyfinServerName, + }, + }, + { + populate: [ + 'externalRefs', + 'seasons:ref', + 'showEpisodes:ref', + 'albums:ref', + 'albumTracks:ref', + ], + fields: ['uuid', 'type'], + }, + ); + + if (isEmpty(programs)) { + return; + } + + const [validPrograms, invalidPrograms] = partition( + programs, + (p) => p.type === programType, + ); + + if (isEmpty(validPrograms)) { + return; + } else if (invalidPrograms.length > 0) { + this.#logger.debug( + "Found %d programs that don't have the correct type: %O", + invalidPrograms.length, + invalidPrograms, + ); + } + + const jellyfinApi = + await MediaSourceApiFactory().getJellyfinByName(jellyfinServerName); + + if (!jellyfinApi) { + return; + } + + const maybeGrandparentAndRef = this.handleJellyfinGrouping( + grandparent, + await jellyfinApi.getItem(grandparentKey), + jellyfinServerName, + ); + let newOrUpdatedGrandparent: Maybe; + if (maybeGrandparentAndRef) { + newOrUpdatedGrandparent = maybeGrandparentAndRef[0]; + } + + const newOrUpdatedParents: ProgramGrouping[] = []; + const parentGroupingsByRef: Record = {}; + // TODO Jellyfin supports getting items by ID in batch + for await (const jellyfinResult of asyncPool( + parentKeys, + (key) => jellyfinApi.getItem(key), + { concurrency: 2 }, + )) { + if (jellyfinResult.type === 'error') { + this.#logger.error( + jellyfinResult.error, + 'Error querying Plex for item: %s', + jellyfinResult.input, + ); + continue; + } + + const existing = find(existingParents, (parent) => + parent.externalRefs.exists( + (ref) => + ref.externalKey === jellyfinResult.input && + ref.sourceType === ProgramExternalIdType.JELLYFIN && + ref.externalSourceId === jellyfinServerName, + ), + ); + const daos = this.handleJellyfinGrouping( + existing ?? null, + jellyfinResult.result, + jellyfinServerName, + ); + if (daos) { + const [grouping, ref] = daos; + newOrUpdatedParents.push(grouping); + parentGroupingsByRef[ref.toExternalIdString()] = grouping.uuid; + } + } + + const entities = [ + ...(newOrUpdatedGrandparent ? [newOrUpdatedGrandparent] : []), + ...newOrUpdatedParents, + ]; + + await em.transactional((em) => + em.upsertMany(ProgramGrouping, entities, { + batchSize: 50, + onConflictFields: ['uuid'], + onConflictAction: 'merge', + onConflictExcludeFields: ['uuid'], + }), + ); + + // Create the relations... + forEach(programs, (program) => { + switch (program.type) { + case ProgramType.Movie: + break; + case ProgramType.Episode: { + if (newOrUpdatedGrandparent) { + program.tvShow = ref(newOrUpdatedGrandparent); + } + const parentKey = parentKeyByProgramId[program.uuid]; + if (parentKey) { + const grouping = + parentGroupingsByRef[ + createExternalId('jellyfin', jellyfinServerName, parentKey) + ]; + if (grouping) { + program.season = ref(em.getReference(ProgramGrouping, grouping)); + } + } + break; + } + case ProgramType.Track: { + if (newOrUpdatedGrandparent) { + program.artist = ref(newOrUpdatedGrandparent); + } + const parentKey = parentKeyByProgramId[program.uuid]; + if (parentKey) { + const grouping = + parentGroupingsByRef[ + createExternalId('jellyfin', jellyfinServerName, parentKey) + ]; + if (grouping) { + program.album = ref(em.getReference(ProgramGrouping, grouping)); + } + } + break; + } + default: + break; + } + }); + + await em.flush(); + } + private handlePlexGrouping( existing: Loaded< ProgramGrouping, @@ -252,7 +473,7 @@ export class ProgramGroupingCalculator { 'uuid' | 'type', never > | null, - queryResult: PlexQueryResult, + queryResult: QueryResult, plexServerName: string, ): Maybe<[ProgramGrouping, ProgramGroupingExternalId]> { if (queryResult.type === 'error') { @@ -338,4 +559,94 @@ export class ProgramGroupingCalculator { return [entity, ref] as const; } + + private handleJellyfinGrouping( + existing: Loaded< + ProgramGrouping, + 'externalRefs', + 'uuid' | 'type', + never + > | null, + queryResult: QueryResult>, + jellyfinServerName: string, + ): Maybe<[ProgramGrouping, ProgramGroupingExternalId]> { + if (queryResult.type === 'error') { + this.#logger.error( + 'Error requesting item from Jellyfin: %O %s', + queryResult.code, + queryResult.message ?? '', + ); + return; + } else if (isUndefined(queryResult.data)) { + this.#logger.error('Item not found in Jellyfin'); + } + + const item = queryResult.data!; + if (!isJellyfinType(item, ['Audio', 'Season', 'Series', 'MusicAlbum'])) { + this.#logger.error( + 'Requested Jellyfin item was not a valid grouping type. Got: %s for key %s', + item.Type, + item.Id, + ); + return; + } + + if ( + existing && + existing.type !== programGroupingTypeForJellyfinType(item.Type) + ) { + this.#logger.error( + 'Program grouping type mismatch: %s existing and %s incoming. Logic error', + existing.type, + item.Type, + ); + return; + } + + const baseFields: Pick = { + title: item.Name ?? '', + summary: nullToUndefined(item.Overview), + }; + + const em = getEm(); + const entity = em.create( + ProgramGrouping, + { + ...baseFields, + type: programGroupingTypeForJellyfinType(item.Type)!, + index: item.IndexNumber, + year: item.ProductionYear, + }, + { persist: false }, + ); + + const ref = em.create( + ProgramGroupingExternalId, + { + externalKey: item.Id, + externalSourceId: jellyfinServerName, + sourceType: ProgramExternalIdType.JELLYFIN, + group: entity, + }, + { persist: false }, + ); + + if (existing) { + entity.uuid = existing.uuid; + if ( + !existing.externalRefs.exists( + (er) => + er.externalKey === item.Id && + er.externalSourceId === jellyfinServerName && + er.sourceType === ProgramExternalIdType.JELLYFIN, + ) + ) { + entity.externalRefs.add(ref); + } + } else { + entity.externalRefs.add(ref); + } + + return [entity, ref] as const; + } } diff --git a/server/src/dao/custom_types/ProgramExternalIdType.ts b/server/src/dao/custom_types/ProgramExternalIdType.ts index 575a5c07..b0de6da2 100644 --- a/server/src/dao/custom_types/ProgramExternalIdType.ts +++ b/server/src/dao/custom_types/ProgramExternalIdType.ts @@ -1,5 +1,7 @@ import { ExternalIdType } from '@tunarr/types/schemas'; import { enumKeys } from '../../util/enumUtil.js'; +import { ProgramSourceType } from './ProgramSourceType.js'; +import { MediaSourceType } from '../entities/MediaSource.js'; export enum ProgramExternalIdType { PLEX = 'plex', @@ -7,6 +9,7 @@ export enum ProgramExternalIdType { TMDB = 'tmdb', IMDB = 'imdb', TVDB = 'tvdb', + JELLYFIN = 'jellyfin', } export function programExternalIdTypeFromExternalIdType( @@ -15,6 +18,30 @@ export function programExternalIdTypeFromExternalIdType( return programExternalIdTypeFromString(str)!; } +export function programExternalIdTypeFromSourceType( + src: ProgramSourceType, +): ProgramExternalIdType { + switch (src) { + case ProgramSourceType.PLEX: + return ProgramExternalIdType.PLEX; + case ProgramSourceType.JELLYFIN: + return ProgramExternalIdType.JELLYFIN; + } +} + +export function programExternalIdTypeToMediaSourceType( + src: ProgramExternalIdType, +) { + switch (src) { + case ProgramExternalIdType.PLEX: + return MediaSourceType.Plex; + case ProgramExternalIdType.JELLYFIN: + return MediaSourceType.Jellyfin; + default: + return; + } +} + export function programExternalIdTypeFromString( str: string, ): ProgramExternalIdType | undefined { @@ -26,3 +53,16 @@ export function programExternalIdTypeFromString( } return; } + +export function programExternalIdTypeFromJellyfinProvider(provider: string) { + switch (provider.toLowerCase()) { + case 'tmdb': + return ProgramExternalIdType.TMDB; + case 'imdb': + return ProgramExternalIdType.IMDB; + case 'tvdb': + return ProgramExternalIdType.TVDB; + default: + return null; + } +} diff --git a/server/src/dao/custom_types/ProgramSourceType.ts b/server/src/dao/custom_types/ProgramSourceType.ts index 5c22e1eb..53f06beb 100644 --- a/server/src/dao/custom_types/ProgramSourceType.ts +++ b/server/src/dao/custom_types/ProgramSourceType.ts @@ -1,7 +1,9 @@ import { enumKeys } from '../../util/enumUtil.js'; +import { MediaSourceType } from '../entities/MediaSource.js'; export enum ProgramSourceType { PLEX = 'plex', + JELLYFIN = 'jellyfin', } export function programSourceTypeFromString( @@ -15,3 +17,21 @@ export function programSourceTypeFromString( } return; } + +export function programSourceTypeToMediaSource(src: ProgramSourceType) { + switch (src) { + case ProgramSourceType.PLEX: + return MediaSourceType.Plex; + case ProgramSourceType.JELLYFIN: + return MediaSourceType.Jellyfin; + } +} + +export function programSourceTypeFromMediaSource(src: MediaSourceType) { + switch (src) { + case MediaSourceType.Plex: + return ProgramSourceType.PLEX; + case MediaSourceType.Jellyfin: + return ProgramSourceType.JELLYFIN; + } +} diff --git a/server/src/dao/derived_types/StreamLineup.ts b/server/src/dao/derived_types/StreamLineup.ts index 23458ca7..9b227d0d 100644 --- a/server/src/dao/derived_types/StreamLineup.ts +++ b/server/src/dao/derived_types/StreamLineup.ts @@ -3,6 +3,7 @@ // active streaming session import { z } from 'zod'; +import { MediaSourceType } from '../entities/MediaSource.js'; const baseStreamLineupItemSchema = z.object({ originalTimestamp: z.number().nonnegative().optional(), @@ -63,6 +64,7 @@ const ProgramTypeEnum = z.enum(['movie', 'episode', 'track']); const BaseContentBackedStreamLineupItemSchema = baseStreamLineupItemSchema.extend({ + // ID in the program DB table programId: z.string().uuid(), // These are taken from the Program DB entity plexFilePath: z.string().optional(), @@ -70,6 +72,7 @@ const BaseContentBackedStreamLineupItemSchema = filePath: z.string().optional(), externalKey: z.string(), programType: ProgramTypeEnum, + externalSource: z.nativeEnum(MediaSourceType), }); const CommercialStreamLineupItemSchema = diff --git a/server/src/dao/direct/programQueryHelpers.ts b/server/src/dao/direct/programQueryHelpers.ts index c2109a13..10c887e3 100644 --- a/server/src/dao/direct/programQueryHelpers.ts +++ b/server/src/dao/direct/programQueryHelpers.ts @@ -153,7 +153,7 @@ const defaultProgramJoins: ProgramJoins = { type ProgramFields = readonly `program.${keyof RawProgram}`[]; -const AllProgramFields: ProgramFields = [ +export const AllProgramFields: ProgramFields = [ 'program.albumName', 'program.albumUuid', 'program.artistName', diff --git a/server/src/dao/entities/MediaSource.ts b/server/src/dao/entities/MediaSource.ts new file mode 100644 index 00000000..95f60c76 --- /dev/null +++ b/server/src/dao/entities/MediaSource.ts @@ -0,0 +1,60 @@ +import { Entity, Enum, Property, Unique } from '@mikro-orm/core'; +import { MediaSourceSettings, tag } from '@tunarr/types'; +import { BaseEntity } from './BaseEntity.js'; + +export enum MediaSourceType { + Plex = 'plex', + Jellyfin = 'jellyfin', +} + +@Entity() +@Unique({ properties: ['type', 'name', 'uri'] }) +export class MediaSource extends BaseEntity { + @Enum({ items: () => MediaSourceType, default: MediaSourceType.Plex }) + type!: MediaSourceType; + + @Property() + name!: string; + + @Property() + uri!: string; + + @Property() + accessToken!: string; + + @Property({ default: true }) + sendGuideUpdates!: boolean; + + @Property({ default: true }) + sendChannelUpdates!: boolean; + + @Property() + index: number; + + // Nullable for now! + @Property({ nullable: true }) + clientIdentifier?: string; + + toDTO(): MediaSourceSettings { + return { + id: tag(this.uuid), + name: this.name, + uri: this.uri, + accessToken: this.accessToken, + sendChannelUpdates: this.sendChannelUpdates, + sendGuideUpdates: this.sendGuideUpdates, + index: this.index, + clientIdentifier: this.clientIdentifier, + type: this.type, + }; + } +} + +export function mediaSourceTypeFromApi(f: MediaSourceSettings['type']) { + switch (f) { + case 'plex': + return MediaSourceType.Plex; + case 'jellyfin': + return MediaSourceType.Jellyfin; + } +} diff --git a/server/src/dao/entities/PlexServerSettings.ts b/server/src/dao/entities/PlexServerSettings.ts deleted file mode 100644 index 7b920de0..00000000 --- a/server/src/dao/entities/PlexServerSettings.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Entity, Property, Unique } from '@mikro-orm/core'; -import { PlexServerSettings as PlexServerSettingsDTO } from '@tunarr/types'; -import { BaseEntity } from './BaseEntity.js'; - -@Entity() -@Unique({ properties: ['name', 'uri'] }) -export class PlexServerSettings extends BaseEntity { - @Property() - name!: string; - - @Property() - uri!: string; - - @Property() - accessToken!: string; - - @Property({ default: true }) - sendGuideUpdates!: boolean; - - @Property({ default: true }) - sendChannelUpdates!: boolean; - - @Property() - index: number; - - // Nullable for now! - @Property({ nullable: true }) - clientIdentifier?: string; - - toDTO(): PlexServerSettingsDTO { - return { - id: this.uuid, - name: this.name, - uri: this.uri, - accessToken: this.accessToken, - sendChannelUpdates: this.sendChannelUpdates, - sendGuideUpdates: this.sendGuideUpdates, - index: this.index, - clientIdentifier: this.clientIdentifier, - }; - } -} diff --git a/server/src/dao/entities/Program.ts b/server/src/dao/entities/Program.ts index 19a41c62..2e59caad 100644 --- a/server/src/dao/entities/Program.ts +++ b/server/src/dao/entities/Program.ts @@ -22,6 +22,7 @@ import { CustomShow } from './CustomShow.js'; import { FillerShow } from './FillerShow.js'; import { ProgramExternalId } from './ProgramExternalId.js'; import { ProgramGrouping } from './ProgramGrouping.js'; +import { JellyfinItemKind } from '@tunarr/types/jellyfin'; /** * Program represents a 'playable' entity. A movie, episode, or music track @@ -259,3 +260,17 @@ export function programTypeFromString(str: string): ProgramType | undefined { } return; } + +export function programTypeFromJellyfinType( + kind: JellyfinItemKind, +): ProgramType | undefined { + switch (kind) { + case 'Audio': + return ProgramType.Track; + case 'Episode': + return ProgramType.Episode; + case 'Movie': + return ProgramType.Movie; + } + return; +} diff --git a/server/src/dao/entities/ProgramExternalId.ts b/server/src/dao/entities/ProgramExternalId.ts index 7bfeffb0..390195ad 100644 --- a/server/src/dao/entities/ProgramExternalId.ts +++ b/server/src/dao/entities/ProgramExternalId.ts @@ -9,6 +9,7 @@ import { isNonEmptyString } from '../../util/index.js'; import { ProgramExternalIdType } from '../custom_types/ProgramExternalIdType.js'; import { BaseEntity } from './BaseEntity.js'; import { Program } from './Program.js'; +import { createExternalId } from '@tunarr/shared'; /** * References to external sources for a {@link Program} @@ -30,7 +31,7 @@ import { Program } from './Program.js'; name: 'unique_program_multi_external_id', properties: ['program', 'sourceType', 'externalSourceId'], expression: - 'create unique index `unique_program_multiple_external_id` on `program_external_id` (`program_uuid`, `source_type`) WHERE `external_source_id` IS NOT NULL', + 'create unique index `unique_program_multiple_external_id` on `program_external_id` (`program_uuid`, `source_type`, `external_source_id`) WHERE `external_source_id` IS NOT NULL', }) export class ProgramExternalId extends BaseEntity { @Enum(() => ProgramExternalIdType) @@ -88,9 +89,11 @@ export class ProgramExternalId extends BaseEntity { } toExternalIdString(): string { - return `${this.sourceType.toString()}|${this.externalSourceId ?? ''}|${ - this.externalKey - }`; + return createExternalId( + this.sourceType, + this.externalSourceId ?? '', + this.externalKey, + ); } toKnexInsertData() { diff --git a/server/src/dao/entities/ProgramGrouping.ts b/server/src/dao/entities/ProgramGrouping.ts index abb8a44b..7d118be8 100644 --- a/server/src/dao/entities/ProgramGrouping.ts +++ b/server/src/dao/entities/ProgramGrouping.ts @@ -11,6 +11,7 @@ import { ProgramGroupingExternalId } from './ProgramGroupingExternalId.js'; import { Maybe } from '../../types/util.js'; import { find } from 'lodash-es'; import { enumKeys } from '../../util/enumUtil.js'; +import { JellyfinItemKind } from '@tunarr/types/jellyfin'; /** * A ProgramGrouping represents some logical collection of Programs. @@ -89,3 +90,20 @@ export function programGroupingTypeForString( } return; } + +export function programGroupingTypeForJellyfinType( + t: JellyfinItemKind, +): Maybe { + switch (t) { + case 'Season': + return ProgramGroupingType.TvShowSeason; + case 'Series': + return ProgramGroupingType.TvShow; + case 'MusicArtist': + return ProgramGroupingType.MusicArtist; + case 'MusicAlbum': + return ProgramGroupingType.MusicAlbum; + default: + return; + } +} diff --git a/server/src/dao/fillerDb.ts b/server/src/dao/fillerDB.ts similarity index 100% rename from server/src/dao/fillerDb.ts rename to server/src/dao/fillerDB.ts diff --git a/server/src/dao/legacy_migration/legacyDbMigration.ts b/server/src/dao/legacy_migration/legacyDbMigration.ts index 5e42985d..1e1be852 100644 --- a/server/src/dao/legacy_migration/legacyDbMigration.ts +++ b/server/src/dao/legacy_migration/legacyDbMigration.ts @@ -31,7 +31,7 @@ import { sortBy, } from 'lodash-es'; import path from 'path'; -import { PlexApiFactory } from '../../external/PlexApiFactory.js'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js'; import { globalOptions } from '../../globals.js'; import { serverContext } from '../../serverContext.js'; import { GlobalScheduler } from '../../services/scheduler.js'; @@ -41,7 +41,7 @@ import { attempt } from '../../util/index.js'; import { LoggerFactory } from '../../util/logging/LoggerFactory.js'; import { EntityManager, withDb } from '../dataSource.js'; import { CachedImage } from '../entities/CachedImage.js'; -import { PlexServerSettings as PlexServerSettingsEntity } from '../entities/PlexServerSettings.js'; +import { MediaSource as PlexServerSettingsEntity } from '../entities/MediaSource.js'; import { Settings, SettingsDB, defaultXmlTvSettings } from '../settings.js'; import { LegacyChannelMigrator, @@ -320,9 +320,9 @@ export class LegacyDbMigrator { // will take care of that -- we may want to do it here if we want // to remove the fixer eventually, though. for (const entity of entities) { - const plexApi = PlexApiFactory().get(entity); - const status = await plexApi.checkServerStatus(); - if (status === 1) { + const plexApi = MediaSourceApiFactory().get(entity); + const healthy = await plexApi.checkServerStatus(); + if (healthy) { this.logger.debug( 'Plex server name: %s url: %s healthy', entity.name, diff --git a/server/src/dao/legacy_migration/metadataBackfill.ts b/server/src/dao/legacy_migration/metadataBackfill.ts index 06e40c00..5cc7efae 100644 --- a/server/src/dao/legacy_migration/metadataBackfill.ts +++ b/server/src/dao/legacy_migration/metadataBackfill.ts @@ -12,13 +12,13 @@ import { PlexTvShow, } from '@tunarr/types/plex'; import { first, groupBy, isNil, isNull, isUndefined, keys } from 'lodash-es'; -import { Plex } from '../../external/plex'; -import { PlexApiFactory } from '../../external/PlexApiFactory'; +import { PlexApiClient } from '../../external/plex/PlexApiClient'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory'; import { isNonEmptyString, wait } from '../../util'; import { LoggerFactory } from '../../util/logging/LoggerFactory'; import { ProgramSourceType } from '../custom_types/ProgramSourceType'; import { getEm } from '../dataSource'; -import { PlexServerSettings } from '../entities/PlexServerSettings'; +import { MediaSource } from '../entities/MediaSource'; import { Program, ProgramType } from '../entities/Program'; import { ProgramGrouping, @@ -84,7 +84,7 @@ export class LegacyMetadataBackfiller { programs: Program[], ) { const em = getEm(); - const server = await em.findOne(PlexServerSettings, { name: serverName }); + const server = await em.findOne(MediaSource, { name: serverName }); if (isNil(server)) { this.logger.warn( 'Could not find plex server details for server %s', @@ -163,14 +163,14 @@ export class LegacyMetadataBackfiller { } // Otherwise, we need to go and find details... - const plex = PlexApiFactory().get(server); + const plex = MediaSourceApiFactory().get(server); // This where the types have to diverge, because the Plex // API types differ. if (type === ProgramType.Episode) { // Lookup the episode in Plex - const plexResult = await plex.doGet( + const plexResult = await plex.doGetPath( '/library/metadata/' + externalKey, ); @@ -197,10 +197,10 @@ export class LegacyMetadataBackfiller { // These will be used on subsequent iterations to identify matches // without hitting Plex. if (!isUndefined(show)) { - const seasons = await plex.doGet(show.key); + const seasons = await plex.doGetPath(show.key); if (!isUndefined(seasons?.Metadata)) { for (const season of seasons.Metadata) { - const seasonEpisodes = await plex.doGet( + const seasonEpisodes = await plex.doGetPath( season.key, ); if (!isUndefined(seasonEpisodes?.Metadata)) { @@ -302,7 +302,7 @@ export class LegacyMetadataBackfiller { } } else { // Lookup the episode in Plex - const plexResult = await plex.doGet( + const plexResult = await plex.doGetPath( '/library/metadata/' + externalKey, ); @@ -329,10 +329,10 @@ export class LegacyMetadataBackfiller { // These will be used on subsequent iterations to identify matches // without hitting Plex. if (!isUndefined(artist)) { - const albums = await plex.doGet(artist.key); + const albums = await plex.doGetPath(artist.key); if (!isUndefined(albums?.Metadata)) { for (const album of albums.Metadata) { - const albumTracks = await plex.doGet( + const albumTracks = await plex.doGetPath( album.key, ); if (!isUndefined(albumTracks?.Metadata)) { @@ -443,7 +443,7 @@ export class LegacyMetadataBackfiller { Metadata: InferredMetadataType[]; }, >( - plex: Plex, + plex: PlexApiClient, ratingKey: string, cb: (item: InferredMetadataType) => ProgramGrouping | undefined, ) { @@ -485,8 +485,11 @@ export class LegacyMetadataBackfiller { InferredPlexType extends { Metadata: InferredMetadataType[] } = { Metadata: InferredMetadataType[]; }, - >(plex: Plex, ratingKey: string): Promise { - const plexResult = await plex.doGet( + >( + plex: PlexApiClient, + ratingKey: string, + ): Promise { + const plexResult = await plex.doGetPath( '/library/metadata/' + ratingKey, ); diff --git a/server/src/dao/plexServerDb.ts b/server/src/dao/mediaSourceDB.ts similarity index 60% rename from server/src/dao/plexServerDb.ts rename to server/src/dao/mediaSourceDB.ts index c310c29a..3840b430 100644 --- a/server/src/dao/plexServerDb.ts +++ b/server/src/dao/mediaSourceDB.ts @@ -1,13 +1,20 @@ import { - InsertPlexServerRequest, - UpdatePlexServerRequest, + InsertMediaSourceRequest, + UpdateMediaSourceRequest, } from '@tunarr/types/api'; import ld, { isNil, isUndefined, keys, map, mapValues } from 'lodash-es'; import { groupByUniq } from '../util/index.js'; import { ChannelDB } from './channelDb.js'; -import { ProgramSourceType } from './custom_types/ProgramSourceType.js'; +import { + ProgramSourceType, + programSourceTypeFromMediaSource, +} from './custom_types/ProgramSourceType.js'; import { getEm } from './dataSource.js'; -import { PlexServerSettings as PlexServerSettingsEntity } from './entities/PlexServerSettings.js'; +import { + MediaSource, + MediaSourceType, + mediaSourceTypeFromApi, +} from './entities/MediaSource.js'; import { Program } from './entities/Program.js'; //hmnn this is more of a "PlexServerService"... @@ -22,7 +29,8 @@ type Report = { destroyedPrograms: number; modifiedPrograms: number; }; -export class PlexServerDB { + +export class MediaSourceDB { #channelDb: ChannelDB; constructor(channelDb: ChannelDB) { @@ -31,25 +39,33 @@ export class PlexServerDB { async getAll() { const em = getEm(); - return em.repo(PlexServerSettingsEntity).findAll(); + return em.repo(MediaSource).findAll(); } async getById(id: string) { - return getEm().repo(PlexServerSettingsEntity).findOne({ uuid: id }); + return getEm().repo(MediaSource).findOne({ uuid: id }); } - async getByExternalid(nameOrClientId: string) { + async getByExternalId(sourceType: MediaSourceType, nameOrClientId: string) { return getEm() - .repo(PlexServerSettingsEntity) + .repo(MediaSource) .findOne({ - $or: [{ name: nameOrClientId }, { clientIdentifier: nameOrClientId }], + $and: [ + { + $or: [ + { name: nameOrClientId }, + { clientIdentifier: nameOrClientId }, + ], + }, + { type: sourceType }, + ], }); } - async deleteServer(id: string, removePrograms: boolean = true) { + async deleteMediaSource(id: string, removePrograms: boolean = true) { const deletedServer = await getEm().transactional(async (em) => { - const ref = em.getReference(PlexServerSettingsEntity, id); - const existing = await em.findOneOrFail(PlexServerSettingsEntity, ref, { + const ref = em.getReference(MediaSource, id); + const existing = await em.findOneOrFail(MediaSource, ref, { populate: ['uuid', 'name'], }); em.remove(ref); @@ -60,39 +76,51 @@ export class PlexServerDB { if (!removePrograms) { reports = []; } else { - reports = await this.fixupProgramReferences(deletedServer.name); + reports = await this.fixupProgramReferences( + deletedServer.name, + programSourceTypeFromMediaSource(deletedServer.type), + ); } return { deletedServer, reports }; } - async updateServer(server: UpdatePlexServerRequest) { + async updateMediaSource(server: UpdateMediaSourceRequest) { const em = getEm(); - const repo = em.repo(PlexServerSettingsEntity); + const repo = em.repo(MediaSource); const id = server.id; if (isNil(id)) { throw Error('Missing server id from request'); } - const s = await repo.findOne(id); + const s = await repo.findOne({ uuid: id }); if (isNil(s)) { throw Error("Server doesn't exist."); } + const sendGuideUpdates = + server.type === 'plex' ? server.sendGuideUpdates ?? false : false; + const sendChannelUpdates = + server.type === 'plex' ? server.sendChannelUpdates ?? false : false; + em.assign(s, { name: server.name, uri: server.uri, accessToken: server.accessToken, - sendGuideUpdates: server.sendGuideUpdates ?? false, - sendChannelUpdates: server.sendChannelUpdates ?? false, + sendGuideUpdates, + sendChannelUpdates, updatedAt: new Date(), }); this.normalizeServer(s); - const report = await this.fixupProgramReferences(id, s); + const report = await this.fixupProgramReferences( + id, + programSourceTypeFromMediaSource(s.type), + s, + ); await repo.upsert(s); await em.flush(); @@ -100,45 +128,77 @@ export class PlexServerDB { return report; } - async addServer(server: InsertPlexServerRequest): Promise { + async addMediaSource(server: InsertMediaSourceRequest): Promise { const em = getEm(); - const repo = em.repo(PlexServerSettingsEntity); + const repo = em.repo(MediaSource); const name = isUndefined(server.name) ? 'plex' : server.name; - // let i = 2; - // const prefix = name; - // let resultName = name; - // while (this.doesNameExist(resultName)) { - // resultName = `${prefix}${i}`; - // i += 1; - // } - // name = resultName; - - const sendGuideUpdates = server.sendGuideUpdates ?? false; - const sendChannelUpdates = server.sendChannelUpdates ?? false; + const sendGuideUpdates = + server.type === 'plex' ? server.sendGuideUpdates ?? false : false; + const sendChannelUpdates = + server.type === 'plex' ? server.sendChannelUpdates ?? false : false; const index = await repo.count(); - const newServer = em.create(PlexServerSettingsEntity, { + const newServer = em.create(MediaSource, { ...server, name, sendGuideUpdates, sendChannelUpdates, index, + type: mediaSourceTypeFromApi(server.type), }); this.normalizeServer(newServer); - return await em.insert(PlexServerSettingsEntity, newServer); + return await em.insert(MediaSource, newServer); } + // private async removeDanglingPrograms(mediaSource: MediaSource) { + // const knownProgramIds = await directDbAccess() + // .selectFrom('programExternalId as p1') + // .where(({ eb, and }) => + // and([ + // eb('p1.externalSourceId', '=', mediaSource.name), + // eb('p1.sourceType', '=', mediaSource.type), + // ]), + // ) + // .selectAll('p1') + // .select((eb) => + // jsonArrayFrom( + // eb + // .selectFrom('programExternalId as p2') + // .whereRef('p2.programUuid', '=', 'p1.programUuid') + // .whereRef('p2.uuid', '!=', 'p1.uuid') + // .select(['p2.sourceType', 'p2.externalSourceId', 'p2.externalKey']), + // ).as('otherExternalIds'), + // ) + // .groupBy('p1.uuid') + // .execute(); + + // const mediaSourceTypes = map(enumValues(MediaSourceType), (typ) => + // typ.toString(), + // ); + // const danglingPrograms = reject(knownProgramIds, (program) => { + // some(program.otherExternalIds, (eid) => + // mediaSourceTypes.includes(eid.sourceType), + // ); + // }); + // } + private async fixupProgramReferences( serverName: string, - newServer?: PlexServerSettingsEntity, + serverType: ProgramSourceType, + newServer?: MediaSource, ) { + // TODO: We need to update this to: + // 1. handle different source types + // 2. use program_external_id table + // 3. not delete programs if they still have another reference via + // the external id table (program that exists on 2 servers) const em = getEm(); const allPrograms = await em .repo(Program) .find( - { sourceType: ProgramSourceType.PLEX, externalSourceId: serverName }, + { sourceType: serverType, externalSourceId: serverName }, { populate: ['fillerShows', 'channels', 'customShows'] }, ); @@ -234,7 +294,7 @@ export class PlexServerDB { return [...channelReports, ...fillerReports, ...customShowReports]; } - private fixupProgram(program: Program, newServer: PlexServerSettingsEntity) { + private fixupProgram(program: Program, newServer: MediaSource) { let modified = false; const fixIcon = (icon: string | undefined) => { if ( @@ -261,7 +321,7 @@ export class PlexServerDB { return modified; } - private normalizeServer(server: PlexServerSettingsEntity) { + private normalizeServer(server: MediaSource) { while (server.uri.endsWith('/')) { server.uri = server.uri.slice(0, -1); } diff --git a/server/src/dao/programHelpers.ts b/server/src/dao/programHelpers.ts index 287cb095..8ac51a0d 100644 --- a/server/src/dao/programHelpers.ts +++ b/server/src/dao/programHelpers.ts @@ -1,4 +1,5 @@ import { ref } from '@mikro-orm/core'; +import { createExternalId } from '@tunarr/shared'; import { ChannelProgram, ContentProgram, @@ -6,13 +7,15 @@ import { isContentProgram, isCustomProgram, } from '@tunarr/types'; -import { PlexEpisode, PlexMedia, PlexMusicTrack } from '@tunarr/types/plex'; +import { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex'; import dayjs from 'dayjs'; import ld, { compact, filter, + find, forEach, isNil, + isUndefined, map, partition, reduce, @@ -21,8 +24,8 @@ import ld, { import { performance } from 'perf_hooks'; import { GlobalScheduler } from '../services/scheduler.js'; import { ReconcileProgramDurationsTask } from '../tasks/ReconcileProgramDurationsTask.js'; -import { SavePlexProgramExternalIdsTask } from '../tasks/SavePlexProgramExternalIdsTask.js'; -import { PlexTaskQueue } from '../tasks/TaskQueue.js'; +import { SavePlexProgramExternalIdsTask } from '../tasks/plex/SavePlexProgramExternalIdsTask.js'; +import { JellyfinTaskQueue, PlexTaskQueue } from '../tasks/TaskQueue.js'; import { SavePlexProgramGroupingsTask } from '../tasks/plex/SavePlexProgramGroupingsTask.js'; import { ProgramMinterFactory } from '../util/ProgramMinter.js'; import { groupByUniqFunc, isNonEmptyString } from '../util/index.js'; @@ -30,10 +33,19 @@ import { LoggerFactory } from '../util/logging/LoggerFactory.js'; import { time, timeNamedAsync } from '../util/perf.js'; import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js'; import { getEm } from './dataSource.js'; -import { Program, programTypeFromString } from './entities/Program.js'; +import { + Program, + programTypeFromJellyfinType, + programTypeFromString, +} from './entities/Program.js'; import { ProgramExternalId } from './entities/ProgramExternalId.js'; import { upsertProgramExternalIds_deprecated } from './programExternalIdHelpers.js'; -import { createExternalId } from '@tunarr/shared'; +import { ContentProgramOriginalProgram } from '@tunarr/types/schemas'; +import { seq } from '@tunarr/shared/util'; +import { JellyfinItem } from '@tunarr/types/jellyfin'; +import { SaveJellyfinProgramGroupingsTask } from '../tasks/jellyfin/SaveJellyfinProgramGroupingsTask.js'; +import { ProgramSourceType } from './custom_types/ProgramSourceType.js'; +import { SaveJellyfinProgramExternalIdsTask } from '../tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.js'; export async function upsertContentPrograms( programs: ChannelProgram[], @@ -58,7 +70,102 @@ export async function upsertContentPrograms( ) .value(); - // TODO handle custom shows + // This code dedupes incoming programs using their external (IMDB, TMDB, etc) IDs. + // Eventually, it could be used to save source-agnostic programs, but it's unclear + // if that gives us benefit yet. + // const pMap = reduce( + // contentPrograms, + // (acc, program) => { + // const externalIds: { + // type: ProgramExternalIdType; + // id: string; + // program: ContentProgram; + // }[] = []; + // switch (program.originalProgram!.sourceType) { + // case 'plex': { + // const x = ld + // .chain(program.originalProgram!.program.Guid ?? []) + // .map((guid) => parsePlexExternalGuid(guid.id)) + // .thru(removeErrors) + // .map((eid) => ({ + // type: eid.sourceType, + // id: eid.externalKey, + // program, + // })) + // .value(); + // externalIds.push(...x); + // break; + // } + // case 'jellyfin': { + // const p = compact( + // map(program.originalProgram!.program.ProviderIds, (value, key) => { + // const typ = programExternalIdTypeFromString(key.toLowerCase()); + // return isNil(value) || isUndefined(typ) + // ? null + // : { type: typ, id: value, program }; + // }), + // ); + // externalIds.push(...p); + // break; + // } + // } + + // forEach(externalIds, ({ type, id, program }) => { + // if (!isValidSingleExternalIdType(type)) { + // return; + // } + + // const key = createGlobalExternalIdString(type, id); + // const last = acc[key]; + // if (last) { + // acc[key] = { type, id, programs: [...last.programs, program] }; + // } else { + // acc[key] = { type, id, programs: [program] }; + // } + // }); + + // return acc; + // }, + // {} as Record< + // `${string}|${string}`, + // { + // type: ProgramExternalIdType; + // id: string; + // programs: ContentProgram[]; + // } + // >, + // ); + + // const existingPrograms = flatten( + // await mapAsyncSeq(chunk(values(pMap), 500), (items) => { + // return directDbAccess() + // .selectFrom('programExternalId') + // .where(({ or, eb }) => { + // const clauses = map(items, (item) => + // eb('programExternalId.sourceType', '=', item.type).and( + // 'programExternalId.externalKey', + // '=', + // item.id, + // ), + // ); + // return or(clauses); + // }) + // .selectAll('programExternalId') + // .select((eb) => + // jsonArrayFrom( + // eb + // .selectFrom('program') + // .whereRef('programExternalId.programUuid', '=', 'program.uuid') + // .select(AllProgramFields), + // ).as('program'), + // ) + // .groupBy('programExternalId.programUuid') + // .execute(); + // }), + // ); + // console.log('results!!!!', existingPrograms); + + // TODO: handle custom shows const programsToPersist = ld .chain(contentPrograms) .map((p) => { @@ -72,31 +179,6 @@ export async function upsertContentPrograms( }) .value(); - const plexRatingExternalIdToMedia = ld - .chain(programsToPersist) - .reduce( - (acc, { apiProgram, externalIds }) => { - const flattened = filter(externalIds, { - sourceType: ProgramExternalIdType.PLEX, - externalSourceId: apiProgram.externalSourceName!, - }); - - return { - ...acc, - ...reduce( - flattened, - (acc2, eid) => ({ - ...acc2, - [eid.toExternalIdString()]: apiProgram.originalProgram!, - }), - {} as Record, - ), - }; - }, - {} as Record, - ) - .value(); - const programInfoByUniqueId = groupByUniqFunc( programsToPersist, ({ program }) => program.uniqueId(), @@ -125,9 +207,6 @@ export async function upsertContentPrograms( ), ); - // We're dealing specifically with Plex items right now. We want to treat - // _at least_ the rating key / GUID as invariants in the program_external_id - // table for each program. const programExternalIds = ld .chain(upsertedPrograms) .flatMap((program) => { @@ -139,19 +218,113 @@ export async function upsertContentPrograms( }) .value(); - const externalIdsByGrandparentId = ld + const sourceExternalIdToOriginalProgram: Record< + string, + ContentProgramOriginalProgram + > = ld + .chain(programsToPersist) + .reduce((acc, { apiProgram, externalIds }) => { + if (isUndefined(apiProgram.originalProgram)) { + return acc; + } + + const itemId = find( + externalIds, + (eid) => + eid.sourceType === apiProgram.originalProgram!.sourceType && + eid.externalSourceId === apiProgram.externalSourceName!, + ); + + if (!itemId) { + return acc; + } + + return { + ...acc, + [itemId.toExternalIdString()]: apiProgram.originalProgram, + }; + }, {}) + .value(); + + schedulePlexProgramGroupingTasks( + programExternalIds, + sourceExternalIdToOriginalProgram, + ); + + scheduleJellyfinProgramGroupingTasks( + programExternalIds, + sourceExternalIdToOriginalProgram, + ); + + const [requiredExternalIds, backgroundExternalIds] = partition( + programExternalIds, + (p) => + p.sourceType === ProgramExternalIdType.PLEX || + p.sourceType === ProgramExternalIdType.PLEX_GUID || + p.sourceType === ProgramExternalIdType.JELLYFIN, + ); + + // Fail hard on not saving Plex external IDs. We need them for streaming + // TODO: We could optimize further here by only saving IDs necessary for streaming + await timeNamedAsync('upsert external ids', logger, () => + upsertProgramExternalIds_deprecated(requiredExternalIds), + ); + + setImmediate(() => { + upsertProgramExternalIds_deprecated(backgroundExternalIds).catch((e) => { + logger.error( + e, + 'Error saving non-essential external IDs. A fixer will run for these', + ); + }); + }); + + await em.flush(); + + schedulePlexExternalIdsTask(upsertedPrograms); + scheduleJellyfinExternalIdsTask(upsertedPrograms); + + setImmediate(() => { + GlobalScheduler.scheduleOneOffTask( + ReconcileProgramDurationsTask.name, + dayjs().add(500, 'ms'), + new ReconcileProgramDurationsTask(), + ); + PlexTaskQueue.resume(); + JellyfinTaskQueue.resume(); + }); + + const end = performance.now(); + logger.debug( + 'upsertContentPrograms to %d millis. %d upsertedPrograms', + round(end - start, 3), + upsertedPrograms.length, + ); + + return upsertedPrograms; +} + +function schedulePlexProgramGroupingTasks( + programExternalIds: ProgramExternalId[], + sourceExternalIdToOriginalProgram: Record< + string, + ContentProgramOriginalProgram + >, +) { + const plexExternalIdsByGrandparentId = ld .chain(programExternalIds) .map((externalId) => { - const plexMedia = - plexRatingExternalIdToMedia[externalId.toExternalIdString()]; + const media = + sourceExternalIdToOriginalProgram[externalId.toExternalIdString()]; if ( - plexMedia && - (plexMedia.type === 'track' || plexMedia.type === 'episode') + media && + media.sourceType === 'plex' && + (media.program.type === 'track' || media.program.type === 'episode') ) { return { externalId, - plexMedia, + plexMedia: media.program, }; } @@ -180,8 +353,9 @@ export async function upsertContentPrograms( ) .value(); + // TODO Need to implement this for Jellyfin setImmediate(() => { - forEach(externalIdsByGrandparentId, (externalIds, grandparentId) => { + forEach(plexExternalIdsByGrandparentId, (externalIds, grandparentId) => { const parentIds = map( externalIds, (eid) => eid.plexMedia.parentRatingKey, @@ -203,81 +377,162 @@ export async function upsertContentPrograms( ).catch((e) => console.error(e)); }); }); +} - const [requiredExternalIds, backgroundExternalIds] = partition( - programExternalIds, - (p) => - p.sourceType === ProgramExternalIdType.PLEX || - p.sourceType === ProgramExternalIdType.PLEX_GUID, - ); +function scheduleJellyfinProgramGroupingTasks( + programExternalIds: ProgramExternalId[], + sourceExternalIdToOriginalProgram: Record< + string, + ContentProgramOriginalProgram + >, +) { + const externalIdsByGrandparentId = ld + .chain(programExternalIds) + .map((externalId) => { + const media = + sourceExternalIdToOriginalProgram[externalId.toExternalIdString()]; - // Fail hard on not saving Plex external IDs. We need them for streaming - // TODO: We could optimize further here by only saving IDs necessary for streaming - await timeNamedAsync('upsert external ids', logger, () => - upsertProgramExternalIds_deprecated(requiredExternalIds), - ); + if ( + media && + media.sourceType === 'jellyfin' && + (media.program.Type === 'Audio' || media.program.Type === 'Episode') + ) { + return { + externalId, + item: media.program, + }; + } + + return; + }) + .compact() + .reduce( + (acc, { item, externalId }) => { + const grandparentKey = item.SeriesId ?? item.AlbumArtist; + if (isNonEmptyString(grandparentKey)) { + const existing = acc[grandparentKey] ?? []; + return { + ...acc, + [grandparentKey]: [...existing, { externalId, item }], + }; + } + return acc; + }, + {} as Record< + string, + { + externalId: ProgramExternalId; + item: JellyfinItem; + }[] + >, + ) + .value(); + + console.log('SCHEDULING a bunchhhh of things: ', externalIdsByGrandparentId); setImmediate(() => { - upsertProgramExternalIds_deprecated(backgroundExternalIds).catch((e) => { - logger.error( - e, - 'Error saving non-essential external IDs. A fixer will run for these', + forEach(externalIdsByGrandparentId, (externalIds, grandparentId) => { + const parentIds = compact( + map(externalIds, ({ item }) => + item.Type === 'Audio' + ? item.AlbumId + : item.Type === 'Episode' + ? item.SeasonId + : null, + ), ); + + const programAndParentIds = seq.collect(externalIds, (eid) => { + const parentKey = eid.item.AlbumId ?? eid.item.SeasonId; + if (!isNonEmptyString(parentKey)) { + return; + } + + return { + jellyfinItemId: eid.item.Id, + programId: eid.externalId.program.uuid, + parentKey, + }; + }); + + JellyfinTaskQueue.add( + new SaveJellyfinProgramGroupingsTask({ + grandparentKey: grandparentId, + parentKeys: compact(parentIds), + programAndJellyfinIds: programAndParentIds, + programType: programTypeFromJellyfinType(externalIds[0].item.Type)!, + jellyfinServerName: externalIds[0].externalId.externalSourceId!, + }), + ).catch((e) => console.error(e)); }); }); +} - await em.flush(); +function schedulePlexExternalIdsTask(upsertedPrograms: Program[]) { + const logger = LoggerFactory.root; PlexTaskQueue.pause(); const [, pQueueTime] = time(() => { - forEach(upsertedPrograms, (program) => { - try { - const task = new SavePlexProgramExternalIdsTask(program.uuid); - task.logLevel = 'trace'; - PlexTaskQueue.add(task).catch((e) => { - logger.error(e, 'Error saving external IDs for program %s', program); - }); - } catch (e) { - logger.error( - e, - 'Failed to schedule external IDs task for persisted program: %O', - program, - ); - } - }); + forEach( + filter(upsertedPrograms, (p) => p.sourceType === ProgramSourceType.PLEX), + (program) => { + try { + const task = new SavePlexProgramExternalIdsTask(program.uuid); + task.logLevel = 'trace'; + PlexTaskQueue.add(task).catch((e) => { + logger.error( + e, + 'Error saving external IDs for program %s', + program, + ); + }); + } catch (e) { + logger.error( + e, + 'Failed to schedule external IDs task for persisted program: %O', + program, + ); + } + }, + ); }); logger.debug('Took %d ms to schedule tasks', pQueueTime); - - setImmediate(() => { - GlobalScheduler.scheduleOneOffTask( - ReconcileProgramDurationsTask.name, - dayjs().add(500, 'ms'), - new ReconcileProgramDurationsTask(), - ); - PlexTaskQueue.resume(); - }); - - const end = performance.now(); - logger.debug( - 'upsertContentPrograms to %d millis. %d upsertedPrograms', - round(end - start, 3), - upsertedPrograms.length, - ); - - return upsertedPrograms; } -// Creates a unique ID that matches the output of the entity Program#uniqueId -// function. Useful to matching non-persisted API programs with persisted programs -export function contentProgramUniqueId(p: ContentProgram) { - // ID should always be defined in the persistent case - if (p.persisted) { - return p.id!; - } +function scheduleJellyfinExternalIdsTask(upsertedPrograms: Program[]) { + const logger = LoggerFactory.root; - // These should always be defined for the non-persisted case - return `${p.externalSourceType}|${p.externalSourceName}|${p.originalProgram?.key}`; + JellyfinTaskQueue.pause(); + const [, pQueueTime] = time(() => { + forEach( + filter( + upsertedPrograms, + (p) => p.sourceType === ProgramSourceType.JELLYFIN, + ), + (program) => { + try { + const task = new SaveJellyfinProgramExternalIdsTask(program.uuid); + task.logLevel = 'trace'; + JellyfinTaskQueue.add(task).catch((e) => { + logger.error( + e, + 'Error saving external IDs for program %s', + program, + ); + }); + } catch (e) { + logger.error( + e, + 'Failed to schedule external IDs task for persisted program: %O', + program, + ); + } + }, + ); + }); + + logger.debug('Took %d ms to schedule tasks', pQueueTime); } // Takes a listing of programs and makes a mapping of a unique identifier, diff --git a/server/src/external/BaseApiClient.ts b/server/src/external/BaseApiClient.ts new file mode 100644 index 00000000..f790fbf1 --- /dev/null +++ b/server/src/external/BaseApiClient.ts @@ -0,0 +1,216 @@ +import axios, { + AxiosHeaderValue, + AxiosInstance, + AxiosRequestConfig, + isAxiosError, +} from 'axios'; +import { isError, isString } from 'lodash-es'; +import { z } from 'zod'; +import { Maybe, Try } from '../types/util.js'; +import { configureAxiosLogging } from '../util/axios.js'; +import { isDefined } from '../util/index.js'; +import { Logger, LoggerFactory } from '../util/logging/LoggerFactory.js'; + +export type ApiClientOptions = { + name?: string; + url: string; + extraHeaders?: { + [key: string]: AxiosHeaderValue; + }; +}; + +export type RemoteMediaSourceOptions = ApiClientOptions & { + apiKey: string; + type: 'plex' | 'jellyfin'; +}; + +export type QuerySuccessResult = { + type: 'success'; + data: T; +}; + +type QueryErrorCode = + | 'not_found' + | 'no_access_token' + | 'parse_error' + | 'generic_request_error'; + +export type QueryErrorResult = { + type: 'error'; + code: QueryErrorCode; + message?: string; +}; + +export type QueryResult = QuerySuccessResult | QueryErrorResult; + +export function isQueryError(x: QueryResult): x is QueryErrorResult { + return x.type === 'error'; +} + +export function isQuerySuccess( + x: QueryResult, +): x is QuerySuccessResult { + return x.type === 'success'; +} + +export abstract class BaseApiClient< + OptionsType extends ApiClientOptions = ApiClientOptions, +> { + protected logger: Logger; + protected axiosInstance: AxiosInstance; + + constructor(protected options: OptionsType) { + this.logger = LoggerFactory.child({ + className: this.constructor.name, + serverName: options.name, + }); + + const url = options.url.endsWith('/') + ? options.url.slice(0, options.url.length - 1) + : options.url; + + this.axiosInstance = axios.create({ + baseURL: url, + headers: { + Accept: 'application/json', + ...(options.extraHeaders ?? {}), + }, + }); + + configureAxiosLogging(this.axiosInstance, this.logger); + } + + async doTypeCheckedGet>( + path: string, + schema: T, + extraConfig: Partial = {}, + ): Promise> { + const getter = async () => { + const req: AxiosRequestConfig = { + ...extraConfig, + method: 'get', + url: path, + }; + + const response = await this.doRequest(req); + + if (isError(response)) { + if (isAxiosError(response) && response.response?.status === 404) { + return this.makeErrorResult('not_found'); + } + return this.makeErrorResult('generic_request_error', response.message); + } + + const parsed = await schema.safeParseAsync(response); + + if (parsed.success) { + return this.makeSuccessResult(parsed.data as Out); + } + + this.logger.error( + parsed.error, + 'Unable to parse schema from Plex response. Path: %s', + path, + ); + + return this.makeErrorResult('parse_error'); + }; + + // return this.opts.enableRequestCache + // ? await PlexCache.getOrSetPlexResult(this.opts.name, path, getter) + // : await getter(); + return await getter(); + } + + protected preRequestValidate( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _req: AxiosRequestConfig, + ): Maybe { + return; + } + + protected makeErrorResult( + code: QueryErrorCode, + message?: string, + ): QueryErrorResult { + return { + type: 'error', + code, + message, + }; + } + + protected makeSuccessResult(data: T): QuerySuccessResult { + return { + type: 'success', + data, + }; + } + + doGet(req: Omit) { + return this.doRequest({ method: 'get', ...req }); + } + + doPost(req: Omit) { + return this.doRequest({ method: 'post', ...req }); + } + + doPut(req: Omit) { + return this.doRequest({ method: 'put', ...req }); + } + + doHead(req: Omit) { + return this.doRequest({ method: 'head', ...req }); + } + + getFullUrl(path: string): string { + const sanitizedPath = path.startsWith('/') ? path : `/${path}`; + const url = new URL(`${this.options.url}${sanitizedPath}`); + return url.toString(); + } + + protected async doRequest(req: AxiosRequestConfig): Promise> { + try { + const response = await this.axiosInstance.request(req); + return response.data; + } catch (error) { + if (isAxiosError(error)) { + if (error.response?.status === 404) { + this.logger.warn( + `Not found: ${this.axiosInstance.defaults.baseURL}${req.url}`, + ); + } + if (isDefined(error.response)) { + const { status, headers } = error.response; + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + this.logger.warn( + 'Plex response error: status %d, data: %O, headers: %O', + status, + error.response.data, + headers, + ); + } else if (error.request) { + this.logger.error(error, 'Plex request error: %s', error.message); + } else { + this.logger.error(error, 'Error requesting Plex: %s', error.message); + } + return error; + } else if (isError(error)) { + this.logger.error(error); + return error; + } else if (isString(error)) { + // Wrap it + const err = new Error(error); + this.logger.error(err); + return err; + } else { + // At this point we have no idea what the object is... attempt to log + // and just return a generic error. Something is probably fatally wrong + // at this point. + this.logger.error('Unknown error type thrown: %O', error); + return new Error('Unknown error when requesting Plex'); + } + } + } +} diff --git a/server/src/external/MediaSourceApiFactory.ts b/server/src/external/MediaSourceApiFactory.ts new file mode 100644 index 00000000..b07955e2 --- /dev/null +++ b/server/src/external/MediaSourceApiFactory.ts @@ -0,0 +1,144 @@ +import { forEach, isBoolean, isNull, isUndefined } from 'lodash-es'; +import NodeCache from 'node-cache'; +import { getEm } from '../dao/dataSource.js'; +import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js'; +import { PlexApiClient, PlexApiOptions } from './plex/PlexApiClient.js'; +import { SettingsDB, getSettings } from '../dao/settings.js'; +import { isDefined } from '../util/index.js'; +import { JellyfinApiClient } from './jellyfin/JellyfinApiClient.js'; +import { FindChild } from '@tunarr/types'; +import { RemoteMediaSourceOptions } from './BaseApiClient.js'; +import { Maybe } from '../types/util.js'; + +type TypeToClient = [ + [MediaSourceType.Plex, PlexApiClient], + [MediaSourceType.Jellyfin, JellyfinApiClient], +]; + +let instance: MediaSourceApiFactoryImpl; + +export class MediaSourceApiFactoryImpl { + #cache: NodeCache; + #requestCacheEnabled: boolean | Record = false; + + constructor(private settings: SettingsDB = getSettings()) { + this.#cache = new NodeCache({ + useClones: false, + deleteOnExpire: true, + checkperiod: 60, + }); + + this.#requestCacheEnabled = + settings.systemSettings().cache?.enablePlexRequestCache ?? false; + + this.settings.addListener('change', () => { + this.#requestCacheEnabled = + settings.systemSettings().cache?.enablePlexRequestCache ?? false; + forEach(this.#cache.data, ({ v: value }, key) => { + if (isDefined(value) && value instanceof PlexApiClient) { + value.setEnableRequestCache(this.requestCacheEnabledForServer(key)); + } + }); + }); + } + + getTyped>( + typ: X, + opts: RemoteMediaSourceOptions, + factory: (opts: RemoteMediaSourceOptions) => ApiClient, + ): ApiClient { + const key = `${typ}|${opts.url}|${opts.apiKey}`; + let client = this.#cache.get(key); + if (!client) { + client = factory(opts); + this.#cache.set(key, client); + } + + return client; + } + + getJellyfinClient(opts: RemoteMediaSourceOptions) { + return this.getTyped( + MediaSourceType.Jellyfin, + opts, + (opts) => new JellyfinApiClient(opts), + ); + } + + async getOrSet(name: string) { + let client = this.#cache.get(name); + if (isUndefined(client)) { + const em = getEm(); + const server = await em.repo(MediaSource).findOne({ name }); + if (!isNull(server)) { + client = new PlexApiClient({ + ...server, + enableRequestCache: this.requestCacheEnabledForServer(server.name), + }); + this.#cache.set(server.name, client); + } + } + return client; + } + + async getTypedByName< + X extends MediaSourceType, + ApiClient = FindChild, + >( + type: X, + name: string, + factory: (opts: RemoteMediaSourceOptions) => ApiClient, + ): Promise> { + const key = `${type}|${name}`; + let client = this.#cache.get(key); + if (isUndefined(client)) { + const em = getEm(); + const server = await em.repo(MediaSource).findOne({ name, type }); + if (!isNull(server)) { + client = factory({ + apiKey: server.accessToken, + url: server.uri, + name: server.name, + type: type, + }); + this.#cache.set(server.name, client); + } + } + return client; + } + + async getJellyfinByName(name: string) { + return this.getTypedByName( + MediaSourceType.Jellyfin, + name, + (opts) => new JellyfinApiClient(opts), + ); + } + + get(opts: PlexApiOptions) { + const key = `${opts.uri}|${opts.accessToken}`; + let client = this.#cache.get(key); + if (!client) { + client = new PlexApiClient({ + ...opts, + enableRequestCache: this.requestCacheEnabledForServer(opts.name), + }); + this.#cache.set(key, client); + } + + return client; + } + + private requestCacheEnabledForServer(id: string) { + return isBoolean(this.#requestCacheEnabled) + ? this.#requestCacheEnabled + : this.#requestCacheEnabled[id]; + } +} + +export const MediaSourceApiFactory = () => { + if (!instance) { + instance = new MediaSourceApiFactoryImpl(); + } + return instance; +}; diff --git a/server/src/external/PlexApiFactory.ts b/server/src/external/PlexApiFactory.ts deleted file mode 100644 index 02302ef0..00000000 --- a/server/src/external/PlexApiFactory.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { forEach, isBoolean, isNull, isUndefined } from 'lodash-es'; -import NodeCache from 'node-cache'; -import { getEm } from '../dao/dataSource.js'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; -import { Plex, PlexApiOptions } from './plex.js'; -import { SettingsDB, getSettings } from '../dao/settings.js'; -import { isDefined } from '../util/index.js'; - -let instance: PlexApiFactoryImpl; - -export class PlexApiFactoryImpl { - #cache: NodeCache; - #requestCacheEnabled: boolean | Record = false; - - constructor(private settings: SettingsDB = getSettings()) { - this.#cache = new NodeCache({ - useClones: false, - deleteOnExpire: true, - checkperiod: 60, - }); - - this.#requestCacheEnabled = - settings.systemSettings().cache?.enablePlexRequestCache ?? false; - - this.settings.addListener('change', () => { - this.#requestCacheEnabled = - settings.systemSettings().cache?.enablePlexRequestCache ?? false; - forEach(this.#cache.data, (data, key) => { - const plex = data.v as Plex; - if (isDefined(plex)) { - plex.setEnableRequestCache(this.requestCacheEnabledForServer(key)); - } - }); - }); - } - - async getOrSet(name: string) { - let client = this.#cache.get(name); - if (isUndefined(client)) { - const em = getEm(); - const server = await em.repo(PlexServerSettings).findOne({ name }); - if (!isNull(server)) { - client = new Plex({ - ...server, - enableRequestCache: this.requestCacheEnabledForServer(server.name), - }); - this.#cache.set(server.name, client); - } - } - return client; - } - - get(opts: PlexApiOptions) { - const key = `${opts.uri}|${opts.accessToken}`; - let client = this.#cache.get(key); - if (!client) { - client = new Plex({ - ...opts, - enableRequestCache: this.requestCacheEnabledForServer(opts.name), - }); - this.#cache.set(key, client); - } - - return client; - } - - private requestCacheEnabledForServer(id: string) { - return isBoolean(this.#requestCacheEnabled) - ? this.#requestCacheEnabled - : this.#requestCacheEnabled[id]; - } -} - -export const PlexApiFactory = () => { - if (!instance) { - instance = new PlexApiFactoryImpl(); - } - return instance; -}; diff --git a/server/src/external/jellyfin/JellyfinApiClient.ts b/server/src/external/jellyfin/JellyfinApiClient.ts new file mode 100644 index 00000000..f2b9ec3a --- /dev/null +++ b/server/src/external/jellyfin/JellyfinApiClient.ts @@ -0,0 +1,191 @@ +import { + JellyfinAuthenticationResult, + JellyfinItem, + JellyfinItemFields, + JellyfinItemKind, + JellyfinLibraryItemsResponse, + JellyfinLibraryResponse, + JellyfinSystemInfo, +} from '@tunarr/types/jellyfin'; +import axios, { AxiosRequestConfig } from 'axios'; +import { first, isEmpty, union } from 'lodash-es'; +import { v4 } from 'uuid'; +import { Maybe, Nilable } from '../../types/util'; +import { LoggerFactory } from '../../util/logging/LoggerFactory'; +import { + BaseApiClient, + QueryErrorResult, + QueryResult, + RemoteMediaSourceOptions, + isQueryError, +} from '../BaseApiClient.js'; +import { getTunarrVersion } from '../../util/version.js'; + +const RequiredLibraryFields = [ + 'Path', + 'Genres', + 'Tags', + 'DateCreated', + 'Etag', + 'Overview', + 'Taglines', + 'Studios', + 'People', + 'OfficialRating', + 'ProviderIds', + 'Chapters', + 'MediaStreams', + 'MediaSources', +]; + +export class JellyfinApiClient extends BaseApiClient< + Omit +> { + constructor(options: Omit) { + super({ + ...options, + extraHeaders: { + ...options.extraHeaders, + Accept: 'application/json', + 'X-Emby-Token': options.apiKey, + }, + }); + } + + static async login( + server: Omit, + username: string, + password: string, + ) { + try { + const response = await axios.post( + `${server.url}/Users/AuthenticateByName`, + { + Username: username, + Pw: password, + }, + { + headers: { + Authorization: `MediaBrowser Client="Tunarr", Device="Web Browser", DeviceId=${v4()}, Version=${getTunarrVersion()}`, + }, + }, + ); + + return await JellyfinAuthenticationResult.parseAsync(response.data); + } catch (e) { + LoggerFactory.root.error(e, 'Error logging into Jellyfin', { + className: JellyfinApiClient.name, + }); + throw e; + } + } + + async ping() { + try { + await this.doGet({ + url: '/System/Ping', + }); + return true; + } catch (e) { + this.logger.error(e); + return false; + } + } + + async getSystemInfo() { + return this.doTypeCheckedGet('/System/Info', JellyfinSystemInfo); + } + + async getUserLibraries(userId?: string) { + return this.doTypeCheckedGet( + '/Library/VirtualFolders', + JellyfinLibraryResponse, + { params: { userId } }, + ); + } + + async getUserViews(userId?: string) { + return this.doTypeCheckedGet('/UserViews', JellyfinLibraryItemsResponse, { + params: { + userId, + includeExternalContent: false, + presetViews: ['movies', 'tvshows', 'music', 'playlists', 'folders'], + }, + }); + } + + async getItem( + itemId: string, + extraFields: JellyfinItemFields[] = [], + ): Promise>> { + const result = await this.getItems( + null, + null, + null, + ['MediaStreams', ...extraFields], + null, + { + ids: itemId, + }, + ); + + if (isQueryError(result)) { + return result; + } + + return this.makeSuccessResult(first(result.data.Items)); + } + + async getItems( + userId: Nilable, // Not required if we are using an access token + libraryId: Nilable, + itemTypes: Nilable = null, + extraFields: JellyfinItemFields[] = [], + pageParams: Nilable<{ offset: number; limit: number }> = null, + extraParams: object = {}, + ): Promise> { + return this.doTypeCheckedGet('/Items', JellyfinLibraryItemsResponse, { + params: { + userId, + parentId: libraryId, + fields: union(extraFields, RequiredLibraryFields).join(','), + startIndex: pageParams?.offset, + limit: pageParams?.limit, + // These will be configurable eventually + sortOrder: 'Ascending', + sortBy: 'SortName,ProductionYear', + recursive: true, + includeItemTypes: itemTypes ? itemTypes.join(',') : undefined, + ...extraParams, + }, + }); + } + + getThumbUrl(id: string) { + // Naive impl for now... + return `${this.options.url}/Items/${id}/Images/Primary`; + } + + static getThumbUrl(opts: { + uri: string; + accessToken: string; + itemKey: string; + width?: number; + height?: number; + upscale?: string; + }): string { + return `${opts.uri}/Items/${opts.itemKey}/Images/Primary`; + } + + protected override preRequestValidate( + req: AxiosRequestConfig, + ): Maybe { + if (isEmpty(this.options.apiKey)) { + return this.makeErrorResult( + 'no_access_token', + 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', + ); + } + return super.preRequestValidate(req); + } +} diff --git a/server/src/external/plex.ts b/server/src/external/plex.ts deleted file mode 100644 index 2a0b2711..00000000 --- a/server/src/external/plex.ts +++ /dev/null @@ -1,595 +0,0 @@ -import { EntityDTO } from '@mikro-orm/core'; -import { DefaultPlexHeaders } from '@tunarr/shared/constants'; -import { - PlexDvr, - PlexDvrsResponse, - PlexGenericMediaContainerResponseSchema, - PlexMedia, - PlexMediaContainerResponseSchema, - PlexResource, -} from '@tunarr/types/plex'; -import axios, { - AxiosInstance, - AxiosRequestConfig, - InternalAxiosRequestConfig, - RawAxiosRequestHeaders, - isAxiosError, -} from 'axios'; -import { XMLParser } from 'fast-xml-parser'; -import { - first, - flatMap, - forEach, - isEmpty, - isError, - isString, - isUndefined, - map, -} from 'lodash-es'; -import NodeCache from 'node-cache'; -import querystring, { ParsedUrlQueryInput } from 'querystring'; -import { MarkOptional } from 'ts-essentials'; -import { z } from 'zod'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; -import { - PlexMediaContainer, - PlexMediaContainerResponse, -} from '../types/plexApiTypes.js'; -import { Maybe, Try } from '../types/util.js'; -import { isDefined, isSuccess } from '../util/index.js'; -import { Logger, LoggerFactory } from '../util/logging/LoggerFactory.js'; - -type AxiosConfigWithMetadata = InternalAxiosRequestConfig & { - metadata: { - startTime: number; - }; -}; - -export type PlexApiOptions = MarkOptional< - Pick< - EntityDTO, - 'accessToken' | 'uri' | 'name' | 'clientIdentifier' - >, - 'clientIdentifier' -> & { - enableRequestCache?: boolean; -}; - -type PlexQuerySuccessResult = { - type: 'success'; - data: T; -}; - -type PlexQueryErrorCode = - | 'not_found' - | 'no_access_token' - | 'parse_error' - | 'generic_request_error'; - -type PlexQueryErrorResult = { - type: 'error'; - code: PlexQueryErrorCode; - message?: string; -}; - -export type PlexQueryResult = - | PlexQuerySuccessResult - | PlexQueryErrorResult; - -export function isPlexQueryError( - x: PlexQueryResult, -): x is PlexQueryErrorResult { - return x.type === 'error'; -} - -export function isPlexQuerySuccess( - x: PlexQueryResult, -): x is PlexQuerySuccessResult { - return x.type === 'success'; -} - -function makeErrorResult( - code: PlexQueryErrorCode, - message?: string, -): PlexQueryErrorResult { - return { - type: 'error', - code, - message, - }; -} - -function makeSuccessResult(data: T): PlexQuerySuccessResult { - return { - type: 'success', - data, - }; -} - -class PlexQueryCache { - #cache: NodeCache; - constructor() { - this.#cache = new NodeCache({ - useClones: false, - deleteOnExpire: true, - checkperiod: 60, - maxKeys: 2500, - stdTTL: 5 * 60 * 1000, - }); - } - - async getOrSet( - serverName: string, - path: string, - getter: () => Promise, - ): Promise { - const key = this.getCacheKey(serverName, path); - const existing = this.#cache.get(key); - if (isDefined(existing)) { - return existing; - } - - const value = await getter(); - this.#cache.set(key, value); - return value; - } - - async getOrSetPlexResult( - serverName: string, - path: string, - getter: () => Promise>, - opts?: { setOnError: boolean }, - ): Promise> { - const key = this.getCacheKey(serverName, path); - const existing = this.#cache.get>(key); - if (isDefined(existing)) { - return existing; - } - - const value = await getter(); - if ( - isPlexQuerySuccess(value) || - (isPlexQueryError(value) && opts?.setOnError) - ) { - this.#cache.set(key, value); - } - - return value; - } - - private getCacheKey(serverName: string, path: string) { - return `${serverName}|${path}`; - } -} - -const PlexCache = new PlexQueryCache(); - -export class Plex { - private logger: Logger; - private opts: PlexApiOptions; - private axiosInstance: AxiosInstance; - private accessToken: string; - - constructor(opts: PlexApiOptions) { - this.opts = opts; - this.accessToken = opts.accessToken; - this.logger = LoggerFactory.child({ caller: import.meta }); - const uri = opts.uri.endsWith('/') - ? opts.uri.slice(0, opts.uri.length - 1) - : opts.uri; - - this.axiosInstance = axios.create({ - baseURL: uri, - headers: { - ...DefaultPlexHeaders, - 'X-Plex-Token': this.accessToken, - }, - }); - - this.axiosInstance.interceptors.request.use((req) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - (req as AxiosConfigWithMetadata).metadata = { - startTime: new Date().getTime(), - }; - return req; - }); - - const logAxiosRequest = (req: AxiosConfigWithMetadata, status: number) => { - const query = req.params - ? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - `?${querystring.stringify(req.params)}` - : ''; - const elapsedTime = new Date().getTime() - req.metadata.startTime; - this.logger.http( - `[Axios Request]: ${req.method?.toUpperCase()} ${req.baseURL}${ - req.url - }${query} - (${status}) ${elapsedTime}ms`, - ); - }; - - this.axiosInstance.interceptors.response.use( - (resp) => { - logAxiosRequest(resp.config as AxiosConfigWithMetadata, resp.status); - return resp; - }, - (err) => { - if (isAxiosError(err) && err.config) { - logAxiosRequest( - err.config as AxiosConfigWithMetadata, - err.status ?? -1, - ); - } - throw err; - }, - ); - } - - get serverName() { - return this.opts.name; - } - - getFullUrl(path: string): string { - const sanitizedPath = path.startsWith('/') ? path : `/${path}`; - const url = new URL(`${this.opts.uri}${sanitizedPath}`); - url.searchParams.set('X-Plex-Token', this.opts.accessToken); - return url.toString(); - } - - private async doRequest(req: AxiosRequestConfig): Promise> { - try { - const response = await this.axiosInstance.request(req); - return response.data; - } catch (error) { - if (isAxiosError(error)) { - if (error.response?.status === 404) { - this.logger.warn( - `Not found: ${this.axiosInstance.defaults.baseURL}${req.url}`, - ); - } - if (!isUndefined(error.response)) { - const { status, headers } = error.response; - // The request was made and the server responded with a status code - // that falls out of the range of 2xx - this.logger.warn( - 'Plex response error: status %d, data: %O, headers: %O', - status, - error.response.data, - headers, - ); - } else if (error.request) { - this.logger.error(error, 'Plex request error: %s', error.message); - } else { - this.logger.error(error, 'Error requesting Plex: %s', error.message); - } - return error; - } else if (isError(error)) { - this.logger.error(error); - return error; - } else if (isString(error)) { - // Wrap it - const err = new Error(error); - this.logger.error(err); - return err; - } else { - // At this point we have no idea what the object is... attempt to log - // and just return a generic error. Something is probably fatally wrong - // at this point. - this.logger.error('Unknown error type thrown: %O', error); - return new Error('Unknown error when requesting Plex'); - } - } - } - - async doHead(path: string, optionalHeaders: RawAxiosRequestHeaders = {}) { - return await this.doRequest({ - method: 'head', - url: path, - headers: optionalHeaders, - }); - } - - // TODO: make all callers use this - async doGetResult( - path: string, - optionalHeaders: RawAxiosRequestHeaders = {}, - skipCache: boolean = false, - ): Promise>> { - const getter = async () => { - const req: AxiosRequestConfig = { - method: 'get', - url: path, - headers: optionalHeaders, - }; - - if (this.accessToken === '') { - throw new Error( - 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', - ); - } - - const res = await this.doRequest>(req); - if (isSuccess(res)) { - if (isUndefined(res?.MediaContainer)) { - this.logger.error(res, 'Expected MediaContainer, got %O', res); - return makeErrorResult('parse_error'); - } - - return makeSuccessResult(res?.MediaContainer); - } - - if (isAxiosError(res) && res.response?.status === 404) { - return makeErrorResult('not_found'); - } - - return makeErrorResult('generic_request_error', res.message); - }; - - return this.opts.enableRequestCache && !skipCache - ? await PlexCache.getOrSetPlexResult(this.opts.name, path, getter) - : await getter(); - } - - // We're just keeping the old contract here right now... - async doGet( - path: string, - optionalHeaders: RawAxiosRequestHeaders = {}, - skipCache: boolean = false, - ): Promise>> { - const result = await this.doGetResult>( - path, - optionalHeaders, - skipCache, - ); - if (isPlexQuerySuccess(result)) { - return result.data; - } else { - return; - } - } - - async doTypeCheckedGet>( - path: string, - schema: T, - extraConfig: Partial = {}, - ): Promise> { - const getter = async () => { - const req: AxiosRequestConfig = { - ...extraConfig, - method: 'get', - url: path, - }; - - if (isEmpty(this.accessToken)) { - return makeErrorResult( - 'no_access_token', - 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', - ); - } - - const response = await this.doRequest(req); - - if (isError(response)) { - if (isAxiosError(response) && response.response?.status === 404) { - return makeErrorResult('not_found'); - } - return makeErrorResult('generic_request_error', response.message); - } - - const parsed = await schema.safeParseAsync(response); - - if (parsed.success) { - return makeSuccessResult(parsed.data as Out); - } - - this.logger.error( - parsed.error, - 'Unable to parse schema from Plex response. Path: %s', - path, - ); - - return makeErrorResult('parse_error'); - }; - - return this.opts.enableRequestCache - ? await PlexCache.getOrSetPlexResult(this.opts.name, path, getter) - : await getter(); - } - - async getItemMetadata(key: string): Promise> { - const parsedResponse = await this.doTypeCheckedGet( - `/library/metadata/${key}`, - PlexMediaContainerResponseSchema, - ); - - if (isPlexQuerySuccess(parsedResponse)) { - const media = first(parsedResponse.data.MediaContainer.Metadata); - if (!isUndefined(media)) { - return makeSuccessResult(media); - } - this.logger.error( - 'Could not extract Metadata object for Plex media, key = %s', - key, - ); - return makeErrorResult('parse_error'); - } - - return parsedResponse; - } - - doPut( - path: string, - query: ParsedUrlQueryInput | URLSearchParams = {}, - optionalHeaders: RawAxiosRequestHeaders = {}, - ) { - const req: AxiosRequestConfig = { - method: 'put', - url: path, - params: query, - headers: optionalHeaders, - }; - - if (this.accessToken === '') { - throw Error( - 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', - ); - } - - return this.doRequest(req); - } - - doPost( - path: string, - query: ParsedUrlQueryInput | URLSearchParams = {}, - optionalHeaders: RawAxiosRequestHeaders = {}, - ) { - const req: AxiosRequestConfig = { - method: 'post', - url: path, - headers: optionalHeaders, - params: query, - }; - - if (this.accessToken === '') { - return makeErrorResult( - 'no_access_token', - 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', - ); - } - - return this.doRequest(req); - } - - async checkServerStatus() { - try { - const result = await this.doTypeCheckedGet( - '/', - PlexGenericMediaContainerResponseSchema, - ); - if (isPlexQueryError(result)) { - throw result; - } else if (isUndefined(result)) { - // Parse error - indicates that the URL is probably not a Plex server - return -1; - } - return 1; - } catch (err) { - this.logger.error(err, 'Error getting Plex server status'); - return -1; - } - } - - async getDvrs() { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await this.doGet('/livetv/dvrs'); - return isUndefined(result?.Dvr) ? [] : result?.Dvr; - } catch (err) { - this.logger.error(err, 'GET /livetv/drs failed'); - throw err; - } - } - - async getResources() {} - - async refreshGuide(_dvrs?: PlexDvr[]) { - const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.getDvrs(); - if (!dvrs) { - throw new Error('Could not retrieve Plex DVRs'); - } - - for (const dvr of dvrs) { - await this.doPost(`/livetv/dvrs/${dvr.key}/reloadGuide`); - } - } - - async refreshChannels(channels: { number: number }[], _dvrs?: PlexDvr[]) { - const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.getDvrs(); - if (!dvrs) throw new Error('Could not retrieve Plex DVRs'); - - const qs: Record = { - channelsEnabled: map(channels, 'number').join(','), - }; - - forEach(channels, ({ number }) => { - qs[`channelMapping[${number}]`] = number; - qs[`channelMappingByKey[${number}]`] = number; - }); - - const keys = map( - flatMap(dvrs, ({ Device }) => Device), - (device) => device.key, - ); - for (const key of keys) { - await this.doPut(`/media/grabbers/devices/${key}/channelmap`, qs); - } - } - - async getDevices(): Promise> { - const response = await this.doRequest({ - method: 'get', - baseURL: 'https://plex.tv', - url: '/devices.xml', - }); - - if (isError(response)) { - this.logger.error(response); - return; - } - - const parsed = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: '', - }).parse(response) as PlexTvDevicesResponse; - return parsed; - } - - getThumbUrl(opts: { - itemKey: string; - width?: number; - height?: number; - upscale?: string; - }) { - return Plex.getThumbUrl({ - uri: this.opts.uri, - accessToken: this.opts.accessToken, - itemKey: opts.itemKey, - width: opts.width, - height: opts.height, - upscale: opts.upscale, - }); - } - - setEnableRequestCache(enable: boolean) { - this.opts.enableRequestCache = enable; - } - - static getThumbUrl(opts: { - uri: string; - accessToken: string; - itemKey: string; - width?: number; - height?: number; - upscale?: string; - }): string { - const { uri, accessToken, itemKey, width, height, upscale } = opts; - const cleanKey = itemKey.replaceAll(/\/library\/metadata\//g, ''); - - let thumbUrl: URL; - const key = `/library/metadata/${cleanKey}/thumb?X-Plex-Token=${accessToken}`; - if (isUndefined(height) || isUndefined(width)) { - thumbUrl = new URL(`${uri}${key}`); - } else { - thumbUrl = new URL(`${uri}/photo/:/transcode`); - thumbUrl.searchParams.append('url', key); - thumbUrl.searchParams.append('X-Plex-Token', accessToken); - thumbUrl.searchParams.append('width', width.toString()); - thumbUrl.searchParams.append('height', height.toString()); - thumbUrl.searchParams.append('upscale', (upscale ?? '1').toString()); - } - return thumbUrl.toString(); - } -} - -type PlexTvDevicesResponse = { - MediaContainer: { Device: PlexResource[] }; -}; diff --git a/server/src/external/plex/PlexApiClient.ts b/server/src/external/plex/PlexApiClient.ts new file mode 100644 index 00000000..9e20ebd9 --- /dev/null +++ b/server/src/external/plex/PlexApiClient.ts @@ -0,0 +1,310 @@ +import { EntityDTO } from '@mikro-orm/core'; +import { + PlexDvr, + PlexDvrsResponse, + PlexGenericMediaContainerResponseSchema, + PlexMedia, + PlexMediaContainerResponseSchema, + PlexResource, +} from '@tunarr/types/plex'; +import { + AxiosRequestConfig, + RawAxiosRequestHeaders, + isAxiosError, +} from 'axios'; +import { XMLParser } from 'fast-xml-parser'; +import { + first, + flatMap, + forEach, + isEmpty, + isError, + isUndefined, + map, +} from 'lodash-es'; +import { MarkOptional } from 'ts-essentials'; +import { MediaSource } from '../../dao/entities/MediaSource.js'; +import { + PlexMediaContainer, + PlexMediaContainerResponse, +} from '../../types/plexApiTypes.js'; +import { Maybe } from '../../types/util.js'; +import { isSuccess } from '../../util/index.js'; +import { + BaseApiClient, + QueryErrorResult, + QueryResult, + isQueryError, + isQuerySuccess, +} from '../BaseApiClient.js'; +import { PlexQueryCache } from './PlexQueryCache.js'; + +export type PlexApiOptions = MarkOptional< + Pick< + EntityDTO, + 'accessToken' | 'uri' | 'name' | 'clientIdentifier' + >, + 'clientIdentifier' +> & { + enableRequestCache?: boolean; +}; + +const PlexCache = new PlexQueryCache(); + +export class PlexApiClient extends BaseApiClient { + private opts: PlexApiOptions; + private accessToken: string; + + constructor(opts: PlexApiOptions) { + super({ + url: opts.uri, + name: opts.name, + extraHeaders: { + 'X-Plex-Token': opts.accessToken, + }, + }); + this.opts = opts; + this.accessToken = opts.accessToken; + } + + get serverName() { + return this.opts.name; + } + + getFullUrl(path: string): string { + const url = super.getFullUrl(path); + const parsed = new URL(url); + parsed.searchParams.set('X-Plex-Token', this.opts.accessToken); + return parsed.toString(); + } + + // TODO: make all callers use this + private async doGetResult( + path: string, + optionalHeaders: RawAxiosRequestHeaders = {}, + skipCache: boolean = false, + ): Promise>> { + const getter = async () => { + const req: AxiosRequestConfig = { + method: 'get', + url: path, + headers: optionalHeaders, + }; + + if (this.accessToken === '') { + throw new Error( + 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', + ); + } + + const res = await this.doRequest>(req); + if (isSuccess(res)) { + if (isUndefined(res?.MediaContainer)) { + this.logger.error(res, 'Expected MediaContainer, got %O', res); + return this.makeErrorResult('parse_error'); + } + + return this.makeSuccessResult(res?.MediaContainer); + } + + if (isAxiosError(res) && res.response?.status === 404) { + return this.makeErrorResult('not_found'); + } + + return this.makeErrorResult('generic_request_error', res.message); + }; + + return this.opts.enableRequestCache && !skipCache + ? await PlexCache.getOrSetPlexResult(this.opts.name, path, getter) + : await getter(); + } + + // We're just keeping the old contract here right now... + async doGetPath( + path: string, + optionalHeaders: RawAxiosRequestHeaders = {}, + skipCache: boolean = false, + ): Promise>> { + const result = await this.doGetResult>( + path, + optionalHeaders, + skipCache, + ); + if (isQuerySuccess(result)) { + return result.data; + } else { + return; + } + } + + async getItemMetadata(key: string): Promise> { + const parsedResponse = await this.doTypeCheckedGet( + `/library/metadata/${key}`, + PlexMediaContainerResponseSchema, + ); + + if (isQuerySuccess(parsedResponse)) { + const media = first(parsedResponse.data.MediaContainer.Metadata); + if (!isUndefined(media)) { + return this.makeSuccessResult(media); + } + this.logger.error( + 'Could not extract Metadata object for Plex media, key = %s', + key, + ); + return this.makeErrorResult('parse_error'); + } + + return parsedResponse; + } + + async checkServerStatus() { + try { + const result = await this.doTypeCheckedGet( + '/', + PlexGenericMediaContainerResponseSchema, + ); + if (isQueryError(result)) { + throw result; + } else if (isUndefined(result)) { + // Parse error - indicates that the URL is probably not a Plex server + return false; + } + return true; + } catch (err) { + this.logger.error(err, 'Error getting Plex server status'); + return false; + } + } + + async getDvrs() { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await this.doGetPath('/livetv/dvrs'); + return isUndefined(result?.Dvr) ? [] : result?.Dvr; + } catch (err) { + this.logger.error(err, 'GET /livetv/drs failed'); + throw err; + } + } + + async getResources() {} + + async refreshGuide(_dvrs?: PlexDvr[]) { + const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.getDvrs(); + if (!dvrs) { + throw new Error('Could not retrieve Plex DVRs'); + } + + for (const dvr of dvrs) { + await this.doPost({ url: `/livetv/dvrs/${dvr.key}/reloadGuide` }); + } + } + + async refreshChannels(channels: { number: number }[], _dvrs?: PlexDvr[]) { + const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.getDvrs(); + if (!dvrs) throw new Error('Could not retrieve Plex DVRs'); + + const qs: Record = { + channelsEnabled: map(channels, 'number').join(','), + }; + + forEach(channels, ({ number }) => { + qs[`channelMapping[${number}]`] = number; + qs[`channelMappingByKey[${number}]`] = number; + }); + + const keys = map( + flatMap(dvrs, ({ Device }) => Device), + (device) => device.key, + ); + + for (const key of keys) { + await this.doPut({ + url: `/media/grabbers/devices/${key}/channelmap`, + params: qs, + }); + } + } + + async getDevices(): Promise> { + const response = await this.doRequest({ + method: 'get', + baseURL: 'https://plex.tv', + url: '/devices.xml', + }); + + if (isError(response)) { + this.logger.error(response); + return; + } + + const parsed = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '', + }).parse(response) as PlexTvDevicesResponse; + return parsed; + } + + getThumbUrl(opts: { + itemKey: string; + width?: number; + height?: number; + upscale?: string; + }) { + return PlexApiClient.getThumbUrl({ + uri: this.opts.uri, + accessToken: this.opts.accessToken, + itemKey: opts.itemKey, + width: opts.width, + height: opts.height, + upscale: opts.upscale, + }); + } + + setEnableRequestCache(enable: boolean) { + this.opts.enableRequestCache = enable; + } + + protected override preRequestValidate( + req: AxiosRequestConfig, + ): Maybe { + if (isEmpty(this.accessToken)) { + return this.makeErrorResult( + 'no_access_token', + 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', + ); + } + return super.preRequestValidate(req); + } + + static getThumbUrl(opts: { + uri: string; + accessToken: string; + itemKey: string; + width?: number; + height?: number; + upscale?: string; + }): string { + const { uri, accessToken, itemKey, width, height, upscale } = opts; + const cleanKey = itemKey.replaceAll(/\/library\/metadata\//g, ''); + + let thumbUrl: URL; + const key = `/library/metadata/${cleanKey}/thumb?X-Plex-Token=${accessToken}`; + if (isUndefined(height) || isUndefined(width)) { + thumbUrl = new URL(`${uri}${key}`); + } else { + thumbUrl = new URL(`${uri}/photo/:/transcode`); + thumbUrl.searchParams.append('url', key); + thumbUrl.searchParams.append('X-Plex-Token', accessToken); + thumbUrl.searchParams.append('width', width.toString()); + thumbUrl.searchParams.append('height', height.toString()); + thumbUrl.searchParams.append('upscale', (upscale ?? '1').toString()); + } + return thumbUrl.toString(); + } +} + +type PlexTvDevicesResponse = { + MediaContainer: { Device: PlexResource[] }; +}; diff --git a/server/src/external/plex/PlexQueryCache.ts b/server/src/external/plex/PlexQueryCache.ts new file mode 100644 index 00000000..541687a8 --- /dev/null +++ b/server/src/external/plex/PlexQueryCache.ts @@ -0,0 +1,56 @@ +import NodeCache from 'node-cache'; +import { isDefined } from '../../util/index.js'; +import { QueryResult, isQueryError, isQuerySuccess } from '../BaseApiClient.js'; + +export class PlexQueryCache { + #cache: NodeCache; + constructor() { + this.#cache = new NodeCache({ + useClones: false, + deleteOnExpire: true, + checkperiod: 60, + maxKeys: 2500, + stdTTL: 5 * 60 * 1000, + }); + } + + async getOrSet( + serverName: string, + path: string, + getter: () => Promise, + ): Promise { + const key = this.getCacheKey(serverName, path); + const existing = this.#cache.get(key); + if (isDefined(existing)) { + return existing; + } + + const value = await getter(); + this.#cache.set(key, value); + return value; + } + + async getOrSetPlexResult( + serverName: string, + path: string, + getter: () => Promise>, + opts?: { setOnError: boolean }, + ): Promise> { + const key = this.getCacheKey(serverName, path); + const existing = this.#cache.get>(key); + if (isDefined(existing)) { + return existing; + } + + const value = await getter(); + if (isQuerySuccess(value) || (isQueryError(value) && opts?.setOnError)) { + this.#cache.set(key, value); + } + + return value; + } + + private getCacheKey(serverName: string, path: string) { + return `${serverName}|${path}`; + } +} diff --git a/server/src/ffmpeg/ffmpeg.ts b/server/src/ffmpeg/ffmpeg.ts index 90b2c0b7..a79f05b3 100644 --- a/server/src/ffmpeg/ffmpeg.ts +++ b/server/src/ffmpeg/ffmpeg.ts @@ -13,7 +13,7 @@ import { import path from 'path'; import { DeepReadonly, DeepRequired } from 'ts-essentials'; import { serverOptions } from '../globals.js'; -import { StreamDetails } from '../stream/plex/PlexTranscoder.js'; +import { StreamDetails } from '../stream/types.js'; import { StreamContextChannel } from '../stream/types.js'; import { Maybe } from '../types/util.js'; import { TypedEventEmitter } from '../types/eventEmitter.js'; @@ -365,6 +365,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< startTime: number, duration: Maybe, enableIcon: Maybe, + extraInnputHeaders: Record = {}, ) { return this.spawn( streamUrl, @@ -373,6 +374,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< duration, true, enableIcon, + extraInnputHeaders, ); } @@ -430,6 +432,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< duration: Maybe, limitRead: boolean, watermark: Maybe, + extraInnputHeaders: Record = {}, ) { const ffmpegArgs: string[] = [ '-hide_banner', @@ -461,6 +464,9 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< let videoFile = -1; let overlayFile = -1; if (isNonEmptyString(streamUrl)) { + for (const [key, value] of Object.entries(extraInnputHeaders)) { + ffmpegArgs.push('-headers', `'${key}: ${value}'`); + } ffmpegArgs.push(`-i`, streamUrl); videoFile = inputFiles++; audioFile = videoFile; @@ -880,7 +886,8 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< const argsWithTokenRedacted = ffmpegArgs .join(' ') - .replaceAll(/(.*X-Plex-Token=)([A-z0-9_\\-]+)(.*)/g, '$1REDACTED$3'); + .replaceAll(/(.*X-Plex-Token=)([A-z0-9_\\-]+)(.*)/g, '$1REDACTED$3') + .replaceAll(/(.*X-Emby-Token:\s)([A-z0-9_\\-]+)(.*)/g, '$1REDACTED$3'); this.logger.debug(`Starting ffmpeg with args: "%s"`, argsWithTokenRedacted); this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { diff --git a/server/src/migrations/.snapshot-db.db.json b/server/src/migrations/.snapshot-db.db.json index aa33e5c3..7a3368d4 100644 --- a/server/src/migrations/.snapshot-db.db.json +++ b/server/src/migrations/.snapshot-db.db.json @@ -10,6 +10,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "url": { @@ -19,6 +20,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "mime_type": { @@ -28,6 +30,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" } }, @@ -35,7 +38,9 @@ "indexes": [ { "keyName": "primary", - "columnNames": ["hash"], + "columnNames": [ + "hash" + ], "composite": false, "constraint": true, "primary": true, @@ -55,6 +60,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -63,8 +69,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -73,8 +79,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "number": { @@ -84,6 +90,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "icon": { @@ -93,6 +100,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "json" }, "guide_minimum_duration": { @@ -102,6 +110,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "disable_filler_overlay": { @@ -111,6 +120,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "default": "false", "mappedType": "integer" }, @@ -121,6 +131,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "duration": { @@ -130,6 +141,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "stealth": { @@ -139,6 +151,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "default": "false", "mappedType": "integer" }, @@ -149,6 +162,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "start_time": { @@ -158,6 +172,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "guide_flex_title": { @@ -167,6 +182,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "offline": { @@ -176,6 +192,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "default": "'{\"mode\":\"clip\"}'", "mappedType": "json" }, @@ -186,6 +203,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "watermark": { @@ -195,6 +213,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "json" }, "transcoding": { @@ -204,6 +223,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "json" } }, @@ -211,7 +231,9 @@ "indexes": [ { "keyName": "channel_number_unique", - "columnNames": ["number"], + "columnNames": [ + "number" + ], "composite": false, "constraint": true, "primary": false, @@ -219,7 +241,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -239,6 +263,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -247,8 +272,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -257,8 +282,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "name": { @@ -268,6 +293,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, @@ -275,7 +301,9 @@ "indexes": [ { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -295,6 +323,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "custom_show_uuid": { @@ -304,13 +333,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, "name": "channel_custom_shows", "indexes": [ { - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "composite": false, "keyName": "channel_custom_shows_channel_uuid_index", "constraint": false, @@ -318,7 +350,9 @@ "unique": false }, { - "columnNames": ["custom_show_uuid"], + "columnNames": [ + "custom_show_uuid" + ], "composite": false, "keyName": "channel_custom_shows_custom_show_uuid_index", "constraint": false, @@ -327,7 +361,10 @@ }, { "keyName": "primary", - "columnNames": ["channel_uuid", "custom_show_uuid"], + "columnNames": [ + "channel_uuid", + "custom_show_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -338,18 +375,26 @@ "foreignKeys": { "channel_custom_shows_channel_uuid_foreign": { "constraintName": "channel_custom_shows_channel_uuid_foreign", - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "localTableName": "channel_custom_shows", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "channel", "deleteRule": "cascade", "updateRule": "cascade" }, "channel_custom_shows_custom_show_uuid_foreign": { "constraintName": "channel_custom_shows_custom_show_uuid_foreign", - "columnNames": ["custom_show_uuid"], + "columnNames": [ + "custom_show_uuid" + ], "localTableName": "channel_custom_shows", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "custom_show", "deleteRule": "cascade", "updateRule": "cascade" @@ -366,6 +411,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -375,7 +421,7 @@ "autoincrement": false, "primary": false, "nullable": true, - "length": 0, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -385,7 +431,7 @@ "autoincrement": false, "primary": false, "nullable": true, - "length": 0, + "length": null, "mappedType": "datetime" }, "name": { @@ -395,6 +441,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, @@ -402,7 +449,9 @@ "indexes": [ { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -422,6 +471,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "filler_show_uuid": { @@ -431,6 +481,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "weight": { @@ -440,6 +491,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "cooldown": { @@ -449,13 +501,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" } }, "name": "channel_filler_show", "indexes": [ { - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "composite": false, "keyName": "channel_filler_show_channel_uuid_index", "constraint": false, @@ -463,7 +518,9 @@ "unique": false }, { - "columnNames": ["filler_show_uuid"], + "columnNames": [ + "filler_show_uuid" + ], "composite": false, "keyName": "channel_filler_show_filler_show_uuid_index", "constraint": false, @@ -472,7 +529,10 @@ }, { "keyName": "primary", - "columnNames": ["channel_uuid", "filler_show_uuid"], + "columnNames": [ + "channel_uuid", + "filler_show_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -483,17 +543,25 @@ "foreignKeys": { "channel_filler_show_channel_uuid_foreign": { "constraintName": "channel_filler_show_channel_uuid_foreign", - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "localTableName": "channel_filler_show", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "channel", "updateRule": "cascade" }, "channel_filler_show_filler_show_uuid_foreign": { "constraintName": "channel_filler_show_filler_show_uuid_foreign", - "columnNames": ["filler_show_uuid"], + "columnNames": [ + "filler_show_uuid" + ], "localTableName": "channel_filler_show", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "filler_show", "updateRule": "cascade" } @@ -509,6 +577,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -517,8 +586,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -527,10 +596,25 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, + "type": { + "name": "type", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "default": "'plex'", + "enumItems": [ + "plex", + "jellyfin" + ], + "mappedType": "enum" + }, "name": { "name": "name", "type": "text", @@ -538,6 +622,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "uri": { @@ -547,6 +632,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "access_token": { @@ -556,6 +642,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "send_guide_updates": { @@ -565,6 +652,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "default": "true", "mappedType": "integer" }, @@ -575,6 +663,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "default": "true", "mappedType": "integer" }, @@ -585,6 +674,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "client_identifier": { @@ -594,14 +684,19 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" } }, - "name": "plex_server_settings", + "name": "media_source", "indexes": [ { - "keyName": "plex_server_settings_name_uri_unique", - "columnNames": ["name", "uri"], + "keyName": "media_source_type_name_uri_unique", + "columnNames": [ + "type", + "name", + "uri" + ], "composite": true, "constraint": true, "primary": false, @@ -609,7 +704,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -629,6 +726,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -637,8 +735,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -647,18 +745,19 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "type": { "name": "type", - "type": "text", + "type": "ProgramGroupingType", "unsigned": false, "autoincrement": false, "primary": false, "nullable": false, - "mappedType": "text" + "length": null, + "mappedType": "unknown" }, "title": { "name": "title", @@ -667,6 +766,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "summary": { @@ -676,6 +776,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "icon": { @@ -685,6 +786,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "year": { @@ -694,6 +796,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "index": { @@ -703,6 +806,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "show_uuid": { @@ -712,6 +816,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "artist_uuid": { @@ -721,13 +826,16 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" } }, "name": "program_grouping", "indexes": [ { - "columnNames": ["show_uuid"], + "columnNames": [ + "show_uuid" + ], "composite": false, "keyName": "program_grouping_show_uuid_index", "constraint": false, @@ -735,7 +843,9 @@ "unique": false }, { - "columnNames": ["artist_uuid"], + "columnNames": [ + "artist_uuid" + ], "composite": false, "keyName": "program_grouping_artist_uuid_index", "constraint": false, @@ -744,7 +854,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -755,18 +867,26 @@ "foreignKeys": { "program_grouping_show_uuid_foreign": { "constraintName": "program_grouping_show_uuid_foreign", - "columnNames": ["show_uuid"], + "columnNames": [ + "show_uuid" + ], "localTableName": "program_grouping", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", "deleteRule": "set null", "updateRule": "cascade" }, "program_grouping_artist_uuid_foreign": { "constraintName": "program_grouping_artist_uuid_foreign", - "columnNames": ["artist_uuid"], + "columnNames": [ + "artist_uuid" + ], "localTableName": "program_grouping", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", "deleteRule": "set null", "updateRule": "cascade" @@ -783,6 +903,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -791,8 +912,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -801,8 +922,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "source_type": { @@ -812,7 +933,11 @@ "autoincrement": false, "primary": false, "nullable": false, - "enumItems": ["plex"], + "length": null, + "enumItems": [ + "plex", + "jellyfin" + ], "mappedType": "enum" }, "original_air_date": { @@ -822,6 +947,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "duration": { @@ -831,6 +957,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "episode": { @@ -840,6 +967,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "episode_icon": { @@ -849,6 +977,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "file_path": { @@ -858,6 +987,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "icon": { @@ -867,6 +997,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "external_source_id": { @@ -876,6 +1007,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "external_key": { @@ -885,6 +1017,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "plex_rating_key": { @@ -894,6 +1027,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "plex_file_path": { @@ -903,6 +1037,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "parent_external_key": { @@ -912,6 +1047,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "grandparent_external_key": { @@ -921,6 +1057,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "rating": { @@ -930,6 +1067,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "season_number": { @@ -939,6 +1077,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "season_icon": { @@ -948,6 +1087,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "show_icon": { @@ -957,6 +1097,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "show_title": { @@ -966,6 +1107,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "summary": { @@ -975,6 +1117,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "title": { @@ -984,6 +1127,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "type": { @@ -993,7 +1137,12 @@ "autoincrement": false, "primary": false, "nullable": false, - "enumItems": ["movie", "episode", "track"], + "length": null, + "enumItems": [ + "movie", + "episode", + "track" + ], "mappedType": "enum" }, "year": { @@ -1003,6 +1152,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "artist_name": { @@ -1012,6 +1162,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "album_name": { @@ -1021,6 +1172,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "season_uuid": { @@ -1030,6 +1182,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "tv_show_uuid": { @@ -1039,6 +1192,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "album_uuid": { @@ -1048,6 +1202,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "artist_uuid": { @@ -1057,13 +1212,16 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" } }, "name": "program", "indexes": [ { - "columnNames": ["season_uuid"], + "columnNames": [ + "season_uuid" + ], "composite": false, "keyName": "program_season_uuid_index", "constraint": false, @@ -1071,7 +1229,9 @@ "unique": false }, { - "columnNames": ["tv_show_uuid"], + "columnNames": [ + "tv_show_uuid" + ], "composite": false, "keyName": "program_tv_show_uuid_index", "constraint": false, @@ -1079,7 +1239,9 @@ "unique": false }, { - "columnNames": ["album_uuid"], + "columnNames": [ + "album_uuid" + ], "composite": false, "keyName": "program_album_uuid_index", "constraint": false, @@ -1087,7 +1249,9 @@ "unique": false }, { - "columnNames": ["artist_uuid"], + "columnNames": [ + "artist_uuid" + ], "composite": false, "keyName": "program_artist_uuid_index", "constraint": false, @@ -1108,7 +1272,11 @@ }, { "keyName": "program_source_type_external_source_id_external_key_unique", - "columnNames": ["source_type", "external_source_id", "external_key"], + "columnNames": [ + "source_type", + "external_source_id", + "external_key" + ], "composite": true, "constraint": true, "primary": false, @@ -1116,7 +1284,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -1127,39 +1297,51 @@ "foreignKeys": { "program_season_uuid_foreign": { "constraintName": "program_season_uuid_foreign", - "columnNames": ["season_uuid"], + "columnNames": [ + "season_uuid" + ], "localTableName": "program", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", - "deleteRule": "set null", - "updateRule": "cascade" + "deleteRule": "set null" }, "program_tv_show_uuid_foreign": { "constraintName": "program_tv_show_uuid_foreign", - "columnNames": ["tv_show_uuid"], + "columnNames": [ + "tv_show_uuid" + ], "localTableName": "program", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", - "deleteRule": "set null", - "updateRule": "cascade" + "deleteRule": "set null" }, "program_album_uuid_foreign": { "constraintName": "program_album_uuid_foreign", - "columnNames": ["album_uuid"], + "columnNames": [ + "album_uuid" + ], "localTableName": "program", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", - "deleteRule": "set null", - "updateRule": "cascade" + "deleteRule": "set null" }, "program_artist_uuid_foreign": { "constraintName": "program_artist_uuid_foreign", - "columnNames": ["artist_uuid"], + "columnNames": [ + "artist_uuid" + ], "localTableName": "program", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", - "deleteRule": "set null", - "updateRule": "cascade" + "deleteRule": "set null" } }, "nativeEnums": {} @@ -1173,6 +1355,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -1181,8 +1364,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -1191,8 +1374,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "source_type": { @@ -1202,7 +1385,15 @@ "autoincrement": false, "primary": false, "nullable": false, - "enumItems": ["plex", "plex-guid", "tmdb", "imdb", "tvdb"], + "length": null, + "enumItems": [ + "plex", + "plex-guid", + "tmdb", + "imdb", + "tvdb", + "jellyfin" + ], "mappedType": "enum" }, "external_source_id": { @@ -1212,6 +1403,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "external_key": { @@ -1221,6 +1413,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "external_file_path": { @@ -1230,6 +1423,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "direct_file_path": { @@ -1239,6 +1433,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "program_uuid": { @@ -1248,13 +1443,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, "name": "program_external_id", "indexes": [ { - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "composite": false, "keyName": "program_external_id_program_uuid_index", "constraint": false, @@ -1263,7 +1461,11 @@ }, { "keyName": "unique_program_multi_external_id", - "columnNames": ["program_uuid", "source_type", "external_source_id"], + "columnNames": [ + "program_uuid", + "source_type", + "external_source_id" + ], "composite": true, "constraint": false, "primary": false, @@ -1272,7 +1474,10 @@ }, { "keyName": "unique_program_single_external_id", - "columnNames": ["program_uuid", "source_type"], + "columnNames": [ + "program_uuid", + "source_type" + ], "composite": true, "constraint": false, "primary": false, @@ -1281,7 +1486,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -1292,9 +1499,13 @@ "foreignKeys": { "program_external_id_program_uuid_foreign": { "constraintName": "program_external_id_program_uuid_foreign", - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "localTableName": "program_external_id", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program", "updateRule": "cascade" } @@ -1310,6 +1521,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "program_uuid": { @@ -1319,6 +1531,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "index": { @@ -1328,13 +1541,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" } }, "name": "filler_show_content", "indexes": [ { - "columnNames": ["filler_show_uuid"], + "columnNames": [ + "filler_show_uuid" + ], "composite": false, "keyName": "filler_show_content_filler_show_uuid_index", "constraint": false, @@ -1342,7 +1558,9 @@ "unique": false }, { - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "composite": false, "keyName": "filler_show_content_program_uuid_index", "constraint": false, @@ -1351,7 +1569,11 @@ }, { "keyName": "filler_show_content_filler_show_uuid_program_uuid_index_unique", - "columnNames": ["filler_show_uuid", "program_uuid", "index"], + "columnNames": [ + "filler_show_uuid", + "program_uuid", + "index" + ], "composite": true, "constraint": true, "primary": false, @@ -1359,7 +1581,10 @@ }, { "keyName": "primary", - "columnNames": ["filler_show_uuid", "program_uuid"], + "columnNames": [ + "filler_show_uuid", + "program_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -1370,17 +1595,25 @@ "foreignKeys": { "filler_show_content_filler_show_uuid_foreign": { "constraintName": "filler_show_content_filler_show_uuid_foreign", - "columnNames": ["filler_show_uuid"], + "columnNames": [ + "filler_show_uuid" + ], "localTableName": "filler_show_content", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "filler_show", "updateRule": "cascade" }, "filler_show_content_program_uuid_foreign": { "constraintName": "filler_show_content_program_uuid_foreign", - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "localTableName": "filler_show_content", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program", "updateRule": "cascade" } @@ -1396,6 +1629,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "content_uuid": { @@ -1405,6 +1639,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "index": { @@ -1414,13 +1649,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" } }, "name": "custom_show_content", "indexes": [ { - "columnNames": ["custom_show_uuid"], + "columnNames": [ + "custom_show_uuid" + ], "composite": false, "keyName": "custom_show_content_custom_show_uuid_index", "constraint": false, @@ -1428,7 +1666,9 @@ "unique": false }, { - "columnNames": ["content_uuid"], + "columnNames": [ + "content_uuid" + ], "composite": false, "keyName": "custom_show_content_content_uuid_index", "constraint": false, @@ -1437,7 +1677,11 @@ }, { "keyName": "custom_show_content_custom_show_uuid_content_uuid_index_unique", - "columnNames": ["custom_show_uuid", "content_uuid", "index"], + "columnNames": [ + "custom_show_uuid", + "content_uuid", + "index" + ], "composite": true, "constraint": true, "primary": false, @@ -1445,7 +1689,10 @@ }, { "keyName": "primary", - "columnNames": ["custom_show_uuid", "content_uuid"], + "columnNames": [ + "custom_show_uuid", + "content_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -1456,17 +1703,25 @@ "foreignKeys": { "custom_show_content_custom_show_uuid_foreign": { "constraintName": "custom_show_content_custom_show_uuid_foreign", - "columnNames": ["custom_show_uuid"], + "columnNames": [ + "custom_show_uuid" + ], "localTableName": "custom_show_content", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "custom_show", "updateRule": "cascade" }, "custom_show_content_content_uuid_foreign": { "constraintName": "custom_show_content_content_uuid_foreign", - "columnNames": ["content_uuid"], + "columnNames": [ + "content_uuid" + ], "localTableName": "custom_show_content", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program", "updateRule": "cascade" } @@ -1482,6 +1737,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "program_uuid": { @@ -1491,13 +1747,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, "name": "channel_programs", "indexes": [ { - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "composite": false, "keyName": "channel_programs_channel_uuid_index", "constraint": false, @@ -1505,7 +1764,9 @@ "unique": false }, { - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "composite": false, "keyName": "channel_programs_program_uuid_index", "constraint": false, @@ -1514,7 +1775,10 @@ }, { "keyName": "primary", - "columnNames": ["channel_uuid", "program_uuid"], + "columnNames": [ + "channel_uuid", + "program_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -1525,18 +1789,26 @@ "foreignKeys": { "channel_programs_channel_uuid_foreign": { "constraintName": "channel_programs_channel_uuid_foreign", - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "localTableName": "channel_programs", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "channel", "deleteRule": "cascade", "updateRule": "cascade" }, "channel_programs_program_uuid_foreign": { "constraintName": "channel_programs_program_uuid_foreign", - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "localTableName": "channel_programs", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program", "deleteRule": "cascade", "updateRule": "cascade" @@ -1553,6 +1825,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "program_uuid": { @@ -1562,13 +1835,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, "name": "channel_fallback", "indexes": [ { - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "composite": false, "keyName": "channel_fallback_channel_uuid_index", "constraint": false, @@ -1576,7 +1852,9 @@ "unique": false }, { - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "composite": false, "keyName": "channel_fallback_program_uuid_index", "constraint": false, @@ -1585,7 +1863,10 @@ }, { "keyName": "primary", - "columnNames": ["channel_uuid", "program_uuid"], + "columnNames": [ + "channel_uuid", + "program_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -1596,18 +1877,26 @@ "foreignKeys": { "channel_fallback_channel_uuid_foreign": { "constraintName": "channel_fallback_channel_uuid_foreign", - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "localTableName": "channel_fallback", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "channel", "deleteRule": "cascade", "updateRule": "cascade" }, "channel_fallback_program_uuid_foreign": { "constraintName": "channel_fallback_program_uuid_foreign", - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "localTableName": "channel_fallback", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program", "deleteRule": "cascade", "updateRule": "cascade" @@ -1624,6 +1913,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -1632,8 +1922,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -1642,8 +1932,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "source_type": { @@ -1653,7 +1943,15 @@ "autoincrement": false, "primary": false, "nullable": false, - "enumItems": ["plex", "plex-guid", "tmdb", "imdb", "tvdb"], + "length": null, + "enumItems": [ + "plex", + "plex-guid", + "tmdb", + "imdb", + "tvdb", + "jellyfin" + ], "mappedType": "enum" }, "external_source_id": { @@ -1663,6 +1961,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "external_key": { @@ -1672,6 +1971,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "external_file_path": { @@ -1681,6 +1981,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "group_uuid": { @@ -1690,13 +1991,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, "name": "program_grouping_external_id", "indexes": [ { - "columnNames": ["group_uuid"], + "columnNames": [ + "group_uuid" + ], "composite": false, "keyName": "program_grouping_external_id_group_uuid_index", "constraint": false, @@ -1705,7 +2009,10 @@ }, { "keyName": "program_grouping_external_id_uuid_source_type_unique", - "columnNames": ["uuid", "source_type"], + "columnNames": [ + "uuid", + "source_type" + ], "composite": true, "constraint": true, "primary": false, @@ -1713,7 +2020,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -1724,9 +2033,13 @@ "foreignKeys": { "program_grouping_external_id_group_uuid_foreign": { "constraintName": "program_grouping_external_id_group_uuid_foreign", - "columnNames": ["group_uuid"], + "columnNames": [ + "group_uuid" + ], "localTableName": "program_grouping_external_id", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", "updateRule": "cascade" } diff --git a/server/src/migrations/Migration20240719145409.ts b/server/src/migrations/Migration20240719145409.ts new file mode 100644 index 00000000..b2c1fc0b --- /dev/null +++ b/server/src/migrations/Migration20240719145409.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240719145409 extends Migration { + async up(): Promise { + this.addSql('alter table `plex_server_settings` rename to `media_source`;'); + this.addSql( + "alter table `media_source` add column `type` text check (`type` in ('plex', 'jellyfin')) not null default 'plex';", + ); + this.addSql('drop index if exists `plex_server_settings_name_uri_unique`;'); + this.addSql( + 'create unique index `media_source_type_name_uri_unique` on `media_source` (`type`, `name`, `uri`);', + ); + } + + async down() { + this.addSql('alter table `media_source` drop column `type`'); + this.addSql('alter table `media_source` rename to `plex_server_settings`;'); + this.addSql('drop index if exists `media_source_type_name_uri_unique`;'); + this.addSql( + 'CREATE UNIQUE INDEX `plex_server_settings_name_uri_unique` on `plex_server_settings` (`name`, `uri`);', + ); + } +} diff --git a/server/src/migrations/Migration20240805185042.ts b/server/src/migrations/Migration20240805185042.ts new file mode 100644 index 00000000..b5df3168 --- /dev/null +++ b/server/src/migrations/Migration20240805185042.ts @@ -0,0 +1,89 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240805185042 extends Migration { + async up(): Promise { + this.addSql('pragma foreign_keys = off;'); + this.addSql( + 'create table `program_grouping__temp_alter` (`uuid` text not null, `created_at` datetime not null, `updated_at` datetime not null, `type` ProgramGroupingType not null, `title` text not null, `summary` text null, `icon` text null, `year` integer null, `index` integer null, `show_uuid` text null, `artist_uuid` text null, constraint `program_grouping_show_uuid_foreign` foreign key(`show_uuid`) references `program_grouping`(`uuid`) on delete set null on update cascade, constraint `program_grouping_artist_uuid_foreign` foreign key(`artist_uuid`) references `program_grouping`(`uuid`) on delete set null on update cascade, primary key (`uuid`));', + ); + this.addSql( + 'insert into `program_grouping__temp_alter` select * from `program_grouping`;', + ); + this.addSql('drop table `program_grouping`;'); + this.addSql( + 'alter table `program_grouping__temp_alter` rename to `program_grouping`;', + ); + this.addSql( + 'create index `program_grouping_show_uuid_index` on `program_grouping` (`show_uuid`);', + ); + this.addSql( + 'create index `program_grouping_artist_uuid_index` on `program_grouping` (`artist_uuid`);', + ); + this.addSql('pragma foreign_keys = on;'); + this.addSql('pragma foreign_keys = off;'); + this.addSql( + "create table `program__temp_alter` (`uuid` text not null, `created_at` datetime not null, `updated_at` datetime not null, `source_type` text check (`source_type` in ('plex', 'jellyfin')) not null, `original_air_date` text null, `duration` integer not null, `episode` integer null, `episode_icon` text null, `file_path` text null, `icon` text null, `external_source_id` text not null, `external_key` text not null, `plex_rating_key` text null, `plex_file_path` text null, `parent_external_key` text null, `grandparent_external_key` text null, `rating` text null, `season_number` integer null, `season_icon` text null, `show_icon` text null, `show_title` text null, `summary` text null, `title` text not null, `type` text check (`type` in ('movie', 'episode', 'track')) not null, `year` integer null, `artist_name` text null, `album_name` text null, `season_uuid` text null, `tv_show_uuid` text null, `album_uuid` text null, `artist_uuid` text null, constraint `program_season_uuid_foreign` foreign key(`season_uuid`) references `program_grouping`(`uuid`) on delete set null, constraint `program_tv_show_uuid_foreign` foreign key(`tv_show_uuid`) references `program_grouping`(`uuid`) on delete set null, constraint `program_album_uuid_foreign` foreign key(`album_uuid`) references `program_grouping`(`uuid`) on delete set null, constraint `program_artist_uuid_foreign` foreign key(`artist_uuid`) references `program_grouping`(`uuid`) on delete set null, primary key (`uuid`));", + ); + this.addSql('insert into `program__temp_alter` select * from `program`;'); + this.addSql('drop table `program`;'); + this.addSql('alter table `program__temp_alter` rename to `program`;'); + this.addSql( + 'create index `program_season_uuid_index` on `program` (`season_uuid`);', + ); + this.addSql( + 'create index `program_tv_show_uuid_index` on `program` (`tv_show_uuid`);', + ); + this.addSql( + 'create index `program_album_uuid_index` on `program` (`album_uuid`);', + ); + this.addSql( + 'create index `program_artist_uuid_index` on `program` (`artist_uuid`);', + ); + this.addSql( + 'create index `program_source_type_external_source_id_plex_rating_key_index` on `program` (`source_type`, `external_source_id`, `plex_rating_key`);', + ); + this.addSql( + 'create unique index `program_source_type_external_source_id_external_key_unique` on `program` (`source_type`, `external_source_id`, `external_key`);', + ); + this.addSql('pragma foreign_keys = on;'); + this.addSql('pragma foreign_keys = off;'); + this.addSql( + "create table `program_external_id__temp_alter` (`uuid` text not null, `created_at` datetime not null, `updated_at` datetime not null, `source_type` text check (`source_type` in ('plex', 'plex-guid', 'tmdb', 'imdb', 'tvdb', 'jellyfin')) not null, `external_source_id` text null, `external_key` text not null, `external_file_path` text null, `direct_file_path` text null, `program_uuid` text not null, constraint `program_external_id_program_uuid_foreign` foreign key(`program_uuid`) references `program`(`uuid`) on update cascade, primary key (`uuid`));", + ); + this.addSql( + 'insert into `program_external_id__temp_alter` select * from `program_external_id`;', + ); + this.addSql('drop table `program_external_id`;'); + this.addSql( + 'alter table `program_external_id__temp_alter` rename to `program_external_id`;', + ); + this.addSql( + 'create index `program_external_id_program_uuid_index` on `program_external_id` (`program_uuid`);', + ); + this.addSql( + 'create unique index `unique_program_multiple_external_id` on `program_external_id` (`program_uuid`, `source_type`, `external_source_id`) WHERE `external_source_id` IS NOT NULL;', + ); + this.addSql( + 'create unique index `unique_program_single_external_id` on `program_external_id` (`program_uuid`, `source_type`) WHERE `external_source_id` IS NULL;', + ); + this.addSql('pragma foreign_keys = on;'); + this.addSql('pragma foreign_keys = off;'); + this.addSql( + "create table `program_grouping_external_id__temp_alter` (`uuid` text not null, `created_at` datetime not null, `updated_at` datetime not null, `source_type` text check (`source_type` in ('plex', 'plex-guid', 'tmdb', 'imdb', 'tvdb', 'jellyfin')) not null, `external_source_id` text null, `external_key` text not null, `external_file_path` text null, `group_uuid` text not null, constraint `program_grouping_external_id_group_uuid_foreign` foreign key(`group_uuid`) references `program_grouping`(`uuid`) on update cascade, primary key (`uuid`));", + ); + this.addSql( + 'insert into `program_grouping_external_id__temp_alter` select * from `program_grouping_external_id`;', + ); + this.addSql('drop table `program_grouping_external_id`;'); + this.addSql( + 'alter table `program_grouping_external_id__temp_alter` rename to `program_grouping_external_id`;', + ); + this.addSql( + 'create index `program_grouping_external_id_group_uuid_index` on `program_grouping_external_id` (`group_uuid`);', + ); + this.addSql( + 'create unique index `program_grouping_external_id_uuid_source_type_unique` on `program_grouping_external_id` (`uuid`, `source_type`);', + ); + this.addSql('pragma foreign_keys = on;'); + } +} diff --git a/server/src/serverContext.ts b/server/src/serverContext.ts index 25567506..e6205eb4 100644 --- a/server/src/serverContext.ts +++ b/server/src/serverContext.ts @@ -4,8 +4,8 @@ import path from 'path'; import { XmlTvWriter } from './XmlTvWriter.js'; import { ChannelDB } from './dao/channelDb.js'; import { CustomShowDB } from './dao/customShowDb.js'; -import { FillerDB } from './dao/fillerDb.js'; -import { PlexServerDB } from './dao/plexServerDb.js'; +import { FillerDB } from './dao/fillerDB.js'; +import { MediaSourceDB } from './dao/mediaSourceDB.js'; import { ProgramDB } from './dao/programDB.js'; import { SettingsDB, getSettings } from './dao/settings.js'; import { serverOptions } from './globals.js'; @@ -33,7 +33,7 @@ export class ServerContext { public hdhrService: HdhrService, public customShowDB: CustomShowDB, public channelCache: ChannelCache, - public plexServerDB: PlexServerDB, + public mediaSourceDB: MediaSourceDB, public settings: SettingsDB, public programDB: ProgramDB, ) {} @@ -77,7 +77,7 @@ export const serverContext: () => ServerContext = once(() => { new HdhrService(settings), customShowDB, channelCache, - new PlexServerDB(channelDB), + new MediaSourceDB(channelDB), settings, new ProgramDB(), ); diff --git a/server/src/services/PlexItemEnumerator.ts b/server/src/services/PlexItemEnumerator.ts index 4df47ded..faaebf2b 100644 --- a/server/src/services/PlexItemEnumerator.ts +++ b/server/src/services/PlexItemEnumerator.ts @@ -10,7 +10,7 @@ import { import { flatten, isNil, uniqBy } from 'lodash-es'; import map from 'lodash-es/map'; import { ProgramDB } from '../dao/programDB'; -import { Plex } from '../external/plex'; +import { PlexApiClient } from '../external/plex/PlexApiClient'; import { typedProperty } from '../types/path'; import { flatMapAsyncSeq, wait } from '../util/index.js'; import { Logger, LoggerFactory } from '../util/logging/LoggerFactory'; @@ -24,10 +24,10 @@ export type EnrichedPlexTerminalMedia = PlexTerminalMedia & { export class PlexItemEnumerator { #logger: Logger = LoggerFactory.child({ className: PlexItemEnumerator.name }); #timer = new Timer(this.#logger); - #plex: Plex; + #plex: PlexApiClient; #programDB: ProgramDB; - constructor(plex: Plex, programDB: ProgramDB) { + constructor(plex: PlexApiClient, programDB: ProgramDB) { this.#plex = plex; this.#programDB = programDB; } @@ -55,7 +55,7 @@ export class PlexItemEnumerator { } else if (isPlexDirectory(item)) { return []; } else { - const plexResult = await this.#plex.doGet( + const plexResult = await this.#plex.doGetPath( item.key, ); diff --git a/server/src/services/dynamic_channels/PlexContentSourceUpdater.ts b/server/src/services/dynamic_channels/PlexContentSourceUpdater.ts index 90894d67..7ac48916 100644 --- a/server/src/services/dynamic_channels/PlexContentSourceUpdater.ts +++ b/server/src/services/dynamic_channels/PlexContentSourceUpdater.ts @@ -7,9 +7,9 @@ import { isNil, map } from 'lodash-es'; import { ChannelDB } from '../../dao/channelDb.js'; import { EntityManager } from '../../dao/dataSource.js'; import { Channel } from '../../dao/entities/Channel.js'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; +import { MediaSource } from '../../dao/entities/MediaSource.js'; import { ProgramDB } from '../../dao/programDB.js'; -import { Plex } from '../../external/plex.js'; +import { PlexApiClient } from '../../external/plex/PlexApiClient.js'; import { EnrichedPlexTerminalMedia, PlexItemEnumerator, @@ -26,7 +26,7 @@ export class PlexContentSourceUpdater extends ContentSourceUpdater - this.#plex.doGet( + this.#plex.doGetPath( `/library/sections/${this.config.plexLibraryKey}/all?${filter.join( '&', )}`, @@ -100,7 +100,7 @@ const plexMediaToContentProgram = ( return { id: media.id ?? uniqueId, persisted: !isNil(media.id), - originalProgram: media, + originalProgram: { sourceType: 'plex', program: media }, duration: media.duration, externalSourceName: serverName, externalSourceType: 'plex', diff --git a/server/src/stream/ProgramPlayer.ts b/server/src/stream/ProgramPlayer.ts index 7179d60c..daec4412 100644 --- a/server/src/stream/ProgramPlayer.ts +++ b/server/src/stream/ProgramPlayer.ts @@ -22,16 +22,18 @@ import { FfmpegSettings, Watermark } from '@tunarr/types'; import { isError, isString, isUndefined } from 'lodash-es'; import { Writable } from 'stream'; import { isContentBackedLineupIteam } from '../dao/derived_types/StreamLineup.js'; +import { MediaSourceType } from '../dao/entities/MediaSource.js'; import { FfmpegEvents } from '../ffmpeg/ffmpeg.js'; import { TypedEventEmitter } from '../types/eventEmitter.js'; import { Maybe } from '../types/util.js'; import { isNonEmptyString } from '../util/index.js'; +import { LoggerFactory } from '../util/logging/LoggerFactory.js'; +import { makeLocalUrl } from '../util/serverUtil.js'; import { OfflinePlayer } from './OfflinePlayer.js'; import { Player, PlayerContext } from './Player.js'; +import { JellyfinPlayer } from './jellyfin/JellyfinPlayer.js'; import { PlexPlayer } from './plex/PlexPlayer.js'; import { StreamContextChannel } from './types.js'; -import { LoggerFactory } from '../util/logging/LoggerFactory.js'; -import { serverOptions } from '../globals.js'; export class ProgramPlayer extends Player { private logger = LoggerFactory.child({ caller: import.meta }); @@ -53,17 +55,22 @@ export class ProgramPlayer extends Player { this.delegate = new OfflinePlayer(true, context); } else if (program.type === 'loading') { this.logger.debug('About to play loading stream'); - /* loading */ context.isLoading = true; this.delegate = new OfflinePlayer(false, context); } else if (program.type === 'offline') { this.logger.debug('About to play offline stream'); - /* offline */ this.delegate = new OfflinePlayer(false, context); - } else if (isContentBackedLineupIteam(program) && program) { - this.logger.debug('About to play plex stream'); - /* plex */ - this.delegate = new PlexPlayer(context); + } else if (isContentBackedLineupIteam(program)) { + switch (program.externalSource) { + case MediaSourceType.Plex: + this.logger.debug('About to play plex stream'); + this.delegate = new PlexPlayer(context); + break; + case MediaSourceType.Jellyfin: + this.logger.debug('About to play plex stream'); + this.delegate = new JellyfinPlayer(context); + break; + } } this.context.watermark = this.getWatermark( context.ffmpegSettings, @@ -137,8 +144,8 @@ export class ProgramPlayer extends Player { ); } this.logger.error( - 'Error when attempting to play video. Fallback to error stream: ' + - actualError.stack, + actualError, + 'Error when attempting to play video. Fallback to error stream', ); //Retry once with an error stream: this.context.lineupItem = { @@ -182,7 +189,7 @@ export class ProgramPlayer extends Player { } else if (isNonEmptyString(channel.icon?.path)) { icon = channel.icon.path; } else { - icon = `http://localhost:${serverOptions().port}/images/tunarr.png`; + icon = makeLocalUrl('/images/tunarr.png'); } console.log(watermark); diff --git a/server/src/stream/StreamProgramCalculator.ts b/server/src/stream/StreamProgramCalculator.ts index eb953e98..cfd34806 100644 --- a/server/src/stream/StreamProgramCalculator.ts +++ b/server/src/stream/StreamProgramCalculator.ts @@ -1,14 +1,6 @@ import { Loaded } from '@mikro-orm/core'; import constants from '@tunarr/shared/constants'; -import { - find, - first, - isEmpty, - isNil, - isNull, - isUndefined, - pick, -} from 'lodash-es'; +import { first, isEmpty, isNil, isNull, isUndefined, pick } from 'lodash-es'; import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js'; import { getEm } from '../dao/dataSource.js'; import { @@ -34,8 +26,10 @@ import { binarySearchRange } from '../util/binarySearch.js'; import { isNonEmptyString, zipWithIndex } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; import { STREAM_CHANNEL_CONTEXT_KEYS, StreamContextChannel } from './types.js'; -import { FillerDB } from '../dao/fillerDb.js'; +import { FillerDB } from '../dao/fillerDB.js'; import { ChannelDB } from '../dao/channelDb.js'; +import { MediaSourceType } from '../dao/entities/MediaSource.js'; +import { ProgramExternalId } from '../dao/entities/ProgramExternalId.js'; const SLACK = constants.SLACK; @@ -164,7 +158,12 @@ export class StreamProgramCalculator { populate: ['externalIds'], populateWhere: { externalIds: { - sourceType: ProgramExternalIdType.PLEX, + sourceType: { + $in: [ + ProgramExternalIdType.PLEX, + ProgramExternalIdType.JELLYFIN, + ], + }, }, }, }, @@ -178,21 +177,26 @@ export class StreamProgramCalculator { if (!isNil(backingItem)) { // Will play this item on the first found server... unsure if that is // what we want - const plexInfo = find( - backingItem.externalIds, - (eid) => eid.sourceType === ProgramExternalIdType.PLEX, + const externalInfo = backingItem.externalIds.find( + (eid) => + eid.sourceType === ProgramExternalIdType.PLEX || + eid.sourceType === ProgramExternalIdType.JELLYFIN, ); if ( - !isUndefined(plexInfo) && - isNonEmptyString(plexInfo.externalSourceId) + !isUndefined(externalInfo) && + isNonEmptyString(externalInfo.externalSourceId) ) { program = { type: 'program', - plexFilePath: plexInfo.externalFilePath, - externalKey: plexInfo.externalKey, - filePath: plexInfo.directFilePath, - externalSourceId: plexInfo.externalSourceId, + externalSource: + externalInfo.sourceType === ProgramExternalIdType.JELLYFIN + ? MediaSourceType.Jellyfin + : MediaSourceType.Plex, + plexFilePath: externalInfo.externalFilePath, + externalKey: externalInfo.externalKey, + filePath: externalInfo.directFilePath, + externalSourceId: externalInfo.externalSourceId, duration: backingItem.duration, programId: backingItem.uuid, title: backingItem.title, @@ -302,24 +306,38 @@ export class StreamProgramCalculator { } } - return { - // just add the video, starting at 0, playing the entire duration - type: 'commercial', - title: filler.title, - filePath: filler.filePath!, - externalKey: filler.externalKey, - start: fillerstart, - streamDuration: Math.max( - 1, - Math.min(filler.duration - fillerstart, remaining), - ), - duration: filler.duration, - programId: filler.uuid, - beginningOffset: beginningOffset, - externalSourceId: filler.externalSourceId, - plexFilePath: filler.plexFilePath!, - programType: filler.type as ProgramType, - }; + const externalInfos = await getEm().find(ProgramExternalId, { + program: { uuid: filler.uuid }, + sourceType: { + $in: [ProgramExternalIdType.PLEX, ProgramExternalIdType.JELLYFIN], + }, + }); + + if (!isEmpty(externalInfos)) { + const externalInfo = first(externalInfos)!; + return { + // just add the video, starting at 0, playing the entire duration + type: 'commercial', + title: filler.title, + filePath: externalInfo.directFilePath, + externalKey: externalInfo.externalKey, + externalSource: + externalInfo.sourceType === ProgramExternalIdType.JELLYFIN + ? MediaSourceType.Jellyfin + : MediaSourceType.Plex, + start: fillerstart, + streamDuration: Math.max( + 1, + Math.min(filler.duration - fillerstart, remaining), + ), + duration: filler.duration, + programId: filler.uuid, + beginningOffset: beginningOffset, + externalSourceId: externalInfo.externalSourceId!, + plexFilePath: externalInfo.externalFilePath, + programType: filler.type as ProgramType, + }; + } } // pick the offline screen remaining = Math.min(remaining, 10 * 60 * 1000); diff --git a/server/src/stream/VideoStream.ts b/server/src/stream/VideoStream.ts index 739eaa77..a7102d0f 100644 --- a/server/src/stream/VideoStream.ts +++ b/server/src/stream/VideoStream.ts @@ -15,7 +15,6 @@ import { fileExists } from '../util/fsUtil'; import { deepCopy } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory'; import { - ProgramAndTimeElapsed, StreamProgramCalculator, generateChannelContext, } from './StreamProgramCalculator'; @@ -107,80 +106,75 @@ export class VideoStream { } let lineupItem: Maybe; - let currentProgram: ProgramAndTimeElapsed | undefined; let channelContext: Loaded = channel; const redirectChannels: string[] = []; const upperBounds: number[] = []; - if (isUndefined(lineupItem)) { - const lineup = await serverCtx.channelDB.loadLineup(channel.uuid); - currentProgram = await this.calculator.getCurrentProgramAndTimeElapsed( - startTimestamp, - channel, - lineup, + let currentProgram = await this.calculator.getCurrentProgramAndTimeElapsed( + startTimestamp, + channel, + lineup, + ); + + while ( + !isUndefined(currentProgram) && + currentProgram.program.type === 'redirect' + ) { + redirectChannels.push(channelContext.uuid); + upperBounds.push( + currentProgram.program.duration - currentProgram.timeElapsed, ); - while ( - !isUndefined(currentProgram) && - currentProgram.program.type === 'redirect' - ) { - redirectChannels.push(channelContext.uuid); - upperBounds.push( - currentProgram.program.duration - currentProgram.timeElapsed, - ); - - if (redirectChannels.includes(currentProgram.program.channel)) { - await serverCtx.channelCache.recordPlayback( - channelContext.uuid, - startTimestamp, - { - type: 'error', - title: 'Error', - error: - 'Recursive channel redirect found: ' + - redirectChannels.join(', '), - duration: 60000, - start: 0, - }, - ); - } - - const nextChannelId = currentProgram.program.channel; - const newChannelAndLineup = - await serverCtx.channelDB.loadChannelAndLineup(nextChannelId); - - if (isNil(newChannelAndLineup)) { - const msg = "Invalid redirect to a channel that doesn't exist"; - this.logger.error(msg); - currentProgram = { - program: { - ...createOfflineStreamLineupIteam(60000), - type: 'error', - error: msg, - }, - timeElapsed: 0, - programIndex: -1, - }; - continue; - } - - channelContext = newChannelAndLineup.channel; - lineupItem = serverCtx.channelCache.getCurrentLineupItem( + if (redirectChannels.includes(currentProgram.program.channel)) { + await serverCtx.channelCache.recordPlayback( channelContext.uuid, startTimestamp, + { + type: 'error', + title: 'Error', + error: + 'Recursive channel redirect found: ' + + redirectChannels.join(', '), + duration: 60000, + start: 0, + }, ); + } - if (!isUndefined(lineupItem)) { - lineupItem = deepCopy(lineupItem); - break; - } else { - currentProgram = - await this.calculator.getCurrentProgramAndTimeElapsed( - startTimestamp, - channelContext, - newChannelAndLineup.lineup, - ); - } + const nextChannelId = currentProgram.program.channel; + const newChannelAndLineup = + await serverCtx.channelDB.loadChannelAndLineup(nextChannelId); + + if (isNil(newChannelAndLineup)) { + const msg = "Invalid redirect to a channel that doesn't exist"; + this.logger.error(msg); + currentProgram = { + program: { + ...createOfflineStreamLineupIteam(60000), + type: 'error', + error: msg, + }, + timeElapsed: 0, + programIndex: -1, + }; + continue; + } + + channelContext = newChannelAndLineup.channel; + lineupItem = serverCtx.channelCache.getCurrentLineupItem( + channelContext.uuid, + startTimestamp, + ); + + if (!isUndefined(lineupItem)) { + lineupItem = deepCopy(lineupItem); + break; + } else { + currentProgram = await this.calculator.getCurrentProgramAndTimeElapsed( + startTimestamp, + channelContext, + newChannelAndLineup.lineup, + ); } } @@ -246,7 +240,6 @@ export class VideoStream { for (let i = redirectChannels.length - 1; i >= 0; i--) { const thisUpperBound = nth(upperBounds, i); if (!isNil(thisUpperBound)) { - console.log('adjusting upper bound....'); const nextBound = thisUpperBound + beginningOffset; const prevBound = isNil(lineupItem.streamDuration) ? upperBound @@ -310,10 +303,10 @@ export class VideoStream { }; const playerContext: PlayerContext = { - lineupItem: lineupItem, - ffmpegSettings: ffmpegSettings, + lineupItem, + ffmpegSettings, channel: combinedChannel, - m3u8: m3u8, + m3u8, audioOnly: audioOnly, // A little hacky... entityManager: ( @@ -322,7 +315,7 @@ export class VideoStream { settings: serverCtx.settings, }; - const player: ProgramPlayer = new ProgramPlayer(playerContext); + const player = new ProgramPlayer(playerContext); let stopped = false; const stop = () => { @@ -343,11 +336,10 @@ export class VideoStream { }); ffmpegEmitter?.on('end', () => { - this.logger.trace('playObj.end'); stop(); }); } catch (err) { - this.logger.error('Error when attempting to play video: %O', err); + this.logger.error(err, 'Error when attempting to play video'); stop(); return { type: 'error', diff --git a/server/src/stream/jellyfin/JellyfinPlayer.ts b/server/src/stream/jellyfin/JellyfinPlayer.ts new file mode 100644 index 00000000..587cfabd --- /dev/null +++ b/server/src/stream/jellyfin/JellyfinPlayer.ts @@ -0,0 +1,191 @@ +import constants from '@tunarr/shared/constants'; +import EventEmitter from 'events'; +import { isNil, isNull, isUndefined } from 'lodash-es'; +import { Writable } from 'stream'; +import { isContentBackedLineupIteam } from '../../dao/derived_types/StreamLineup.js'; +import { + MediaSource, + MediaSourceType, +} from '../../dao/entities/MediaSource.js'; +import { ProgramDB } from '../../dao/programDB.js'; +import { FFMPEG, FfmpegEvents } from '../../ffmpeg/ffmpeg.js'; +import { TypedEventEmitter } from '../../types/eventEmitter.js'; +import { Nullable } from '../../types/util.js'; +import { isDefined } from '../../util/index.js'; +import { LoggerFactory } from '../../util/logging/LoggerFactory.js'; +import { Player, PlayerContext } from '../Player.js'; +import { JellyfinStreamDetails } from './JellyfinStreamDetails.js'; + +export class JellyfinPlayer extends Player { + private logger = LoggerFactory.child({ + caller: import.meta, + className: JellyfinPlayer.name, + }); + private ffmpeg: Nullable = null; + private killed: boolean = false; + + constructor(private context: PlayerContext) { + super(); + } + + cleanUp() { + super.cleanUp(); + this.killed = true; + // ifDefined(this.updatePlexStatusTask, (task) => { + // task.stop(); + // }); + + if (!isNull(this.ffmpeg)) { + this.ffmpeg.kill(); + this.ffmpeg = null; + } + } + + async play( + outStream: Writable, + ): Promise | undefined> { + const lineupItem = this.context.lineupItem; + if (!isContentBackedLineupIteam(lineupItem)) { + throw new Error( + 'Lineup item is not backed by Plex: ' + JSON.stringify(lineupItem), + ); + } + + const ffmpegSettings = this.context.ffmpegSettings; + const db = this.context.entityManager.repo(MediaSource); + const channel = this.context.channel; + const server = await db.findOne({ + type: MediaSourceType.Jellyfin, + name: lineupItem.externalSourceId, + }); + + if (isNil(server)) { + throw new Error( + `Unable to find server "${lineupItem.externalSourceId}" specified by program.`, + ); + } + + // const plexSettings = this.context.settings.plexSettings(); + const jellyfinStreamDetails = new JellyfinStreamDetails( + server, + this.context.settings, + new ProgramDB(), + ); + + const watermark = this.context.watermark; + this.ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + this.ffmpeg.setAudioOnly(this.context.audioOnly); + + let streamDuration: number | undefined; + + if ( + !isUndefined(lineupItem.streamDuration) && + (lineupItem.start ?? 0) + lineupItem.streamDuration + constants.SLACK < + lineupItem.duration + ) { + streamDuration = lineupItem.streamDuration / 1000; + } + + const stream = await jellyfinStreamDetails.getStream(lineupItem); + if (isNull(stream)) { + this.logger.error('Unable to retrieve stream details from Jellyfin'); + return; + } + + if (this.killed) { + this.logger.warn('Stream was killed already, returning'); + return; + } + + if (isDefined(stream.streamDetails)) { + stream.streamDetails.duration = lineupItem.streamDuration; + } + + const streamUrl = new URL(stream.streamUrl); + streamUrl.searchParams.append( + 'startTimeTicks', + Math.round((lineupItem.start ?? 0) * 1000).toString(), + ); + + const emitter = new EventEmitter() as TypedEventEmitter; + let ffmpegOutStream = this.ffmpeg.spawnStream( + streamUrl.toString(), + stream.streamDetails, + // Don't use FFMPEG's -ss parameter for Jellyfin since we need to request + // the seek against their API instead + 0, + streamDuration?.toString(), + watermark, + { + 'X-Emby-Token': server.accessToken, + }, + ); // Spawn the ffmpeg process + + if (isUndefined(ffmpegOutStream)) { + throw new Error('Unable to spawn ffmpeg'); + } + + ffmpegOutStream.pipe(outStream, { end: false }); + + // if (plexSettings.updatePlayStatus) { + // this.updatePlexStatusTask = new UpdatePlexPlayStatusScheduledTask( + // server, + // { + // channelNumber: channel.number, + // duration: lineupItem.duration, + // ratingKey: lineupItem.externalKey, + // startTime: lineupItem.start ?? 0, + // }, + // ); + + // GlobalScheduler.scheduleTask( + // this.updatePlexStatusTask.id, + // this.updatePlexStatusTask, + // ); + // } + + this.ffmpeg.on('end', () => { + emitter.emit('end'); + }); + + this.ffmpeg.on('close', () => { + emitter.emit('close'); + }); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + this.ffmpeg.on('error', (err) => { + this.logger.debug('Replacing failed stream with error stream'); + ffmpegOutStream!.unpipe(outStream); + this.ffmpeg?.removeAllListeners(); + // TODO: Extremely weird logic here leftover, should sort this all out. + this.ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + this.ffmpeg.setAudioOnly(this.context.audioOnly); + this.ffmpeg.on('close', () => { + emitter.emit('close'); + }); + this.ffmpeg.on('end', () => { + emitter.emit('end'); + }); + this.ffmpeg.on('error', (err) => { + emitter.emit('error', err); + }); + + try { + ffmpegOutStream = this.ffmpeg.spawnError( + 'oops', + 'oops', + Math.min(stream.streamDetails?.duration ?? 30000, 60000), + ); + if (isUndefined(ffmpegOutStream)) { + throw new Error('Unable to spawn ffmpeg...what is going on here'); + } + ffmpegOutStream.pipe(outStream); + } catch (err) { + this.logger.error(err, 'Err while trying to spawn error stream! YIKES'); + } + + emitter.emit('error', err); + }); + return emitter; + } +} diff --git a/server/src/stream/jellyfin/JellyfinStreamDetails.ts b/server/src/stream/jellyfin/JellyfinStreamDetails.ts new file mode 100644 index 00000000..08af3aca --- /dev/null +++ b/server/src/stream/jellyfin/JellyfinStreamDetails.ts @@ -0,0 +1,255 @@ +import { + find, + first, + isNull, + replace, + trimEnd, + isUndefined, + attempt, + isError, + isEmpty, + trimStart, +} from 'lodash-es'; +import { MediaSource } from '../../dao/entities/MediaSource.js'; +import { ProgramDB } from '../../dao/programDB.js'; +import { SettingsDB } from '../../dao/settings.js'; +import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.js'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js'; +import { + isDefined, + isNonEmptyString, + nullToUndefined, +} from '../../util/index.js'; +import { Logger, LoggerFactory } from '../../util/logging/LoggerFactory.js'; +import { makeLocalUrl } from '../../util/serverUtil.js'; +import { PlexStream } from '../types.js'; +import { StreamDetails } from '../types.js'; +import { ContentBackedStreamLineupItem } from '../../dao/derived_types/StreamLineup.js'; +import { Nullable } from '../../types/util.js'; +import { isQueryError } from '../../external/BaseApiClient.js'; +import { JellyfinItem } from '@tunarr/types/jellyfin'; +import { ProgramType } from '../../dao/entities/Program.js'; + +// The minimum fields we need to get stream details about an item +// TODO: See if we need separate types for JF and Plex and what is really necessary here +type JellyfinItemStreamDetailsQuery = Pick< + ContentBackedStreamLineupItem, + 'programType' | 'externalKey' | 'plexFilePath' | 'filePath' | 'programId' +>; + +export class JellyfinStreamDetails { + private logger: Logger; + private jellyfin: JellyfinApiClient; + + constructor( + private server: MediaSource, + private settings: SettingsDB, + private programDB: ProgramDB, + ) { + this.logger = LoggerFactory.child({ + jellyfinServer: server.name, + // channel: channel.uuid, + caller: import.meta, + }); + + this.jellyfin = MediaSourceApiFactory().getJellyfinClient({ + apiKey: server.accessToken, + type: 'jellyfin', + url: server.uri, + name: server.name, + }); + } + + async getStream(item: JellyfinItemStreamDetailsQuery) { + return this.getStreamInternal(item); + } + + private async getStreamInternal( + item: JellyfinItemStreamDetailsQuery, + depth: number = 0, + ): Promise> { + if (depth > 1) { + return null; + } + + const expectedItemType = item.programType; + const itemMetadataResult = await this.jellyfin.getItem(item.externalKey); + + if (isQueryError(itemMetadataResult)) { + this.logger.error(itemMetadataResult, 'Error getting Jellyfin stream'); + return null; + } else if (isUndefined(itemMetadataResult.data)) { + this.logger.error( + 'Jellyfin item with ID %s does not exist', + item.externalKey, + ); + return null; + } + + const itemMetadata = itemMetadataResult.data; + + if (expectedItemType !== jellyfinItemTypeToProgramType(itemMetadata)) { + this.logger.warn( + 'Got unexpected item type %s from Plex (ID = %s) when starting stream. Expected item type %s', + itemMetadata.Type, + item.externalKey, + expectedItemType, + ); + return null; + } + + const details = await this.getItemStreamDetails(item, itemMetadata); + + if (isNull(details)) { + return null; + } + + if ( + isNonEmptyString(details.serverPath) && + details.serverPath !== item.plexFilePath + ) { + this.programDB + .updateProgramPlexRatingKey(item.programId, this.server.name, { + externalKey: item.externalKey, + externalFilePath: details.serverPath, + directFilePath: details.directFilePath, + }) + .catch((err) => { + this.logger.error( + err, + 'Error while updating Plex file path for program %s', + item.programId, + ); + }); + } + + const streamSettings = this.settings.plexSettings(); + + let streamUrl: string; + const filePath = + details.directFilePath ?? first(itemMetadata?.MediaSources)?.Path; + if (streamSettings.streamPath === 'direct' && isNonEmptyString(filePath)) { + streamUrl = replace( + filePath, + streamSettings.pathReplace, + streamSettings.pathReplaceWith, + ); + } else { + const path = details.serverPath ?? item.plexFilePath; + if (isNonEmptyString(path)) { + streamUrl = `${trimEnd(this.server.uri, '/')}/Videos/${trimStart( + path, + '/', + )}/stream`; + } else { + throw new Error('Could not resolve stream URL'); + } + } + + return { + directPlay: true, + streamUrl, + streamDetails: details, + }; + } + + private async getItemStreamDetails( + item: JellyfinItemStreamDetailsQuery, + media: JellyfinItem, + ): Promise> { + const streamDetails: StreamDetails = {}; + const firstMediaSource = first(media.MediaSources); + streamDetails.serverPath = nullToUndefined(firstMediaSource?.Id); + streamDetails.directFilePath = nullToUndefined(firstMediaSource?.Path); + + // const firstStream = firstPart?.Stream; + // if (isUndefined(firstStream)) { + // this.logger.error( + // 'Could not extract a stream for Jellyfin item ID = %s', + // item.externalKey, + // ); + // } + + const videoStream = find( + firstMediaSource?.MediaStreams, + (stream) => stream.Type === 'Video', + ); + + const audioStream = find( + firstMediaSource?.MediaStreams, + (stream) => stream.Type === 'Audio' && !!stream.IsDefault, + ); + const audioOnly = isUndefined(videoStream) && !isUndefined(audioStream); + + // Video + if (isDefined(videoStream)) { + // TODO Parse pixel aspect ratio + streamDetails.anamorphic = !!videoStream.IsAnamorphic; + streamDetails.videoCodec = nullToUndefined(videoStream.Codec); + // Keeping old behavior here for now + streamDetails.videoFramerate = videoStream.AverageFrameRate + ? Math.round(videoStream.AverageFrameRate) + : undefined; + streamDetails.videoHeight = nullToUndefined(videoStream.Height); + streamDetails.videoWidth = nullToUndefined(videoStream.Width); + streamDetails.videoBitDepth = nullToUndefined(videoStream.BitDepth); + streamDetails.pixelP = 1; + streamDetails.pixelQ = 1; + } + + if (isDefined(audioStream)) { + streamDetails.audioChannels = nullToUndefined(audioStream.Channels); + streamDetails.audioCodec = nullToUndefined(audioStream.Codec); + streamDetails.audioIndex = + nullToUndefined(audioStream.Index?.toString()) ?? 'a'; + } + + if (isUndefined(videoStream) && isUndefined(audioStream)) { + this.logger.warn( + 'Could not find a video nor audio stream for Plex item %s', + item.externalKey, + ); + return null; + } + + if (audioOnly) { + // TODO Use our proxy endpoint here + const placeholderThumbPath = + media.Type === 'Audio' + ? media.AlbumId ?? first(media.ArtistItems)?.Id ?? media.Id + : media.SeasonId ?? media.Id; + + // We have to check that we can hit this URL or the stream will not work + if (isNonEmptyString(placeholderThumbPath)) { + const path = `/Items/${placeholderThumbPath}/Images/Primary`; + const result = await attempt(() => this.jellyfin.doHead({ url: path })); + if (!isError(result)) { + streamDetails.placeholderImage = this.jellyfin.getFullUrl(path); + } + } + + if (isEmpty(streamDetails.placeholderImage)) { + streamDetails.placeholderImage = makeLocalUrl( + '/images/generic-music-screen.png', + ); + } + } + + streamDetails.audioOnly = audioOnly; + + return streamDetails; + } +} + +function jellyfinItemTypeToProgramType(item: JellyfinItem) { + switch (item.Type) { + case 'Movie': + return ProgramType.Movie; + case 'Episode': + return ProgramType.Episode; + case 'Audio': + return ProgramType.Track; + default: + return null; + } +} diff --git a/server/src/stream/plex/PlexPlayer.ts b/server/src/stream/plex/PlexPlayer.ts index 8593a69d..2924f690 100644 --- a/server/src/stream/plex/PlexPlayer.ts +++ b/server/src/stream/plex/PlexPlayer.ts @@ -3,10 +3,13 @@ import EventEmitter from 'events'; import { isNil, isNull, isUndefined } from 'lodash-es'; import { Writable } from 'stream'; import { isContentBackedLineupIteam } from '../../dao/derived_types/StreamLineup.js'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; +import { + MediaSource, + MediaSourceType, +} from '../../dao/entities/MediaSource.js'; import { FFMPEG, FfmpegEvents } from '../../ffmpeg/ffmpeg.js'; import { GlobalScheduler } from '../../services/scheduler.js'; -import { UpdatePlexPlayStatusScheduledTask } from '../../tasks/UpdatePlexPlayStatusTask.js'; +import { UpdatePlexPlayStatusScheduledTask } from '../../tasks/plex/UpdatePlexPlayStatusTask.js'; import { TypedEventEmitter } from '../../types/eventEmitter.js'; import { Maybe, Nullable } from '../../types/util.js'; import { ifDefined } from '../../util/index.js'; @@ -61,9 +64,12 @@ export class PlexPlayer extends Player { } const ffmpegSettings = this.context.ffmpegSettings; - const db = this.context.entityManager.repo(PlexServerSettings); + const db = this.context.entityManager.repo(MediaSource); const channel = this.context.channel; - const server = await db.findOne({ name: lineupItem.externalSourceId }); + const server = await db.findOne({ + name: lineupItem.externalSourceId, + type: MediaSourceType.Plex, + }); if (isNil(server)) { throw Error( `Unable to find server "${lineupItem.externalSourceId}" specified by program.`, diff --git a/server/src/stream/plex/PlexStreamDetails.ts b/server/src/stream/plex/PlexStreamDetails.ts index d6e18dca..428b4ee2 100644 --- a/server/src/stream/plex/PlexStreamDetails.ts +++ b/server/src/stream/plex/PlexStreamDetails.ts @@ -17,22 +17,20 @@ import { replace, trimEnd, } from 'lodash-es'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings'; -import { - Plex, - isPlexQueryError, - isPlexQuerySuccess, -} from '../../external/plex'; -import { PlexApiFactory } from '../../external/PlexApiFactory'; +import { MediaSource } from '../../dao/entities/MediaSource'; +import { PlexApiClient } from '../../external/plex/PlexApiClient'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory'; import { Nullable } from '../../types/util'; import { Logger, LoggerFactory } from '../../util/logging/LoggerFactory'; -import { PlexStream, StreamDetails } from './PlexTranscoder'; +import { PlexStream } from '../types'; +import { StreamDetails } from '../types'; import { attempt, isNonEmptyString } from '../../util'; import { ContentBackedStreamLineupItem } from '../../dao/derived_types/StreamLineup.js'; import { SettingsDB } from '../../dao/settings.js'; import { makeLocalUrl } from '../../util/serverUtil.js'; import { ProgramDB } from '../../dao/programDB'; import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType'; +import { isQueryError, isQuerySuccess } from '../../external/BaseApiClient.js'; // The minimum fields we need to get stream details about an item type PlexItemStreamDetailsQuery = Pick< @@ -48,10 +46,10 @@ type PlexItemStreamDetailsQuery = Pick< */ export class PlexStreamDetails { private logger: Logger; - private plex: Plex; + private plex: PlexApiClient; constructor( - private server: PlexServerSettings, + private server: MediaSource, private settings: SettingsDB, private programDB: ProgramDB, ) { @@ -61,7 +59,7 @@ export class PlexStreamDetails { caller: import.meta, }); - this.plex = PlexApiFactory().get(this.server); + this.plex = MediaSourceApiFactory().get(this.server); } async getStream(item: PlexItemStreamDetailsQuery) { @@ -81,7 +79,7 @@ export class PlexStreamDetails { item.externalKey, ); - if (isPlexQueryError(itemMetadataResult)) { + if (isQueryError(itemMetadataResult)) { if (itemMetadataResult.code === 'not_found') { this.logger.debug( 'Could not find item %s in Plex. Rating key may have changed. Attempting to update.', @@ -105,7 +103,7 @@ export class PlexStreamDetails { }, ); - if (isPlexQuerySuccess(byGuidResult)) { + if (isQuerySuccess(byGuidResult)) { if (byGuidResult.data.MediaContainer.size > 0) { this.logger.debug( 'Found %d matching items in library. Using the first', @@ -286,7 +284,7 @@ export class PlexStreamDetails { // We have to check that we can hit this URL or the stream will not work if (isNonEmptyString(placeholderThumbPath)) { const result = await attempt(() => - this.plex.doHead(placeholderThumbPath), + this.plex.doHead({ url: placeholderThumbPath }), ); if (!isError(result)) { streamDetails.placeholderImage = diff --git a/server/src/stream/plex/PlexTranscoder.ts b/server/src/stream/plex/PlexTranscoder.ts deleted file mode 100644 index 37246636..00000000 --- a/server/src/stream/plex/PlexTranscoder.ts +++ /dev/null @@ -1,775 +0,0 @@ -import { PlexStreamSettings } from '@tunarr/types'; -import { first, isNil, isUndefined, pick } from 'lodash-es'; -import { constants as fsConstants } from 'node:fs'; -import * as fs from 'node:fs/promises'; -import { stringify } from 'node:querystring'; -import { DeepReadonly } from 'ts-essentials'; -import { v4 as uuidv4 } from 'uuid'; -import { ContentBackedStreamLineupItem } from '../../dao/derived_types/StreamLineup.js'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; -import { serverOptions } from '../../globals.js'; -import { Plex } from '../../external/plex.js'; -import { PlexApiFactory } from '../../external/PlexApiFactory.js'; -import { StreamContextChannel } from '../types.js'; -import { Maybe } from '../../types/util.js'; -import { - PlexItemMetadata, - PlexMediaContainer, - PlexMediaVideoStream, - TranscodeDecision, - TranscodeDecisionMediaStream, - isPlexVideoStream, -} from '../../types/plexApiTypes.js'; -import { Logger, LoggerFactory } from '../../util/logging/LoggerFactory.js'; - -export type PlexStream = { - directPlay: boolean; - streamUrl: string; - separateVideoStream?: string; - streamDetails?: StreamDetails; -}; - -export type StreamDetails = { - duration?: number; - anamorphic?: boolean; - pixelP?: number; - pixelQ?: number; - - videoCodec?: string; - videoWidth?: number; - videoHeight?: number; - videoFramerate?: number; - videoDecision?: string; - videoScanType?: string; - videoBitDepth?: number; - - audioDecision?: string; - audioOnly?: boolean; - audioChannels?: number; - audioCodec?: string; - audioIndex?: string; - - placeholderImage?: string; - - serverPath?: string; - directFilePath?: string; -}; - -export class PlexTranscoder { - private logger: Logger; - private session: string; - private device: string; - private deviceName: string; - private clientIdentifier: string; - private product: string; - private settings: DeepReadonly; - private plexFile: string; - private file: string; - private transcodeUrlBase: string; - private ratingKey: string; - private currTimeMs: number; - public currTimeS: number; - private duration: number; - private server: PlexServerSettings; - private transcodingArgs: string | undefined; - private decisionJson: Maybe>; - private updateInterval: number; - private updatingPlex: NodeJS.Timeout | undefined; - private playState: string; - private mediaHasNoVideo: boolean; - private albumArt: { path?: string; attempted: boolean }; - private plex: Plex; - private directInfo?: PlexItemMetadata; - private videoIsDirect: boolean = false; - private cachedItemMetadata: Maybe; - - constructor( - clientId: string, - server: PlexServerSettings, - settings: DeepReadonly, - channel: StreamContextChannel, - lineupItem: ContentBackedStreamLineupItem, - ) { - this.logger = LoggerFactory.child({ - plexServer: server.name, - channel: channel.uuid, - caller: import.meta, - }); - this.session = uuidv4(); - - this.device = 'channel-' + channel.number; - this.deviceName = this.device; - this.clientIdentifier = clientId; - this.product = 'Tunarr'; - - this.settings = settings; - - if (settings.enableDebugLogging) { - this.log('Plex transcoder initiated'); - this.log('Debug logging enabled'); - } - - this.plex = PlexApiFactory().get(server); - // this.metadataPath = `${lineupItem.key}?X-Plex-Token=${server.accessToken}`; - this.plexFile = `${server.uri}${lineupItem.plexFilePath}?X-Plex-Token=${server.accessToken}`; - if (!isUndefined(lineupItem.filePath)) { - this.file = lineupItem.filePath.replace( - settings.pathReplace, - settings.pathReplaceWith, - ); - } - this.transcodeUrlBase = `${server.uri}/video/:/transcode/universal/start.m3u8?`; - this.ratingKey = lineupItem.externalKey; - this.currTimeMs = lineupItem.start ?? 0; - this.currTimeS = this.currTimeMs / 1000; - this.duration = lineupItem.duration; - this.server = server; - - this.transcodingArgs = undefined; - this.decisionJson = undefined; - - this.updateInterval = 30000; - this.updatingPlex = undefined; - this.playState = 'stopped'; - this.mediaHasNoVideo = false; - this.albumArt = { - attempted: false, - }; - } - - async getStream(deinterlace: boolean): Promise { - // let stream: PlexStream = { directPlay: false }; - let directPlay: boolean = false; - let streamUrl: string; - let separateVideoStream: Maybe; - - this.log('Getting stream'); - this.log(` deinterlace: ${deinterlace}`); - this.log(` streamPath: ${this.settings.streamPath}`); - - this.setTranscodingArgs(directPlay, true, false, false); - await this.tryToDetectAudioOnly(); - - if ( - this.settings.streamPath === 'direct' || - this.settings.forceDirectPlay - ) { - if (this.settings.enableSubtitles) { - this.log('Direct play is forced, so subtitles are forcibly disabled.'); - this.settings = { ...this.settings, enableSubtitles: false }; - } - directPlay = true; - } else { - try { - this.log('Setting transcoding parameters'); - this.setTranscodingArgs( - directPlay, - true, - deinterlace, - this.mediaHasNoVideo, - ); - await this.getDecision(directPlay); - if (this.isDirectPlay()) { - directPlay = true; - streamUrl = this.plexFile; - } - } catch (err) { - console.error( - "Error when getting decision. 1. Check Plex connection. 2. This might also be a sign that plex direct play and transcode settings are too strict and it can't find any allowed action for the selected video.", - err, - ); - directPlay = true; - } - } - if (directPlay /* || this.isAV1()*/) { - // if (!directPlay) { - // this.log( - // "Plex doesn't support av1, so we are forcing direct play, including for audio because otherwise plex breaks the stream.", - // ); - // } - this.log('Direct play forced or native paths enabled'); - directPlay = true; - this.setTranscodingArgs(directPlay, true, false, this.mediaHasNoVideo); - // Update transcode decision for session - await this.getDecision(directPlay); - streamUrl = - this.settings.streamPath === 'direct' ? this.file : this.plexFile; - if (this.settings.streamPath === 'direct') { - await fs.access(this.file, fsConstants.F_OK); - } - if (isUndefined(streamUrl)) { - throw Error( - 'Direct path playback is not possible for this program because it was registered at a time when the direct path settings were not set. To fix this, you must either revert the direct path setting or rebuild this channel.', - ); - } - } else if (!this.isVideoDirectStream()) { - this.log('Decision: Should transcode'); - // Change transcoding arguments to be the user chosen transcode parameters - this.setTranscodingArgs( - directPlay, - false, - deinterlace, - this.mediaHasNoVideo, - ); - // Update transcode decision for session - await this.getDecision(directPlay); - streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}`; - } else { - //This case sounds complex. Apparently plex is sending us just the audio, so we would need to get the video in a separate stream. - this.log('Decision: Direct stream. Audio is being transcoded'); - separateVideoStream = - this.settings.streamPath === 'direct' ? this.file : this.plexFile; - streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}`; - this.directInfo = await this.getDirectInfo(); - this.videoIsDirect = true; - } - - const streamStats = this.getVideoStats(); - - // use correct audio stream if direct play - streamStats.audioIndex = directPlay ? await this.getAudioIndex() : 'a'; - - const stream: PlexStream = { - directPlay, - streamUrl, - separateVideoStream, - streamDetails: streamStats, - }; - - this.log('PlexStream: %O', stream); - - return stream; - } - - setTranscodingArgs( - directPlay: boolean, - directStream: boolean, - deinterlace: boolean, - audioOnly: boolean, - ) { - const resolution = directStream - ? this.settings.maxPlayableResolution - : this.settings.maxTranscodeResolution; - const bitrate = directStream - ? this.settings.directStreamBitrate - : this.settings.transcodeBitrate; - const mediaBufferSize = directStream - ? this.settings.mediaBufferSize - : this.settings.transcodeMediaBufferSize; - const subtitles = this.settings.enableSubtitles ? 'burn' : 'none'; // subtitle options: burn, none, embedded, sidecar - const streamContainer = 'mpegts'; // Other option is mkv, mkv has the option of copying it's subs for later processing - const isDirectPlay = directPlay ? '1' : '0'; - const hasMDE = '1'; - - const videoQuality = `100`; // Not sure how this applies, maybe this works if maxVideoBitrate is not set - const profileName = `Generic`; // Blank profile, everything is specified through X-Plex-Client-Profile-Extra - - const vc = [...this.settings.videoCodecs]; - //This codec is not currently supported by plex so requesting it to transcode will always - // cause an error. If Plex ever supports av1, remove this. I guess. - // UPDATE: Plex 1.30.1 added AV1 playback support - experimentally removing this clause here. - // if (vc.length > 0) { - // vc.push('av1'); - // } else { - // vc = ['av1']; - // } - - // let clientProfile = ''; - const clientProfileParts: string[] = []; - if (!audioOnly) { - clientProfileParts.push( - transcodeTarget({ - type: 'videoProfile', - protocol: this.settings.streamProtocol, - container: streamContainer, - videoCodecs: vc, - audioCodecs: this.settings.audioCodecs, - subtitleCodecs: [], - }), - transcodeTargetSettings({ - type: 'videoProfile', - protocol: this.settings.streamProtocol, - settings: { - CopyMatroskaAttachments: true, - }, - }), - transcodeTargetSettings({ - type: 'videoProfile', - protocol: this.settings.streamProtocol, - settings: { BreakNonKeyframes: true }, - }), - transcodeLimitation({ - scope: 'videoCodec', - scopeName: '*', - type: 'upperBound', - name: 'video.width', - value: resolution.widthPx, - }), - transcodeLimitation({ - scope: 'videoCodec', - scopeName: '*', - type: 'upperBound', - name: 'video.height', - value: resolution.heightPx, - }), - ); - } else { - clientProfileParts.push( - transcodeTarget({ - type: 'musicProfile', - protocol: this.settings.streamProtocol, - container: streamContainer, - audioCodecs: this.settings.audioCodecs, - }), - ); - } - - // Set transcode settings per audio codec - this.settings.audioCodecs.forEach((codec) => { - clientProfileParts.push( - transcodeAudioTarget({ - type: 'videoProfile', - protocol: this.settings.streamProtocol, - audioCodec: codec, - }), - ); - if (codec == 'mp3') { - clientProfileParts.push( - transcodeLimitation({ - scope: 'videoAudioCodec', - scopeName: codec, - type: 'upperBound', - name: 'audio.channels', - value: 2, - }), - ); - } else { - clientProfileParts.push( - transcodeLimitation({ - scope: 'videoAudioCodec', - scopeName: codec, - type: 'upperBound', - name: 'audio.channels', - value: this.settings.maxAudioChannels, - }), - ); - } - }); - - // deinterlace video if specified, only useful if overlaying channel logo later - if (deinterlace == true) { - clientProfileParts.push( - transcodeLimitation({ - scope: 'videoCodec', - scopeName: '*', - type: 'notMatch', - name: 'video.scanType', - value: 'interlaced', - }), - ); - } - - const clientProfile_enc = encodeURIComponent(clientProfileParts.join('+')); - this.transcodingArgs = `X-Plex-Platform=${profileName}&\ -X-Plex-Product=${this.product}&\ -X-Plex-Client-Platform=${profileName}&\ -X-Plex-Client-Profile-Name=${profileName}&\ -X-Plex-Device-Name=${this.deviceName}&\ -X-Plex-Device=${this.device}&\ -X-Plex-Client-Identifier=${this.clientIdentifier}&\ -X-Plex-Platform=${profileName}&\ -X-Plex-Token=${this.server.accessToken}&\ -X-Plex-Client-Profile-Extra=${clientProfile_enc}&\ -protocol=${this.settings.streamProtocol}&\ -Connection=keep-alive&\ -hasMDE=${hasMDE}&\ -path=/library/metadata/${this.ratingKey}&\ -mediaIndex=0&\ -partIndex=0&\ -fastSeek=1&\ -directPlay=${isDirectPlay}&\ -directStream=1&\ -directStreamAudio=1&\ -copyts=1&\ -audioBoost=${this.settings.audioBoost}&\ -mediaBufferSize=${mediaBufferSize}&\ -session=${this.session}&\ -offset=${this.currTimeS}&\ -subtitles=${subtitles}&\ -subtitleSize=${this.settings.subtitleSize}&\ -maxVideoBitrate=${bitrate}&\ -videoQuality=${videoQuality}&\ -videoResolution=${resolution.widthPx}x${resolution.heightPx}&\ -lang=en`; - } - - isVideoDirectStream() { - try { - return this.getVideoStats().videoDecision === 'copy'; - } catch (e) { - console.error('Error at decision:', e); - return false; - } - } - - isAV1() { - try { - return this.getVideoStats().videoCodec === 'av1'; - } catch (e) { - return false; - } - } - - isDirectPlay() { - try { - const videoStats = this.getVideoStats(); - if (videoStats.audioOnly) { - return videoStats.audioDecision === 'copy'; - } - return ( - videoStats.videoDecision === 'copy' && - videoStats.audioDecision === 'copy' - ); - } catch (e) { - console.error('Error at decision:', e); - return false; - } - } - - // TODO - cache this somehow so we only update VideoStats if decisionJson or directInfo change - getVideoStats(): StreamDetails { - const ret: Partial = {}; - - try { - const streams: TranscodeDecisionMediaStream[] = - this.decisionJson?.Metadata[0].Media[0].Part[0].Stream ?? []; - ret.duration = this.decisionJson?.Metadata[0].Media[0].Part[0].duration; - streams.forEach((_stream, idx) => { - // Video - if (_stream.streamType === 1) { - let stream: TranscodeDecisionMediaStream | PlexMediaVideoStream = - _stream; - if (this.videoIsDirect && !isNil(this.directInfo)) { - const directStream = - this.directInfo.Metadata[0].Media[0].Part[0].Stream[idx]; - if (isPlexVideoStream(directStream)) { - stream = directStream; - } - } - ret.anamorphic = - stream.anamorphic === '1' || stream.anamorphic === true; - if (ret.anamorphic) { - const parsed = parsePixelAspectRatio(stream.pixelAspectRatio); - if (isUndefined(parsed)) { - throw Error( - 'Unable to parse pixelAspectRatio: ' + stream.pixelAspectRatio, - ); - } - ret.pixelP = parsed.p; - ret.pixelQ = parsed.q; - } else { - ret.pixelP = 1; - ret.pixelQ = 1; - } - ret.videoCodec = stream.codec; - ret.videoWidth = stream.width; - ret.videoHeight = stream.height; - ret.videoFramerate = Math.round(stream.frameRate); - // Rounding framerate avoids scenarios where - // 29.9999999 & 30 don't match. - ret.videoDecision = isUndefined(stream['decision'] as Maybe) - ? 'copy' - : (stream['decision'] as string); - ret.videoScanType = stream.scanType; - } - - // Audio. Only look at stream being used - if (_stream.streamType === 2 && _stream.selected) { - ret.audioChannels = _stream.channels; - ret.audioCodec = _stream.codec; - ret.audioDecision = isUndefined(_stream.decision) - ? 'copy' - : _stream.decision; - } - }); - } catch (e) { - console.error('Error at decision:', e); - } - - if (isUndefined(ret.videoCodec)) { - ret.audioOnly = true; - ret.placeholderImage = !isNil(this.albumArt?.path) - ? (ret.placeholderImage = this.albumArt.path) - : (ret.placeholderImage = `http://localhost:${ - serverOptions().port - }/images/generic-music-screen.png`); - } - - this.log('Current video stats: %O', ret); - - return ret as Required; // This isn't technically right, but this is how the current code treats this - } - - private async getAudioIndex() { - let index: string | number = 'a'; - // Duplicate call to API here ... we should try to keep a cache. - const response = await this.getPlexItemMetadata(); - this.log('Got Plex item metadata response: %O', response); - - if (isUndefined(response)) { - return index; - } - - try { - const streams = response.Metadata[0].Media[0].Part[0].Stream; - - streams.forEach(function (stream) { - // Audio. Only look at stream being used - if (stream.streamType === 2 && stream.selected) { - index = stream.index; - } - }); - } catch (e) { - console.error('Error at get media info:' + e); - } - - this.log(`Found audio index: ${index}`); - - return index; - } - - async getDirectInfo() { - return this.getPlexItemMetadata(); - } - async tryToDetectAudioOnly() { - try { - this.log('Try to detect audio only:'); - const mediaContainer = await this.getPlexItemMetadata(); - - const metadata = first(mediaContainer?.Metadata); - if (!isUndefined(metadata)) { - this.albumArt = this.albumArt || {}; - this.albumArt.path = `${this.server.uri}${metadata.thumb}?X-Plex-Token=${this.server.accessToken}`; - - const media = first(metadata.Media); - if (!isUndefined(media)) { - if (isUndefined(media.videoCodec)) { - this.log('Audio-only file detected'); - this.mediaHasNoVideo = true; - } - } - } - } catch (err) { - console.error('Error when getting album art', err); - } - } - - async getDecisionUnmanaged(directPlay: boolean) { - this.decisionJson = await this.plex.doGet( - `/video/:/transcode/universal/decision?${this.transcodingArgs}`, - ); - - if (isUndefined(this.decisionJson)) { - throw new Error('Got unexpected undefined response from Plex'); - } - - this.log('Received transcode decision: %O', this.decisionJson); - - // Print error message if transcode not possible - // TODO: handle failure better - // mdeDecisionCode doesn't seem to exist on later Plex versions... - // if (response.mdeDecisionCode === 1000) { - // this.log("mde decision code 1000, so it's all right?"); - // return; - // } - - const transcodeDecisionCode = this.decisionJson.transcodeDecisionCode; - if (isUndefined(transcodeDecisionCode)) { - this.log('Strange case, attempt direct play'); - } else if (!(directPlay || transcodeDecisionCode == 1001)) { - this.log( - `IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`, - ); - this.log(`Error message: '${this.decisionJson.transcodeDecisionText}'`); - } - } - - async getDecision(directPlay: boolean) { - try { - await this.getDecisionUnmanaged(directPlay); - } catch (err) { - console.error(err); - } - } - - private getStatusUrl(): { - path: string; - params: Record; - } { - const profileName = `Generic`; - - const containerKey = `/video/:/transcode/universal/decision?${this.transcodingArgs}`; - const containerKey_enc = encodeURIComponent(containerKey); - - return { - path: '/:/timeline', - params: { - containerKey: containerKey_enc, - ratingKey: this.ratingKey, - state: this.playState, - key: `/library/metadata/${this.ratingKey}`, - time: this.currTimeMs, - duration: this.duration, - 'X-Plex-Product': this.product, - 'X-Plex-Platform': profileName, - 'X-Plex-Client-Platform': profileName, - 'X-Plex-Client-Profile-Name': profileName, - 'X-Plex-Device-Name': this.deviceName, - 'X-Plex-Device': this.device, - 'X-Plex-Client-Identifier': this.clientIdentifier, - 'X-Plex-Token': this.server.accessToken, - }, - }; - } - - private async getPlexItemMetadata(force: boolean = false) { - if (!force && !isUndefined(this.cachedItemMetadata)) { - this.log('Using cached response from Plex for metadata'); - return this.cachedItemMetadata; - } - - this.cachedItemMetadata = await this.plex.doGet( - `/library/metadata/${this.ratingKey}`, - ); - return this.cachedItemMetadata; - } - - async startUpdatingPlex() { - if (this.settings.updatePlayStatus == true) { - this.playState = 'playing'; - await this.updatePlex(); // do initial update - this.updatingPlex = setInterval( - // eslint-disable-next-line @typescript-eslint/no-misused-promises - async () => await this.updatePlex(), - this.updateInterval, - ); - } - } - - async stopUpdatingPlex() { - if (this.settings.updatePlayStatus == true) { - clearInterval(this.updatingPlex); - this.playState = 'stopped'; - await this.updatePlex(); - } - } - - private async updatePlex() { - this.log('Updating plex status'); - const { path: statusUrl, params } = this.getStatusUrl(); - try { - await this.plex.doPost(statusUrl, params); - } catch (error) { - this.logger.warn( - `Problem updating Plex status using status URL ${statusUrl}: `, - error, - ); - return false; - } - this.currTimeMs += this.updateInterval; - if (this.currTimeMs > this.duration) { - this.currTimeMs = this.duration; - } - this.currTimeS = this.duration / 1000; - return true; - } - - //(obj: unknown, msg?: string, ...args: any[]): void; - //(msg: string, ...args: any[]): void; - // private log(obj: unknown, msg?: string, ...args: unknown[]): void; - private log(obj: object, msg?: string, ...args: unknown[]): void; - private log(msg: string, ...args: unknown[]); - private log( - t0: string | object, - msg: string | undefined, - ...rest: unknown[] - ): void { - if (this.settings.enableDebugLogging) { - return this.logger.debug(t0, msg, ...rest); - } else { - return this.logger.trace(t0, msg, ...rest); - } - } -} - -function parsePixelAspectRatio(s: Maybe) { - if (isUndefined(s)) return; - const x = s.split(':'); - return { - p: parseInt(x[0], 10), - q: parseInt(x[1], 10), - }; -} - -function transcodeTarget(opts: { - type: 'videoProfile' | 'musicProfile'; - protocol: string; - container: string; - videoCodecs?: ReadonlyArray; - audioCodecs?: ReadonlyArray; - subtitleCodecs?: ReadonlyArray; -}) { - const parts = { - ...pick(opts, ['type', 'protocol', 'container']), - subtitleCodec: (opts.subtitleCodecs ?? []).join(','), - context: 'streaming', - replace: true, - }; - - if (opts.videoCodecs) { - parts['videoCodec'] = opts.videoCodecs.join(','); - } - - if (opts.audioCodecs) { - parts['audioCodec'] = opts.audioCodecs.join(','); - } - - return `add-transcode-target(${stringify(parts)})`; -} - -function transcodeTargetSettings(opts: { - type: string; - protocol: string; - settings: Record; -}) { - const parts = { - ...pick(opts, ['type', 'protocol']), - ...opts.settings, - context: 'streaming', - }; - - return `add-transcode-target-settings(${stringify(parts)})`; -} - -function transcodeLimitation(opts: { - scope: string; - scopeName: string; - type: string; - name: string; - value: string | number; -}) { - return `add-limitation(${stringify(opts)})`; -} - -function transcodeAudioTarget(opts: { - type: 'videoProfile'; - protocol: string; - audioCodec: string; -}) { - const parts = { - ...opts, - context: 'streaming', - }; - - return `add-transcode-target-audio-codec(${stringify(parts)})`; -} diff --git a/server/src/stream/types.ts b/server/src/stream/types.ts index f4c702c0..cc677f56 100644 --- a/server/src/stream/types.ts +++ b/server/src/stream/types.ts @@ -17,3 +17,35 @@ export type StreamContextChannel = Pick< Channel & { offlinePicture?: string; offlineSoundtrack?: string }, TupleToUnion >; +export type StreamDetails = { + duration?: number; + anamorphic?: boolean; + pixelP?: number; + pixelQ?: number; + + videoCodec?: string; + videoWidth?: number; + videoHeight?: number; + videoFramerate?: number; + videoDecision?: string; + videoScanType?: string; + videoBitDepth?: number; + + audioDecision?: string; + audioOnly?: boolean; + audioChannels?: number; + audioCodec?: string; + audioIndex?: string; + + placeholderImage?: string; + + serverPath?: string; + directFilePath?: string; +}; + +export type PlexStream = { + directPlay: boolean; + streamUrl: string; + separateVideoStream?: string; + streamDetails?: StreamDetails; +}; diff --git a/server/src/tasks/ReconcileProgramDurationsTask.ts b/server/src/tasks/ReconcileProgramDurationsTask.ts index bf4221ee..c31234dc 100644 --- a/server/src/tasks/ReconcileProgramDurationsTask.ts +++ b/server/src/tasks/ReconcileProgramDurationsTask.ts @@ -81,7 +81,7 @@ export class ReconcileProgramDurationsTask extends Task { !isUndefined(dbItemDuration) && dbItemDuration !== item.durationMs ) { - console.debug('Found duration mismatch: %s', item.id); + this.logger.debug('Found duration mismatch: %s', item.id); changed = true; return { ...item, diff --git a/server/src/tasks/TaskQueue.ts b/server/src/tasks/TaskQueue.ts index 7c06f34f..c73f5749 100644 --- a/server/src/tasks/TaskQueue.ts +++ b/server/src/tasks/TaskQueue.ts @@ -53,3 +53,9 @@ export const PlexTaskQueue = new TaskQueue('PlexTaskQueue', { intervalCap: 5, interval: 2000, }); + +export const JellyfinTaskQueue = new TaskQueue('JellyfinTaskQueue', { + concurrency: 2, + intervalCap: 5, + interval: 2000, +}); diff --git a/server/src/tasks/UpdateXmlTvTask.ts b/server/src/tasks/UpdateXmlTvTask.ts index ac504fbd..531cb59b 100644 --- a/server/src/tasks/UpdateXmlTvTask.ts +++ b/server/src/tasks/UpdateXmlTvTask.ts @@ -3,9 +3,9 @@ import { PlexDvr } from '@tunarr/types/plex'; import dayjs from 'dayjs'; import { ChannelDB } from '../dao/channelDb.js'; import { withDb } from '../dao/dataSource.js'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; +import { MediaSource } from '../dao/entities/MediaSource.js'; import { SettingsDB, defaultXmlTvSettings } from '../dao/settings.js'; -import { Plex } from '../external/plex.js'; +import { PlexApiClient } from '../external/plex/PlexApiClient.js'; import { globalOptions } from '../globals.js'; import { ServerContext } from '../serverContext.js'; import { TVGuideService } from '../services/tvGuideService.js'; @@ -88,11 +88,11 @@ export class UpdateXmlTvTask extends Task { const channels = await this.#channelDB.getAllChannels(); const allPlexServers = await withDb((em) => { - return em.find(PlexServerSettings, {}); + return em.find(MediaSource, {}); }); await mapAsyncSeq(allPlexServers, async (plexServer) => { - const plex = new Plex(plexServer); + const plex = new PlexApiClient(plexServer); let dvrs: PlexDvr[] = []; if (!plexServer.sendGuideUpdates && !plexServer.sendChannelUpdates) { diff --git a/server/src/tasks/fixers/BackfillProgramExternalIds.ts b/server/src/tasks/fixers/BackfillProgramExternalIds.ts index 069b291c..a4a27c58 100644 --- a/server/src/tasks/fixers/BackfillProgramExternalIds.ts +++ b/server/src/tasks/fixers/BackfillProgramExternalIds.ts @@ -10,11 +10,11 @@ import { import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType'; import { ProgramSourceType } from '../../dao/custom_types/ProgramSourceType.js'; import { getEm } from '../../dao/dataSource'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; +import { MediaSource } from '../../dao/entities/MediaSource.js'; import { Program } from '../../dao/entities/Program'; import { ProgramExternalId } from '../../dao/entities/ProgramExternalId.js'; -import { Plex, isPlexQueryError } from '../../external/plex.js'; -import { PlexApiFactory } from '../../external/PlexApiFactory'; +import { PlexApiClient } from '../../external/plex/PlexApiClient.js'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory'; import { Maybe } from '../../types/util.js'; import { asyncPool } from '../../util/asyncPool.js'; import { attempt, attemptSync, groupByUniq, wait } from '../../util/index.js'; @@ -22,6 +22,7 @@ import { LoggerFactory } from '../../util/logging/LoggerFactory.js'; import Fixer from './fixer'; import { PlexTerminalMedia } from '@tunarr/types/plex'; import { upsertProgramExternalIds_deprecated } from '../../dao/programExternalIdHelpers'; +import { isQueryError } from '../../external/BaseApiClient.js'; export class BackfillProgramExternalIds extends Fixer { #logger = LoggerFactory.child({ caller: import.meta }); @@ -53,7 +54,7 @@ export class BackfillProgramExternalIds extends Fixer { cursor.totalCount, ); - const plexConnections: Record = {}; + const plexConnections: Record = {}; while (cursor.length > 0) { await wait(50); // process @@ -66,12 +67,12 @@ export class BackfillProgramExternalIds extends Fixer { keys(plexConnections), ); - const serverSettings = await em.find(PlexServerSettings, { + const serverSettings = await em.find(MediaSource, { name: { $in: missingServers }, }); forEach(serverSettings, (server) => { - plexConnections[server.name] = PlexApiFactory().get(server); + plexConnections[server.name] = MediaSourceApiFactory().get(server); }); for await (const result of asyncPool( @@ -123,7 +124,7 @@ export class BackfillProgramExternalIds extends Fixer { } } - private async handleProgram(program: Program, plex: Maybe) { + private async handleProgram(program: Program, plex: Maybe) { if (isUndefined(plex)) { throw new Error( 'No Plex server connection found for server ' + @@ -133,7 +134,7 @@ export class BackfillProgramExternalIds extends Fixer { const metadataResult = await plex.getItemMetadata(program.externalKey); - if (isPlexQueryError(metadataResult)) { + if (isQueryError(metadataResult)) { throw new Error( `Could not retrieve metadata for program ID ${program.uuid}, rating key = ${program.externalKey}`, ); diff --git a/server/src/tasks/fixers/addPlexServerIds.ts b/server/src/tasks/fixers/addPlexServerIds.ts index ae462505..58ac9a7e 100644 --- a/server/src/tasks/fixers/addPlexServerIds.ts +++ b/server/src/tasks/fixers/addPlexServerIds.ts @@ -1,17 +1,20 @@ import { find, isNil } from 'lodash-es'; import { EntityManager } from '../../dao/dataSource.js'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; -import { Plex } from '../../external/plex.js'; +import { + MediaSource, + MediaSourceType, +} from '../../dao/entities/MediaSource.js'; +import { PlexApiClient } from '../../external/plex/PlexApiClient.js'; import Fixer from './fixer.js'; export class AddPlexServerIdsFixer extends Fixer { async runInternal(em: EntityManager): Promise { const plexServers = await em - .repo(PlexServerSettings) - .find({ clientIdentifier: null }); + .repo(MediaSource) + .find({ clientIdentifier: null, type: MediaSourceType.Plex }); for (const server of plexServers) { - const api = new Plex(server); + const api = new PlexApiClient(server); const devices = await api.getDevices(); if (!isNil(devices) && devices.MediaContainer.Device) { const matchingServer = find( diff --git a/server/src/tasks/fixers/backfillProgramGroupings.ts b/server/src/tasks/fixers/backfillProgramGroupings.ts index 0e82be33..1b2735fc 100644 --- a/server/src/tasks/fixers/backfillProgramGroupings.ts +++ b/server/src/tasks/fixers/backfillProgramGroupings.ts @@ -16,14 +16,14 @@ import ld, { } from 'lodash-es'; import { ProgramSourceType } from '../../dao/custom_types/ProgramSourceType'; import { getEm } from '../../dao/dataSource'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings'; +import { MediaSource } from '../../dao/entities/MediaSource'; import { Program, ProgramType } from '../../dao/entities/Program'; import { ProgramGrouping, ProgramGroupingType, } from '../../dao/entities/ProgramGrouping'; import { ProgramGroupingExternalId } from '../../dao/entities/ProgramGroupingExternalId'; -import { PlexApiFactory } from '../../external/PlexApiFactory'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory'; import { LoggerFactory } from '../../util/logging/LoggerFactory'; import Fixer from './fixer'; import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType'; @@ -35,7 +35,7 @@ export class BackfillProgramGroupings extends Fixer { }); protected async runInternal(em: EntityManager): Promise { - const plexServers = await em.findAll(PlexServerSettings); + const plexServers = await em.findAll(MediaSource); // Update shows first, then seasons, so we can relate them const serversAndShows = await em @@ -77,8 +77,8 @@ export class BackfillProgramGroupings extends Fixer { continue; } - const plex = PlexApiFactory().get(server); - const plexResult = await plex.doGet( + const plex = MediaSourceApiFactory().get(server); + const plexResult = await plex.doGetPath( '/library/metadata/' + grandparentExternalKey, ); @@ -147,8 +147,8 @@ export class BackfillProgramGroupings extends Fixer { continue; } - const plex = PlexApiFactory().get(server); - const plexResult = await plex.doGet( + const plex = MediaSourceApiFactory().get(server); + const plexResult = await plex.doGetPath( '/library/metadata/' + parentExternalKey, ); @@ -245,8 +245,8 @@ export class BackfillProgramGroupings extends Fixer { continue; } - const plex = PlexApiFactory().get(server); - const plexResult = await plex.doGet( + const plex = MediaSourceApiFactory().get(server); + const plexResult = await plex.doGetPath( '/library/metadata/' + ref.externalKey, ); diff --git a/server/src/tasks/fixers/missingSeasonNumbersFixer.ts b/server/src/tasks/fixers/missingSeasonNumbersFixer.ts index 6658d042..9faa9795 100644 --- a/server/src/tasks/fixers/missingSeasonNumbersFixer.ts +++ b/server/src/tasks/fixers/missingSeasonNumbersFixer.ts @@ -2,9 +2,9 @@ import { EntityManager } from '@mikro-orm/better-sqlite'; import { Cursor } from '@mikro-orm/core'; import { PlexEpisodeView, PlexSeasonView } from '@tunarr/types/plex'; import { first, forEach, groupBy, mapValues, pickBy } from 'lodash-es'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; +import { MediaSource } from '../../dao/entities/MediaSource.js'; import { Program, ProgramType } from '../../dao/entities/Program.js'; -import { Plex } from '../../external/plex.js'; +import { PlexApiClient } from '../../external/plex/PlexApiClient.js'; import { Maybe } from '../../types/util.js'; import { groupByUniqAndMap, wait } from '../../util/index.js'; import Fixer from './fixer.js'; @@ -14,7 +14,7 @@ export class MissingSeasonNumbersFixer extends Fixer { private logger = LoggerFactory.child({ caller: import.meta }); async runInternal(em: EntityManager): Promise { - const allPlexServers = await em.findAll(PlexServerSettings); + const allPlexServers = await em.findAll(MediaSource); if (allPlexServers.length === 0) { return; @@ -23,7 +23,7 @@ export class MissingSeasonNumbersFixer extends Fixer { const plexByName = groupByUniqAndMap( allPlexServers, 'name', - (server) => new Plex(server), + (server) => new PlexApiClient(server), ); let cursor: Maybe> = undefined; @@ -122,9 +122,12 @@ export class MissingSeasonNumbersFixer extends Fixer { } while (cursor.hasNextPage); } - private async findSeasonNumberUsingEpisode(episodeId: string, plex: Plex) { + private async findSeasonNumberUsingEpisode( + episodeId: string, + plex: PlexApiClient, + ) { try { - const episode = await plex.doGet( + const episode = await plex.doGetPath( `/library/metadata/${episodeId}`, ); return episode?.parentIndex; @@ -134,11 +137,14 @@ export class MissingSeasonNumbersFixer extends Fixer { } } - private async findSeasonNumberUsingParent(seasonId: string, plex: Plex) { + private async findSeasonNumberUsingParent( + seasonId: string, + plex: PlexApiClient, + ) { // We get the parent because we're dealing with an episode and we want the // season index. try { - const season = await plex.doGet( + const season = await plex.doGetPath( `/library/metadata/${seasonId}`, ); return first(season?.Metadata ?? [])?.index; diff --git a/server/src/tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.ts b/server/src/tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.ts new file mode 100644 index 00000000..72710ca6 --- /dev/null +++ b/server/src/tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.ts @@ -0,0 +1,99 @@ +import { ref } from '@mikro-orm/core'; +import { compact, isEmpty, isUndefined, map } from 'lodash-es'; +import { + ProgramExternalIdType, + programExternalIdTypeFromJellyfinProvider, +} from '../../dao/custom_types/ProgramExternalIdType.js'; +import { getEm } from '../../dao/dataSource.js'; +import { Program } from '../../dao/entities/Program.js'; +import { ProgramExternalId } from '../../dao/entities/ProgramExternalId.js'; +import { upsertProgramExternalIds_deprecated } from '../../dao/programExternalIdHelpers.js'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js'; +import { Maybe } from '../../types/util.js'; +import { isDefined, isNonEmptyString } from '../../util/index.js'; +import { Task } from '../Task.js'; +import { isQueryError } from '../../external/BaseApiClient.js'; +import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.js'; + +export class SaveJellyfinProgramExternalIdsTask extends Task { + ID = SaveJellyfinProgramExternalIdsTask.name; + + constructor(private programId: string) { + super(); + } + + protected async runInternal(): Promise { + const em = getEm(); + + const program = await em.findOneOrFail(Program, this.programId); + + const jellyfinIds = program.externalIds.filter( + (eid) => + eid.sourceType === ProgramExternalIdType.JELLYFIN && + isNonEmptyString(eid.externalSourceId), + ); + + if (isEmpty(jellyfinIds)) { + return; + } + + let chosenId: Maybe = undefined; + let api: Maybe; + for (const id of jellyfinIds) { + if (!isNonEmptyString(id.externalSourceId)) { + continue; + } + + api = await MediaSourceApiFactory().getJellyfinByName( + id.externalSourceId, + ); + + if (isDefined(api)) { + chosenId = id; + break; + } + } + + if (isUndefined(api) || isUndefined(chosenId)) { + return; + } + + const metadataResult = await api.getItem(chosenId.externalKey); + + if (isQueryError(metadataResult)) { + this.logger.error( + 'Error querying Jellyfin for item %s', + chosenId.externalKey, + ); + return; + } + + const metadata = metadataResult.data; + + const eids = compact( + map(metadata?.ProviderIds, (id, provider) => { + if (!isNonEmptyString(id)) { + return; + } + + const type = programExternalIdTypeFromJellyfinProvider(provider); + if (!type) { + return; + } + + const eid = new ProgramExternalId(); + eid.program = ref(program); + eid.externalSourceId = undefined; + eid.externalKey = id; + eid.sourceType = type; + return eid; + }), + ); + + return await upsertProgramExternalIds_deprecated(eids); + } + + get taskName() { + return SaveJellyfinProgramExternalIdsTask.name; + } +} diff --git a/server/src/tasks/jellyfin/SaveJellyfinProgramGroupingsTask.ts b/server/src/tasks/jellyfin/SaveJellyfinProgramGroupingsTask.ts new file mode 100644 index 00000000..1b713653 --- /dev/null +++ b/server/src/tasks/jellyfin/SaveJellyfinProgramGroupingsTask.ts @@ -0,0 +1,41 @@ +import { Tag } from '@tunarr/types'; +import { Task, TaskId } from '../Task.js'; +import { ProgramGroupingCalculator } from '../../dao/ProgramGroupingCalculator.js'; +import { ProgramDB } from '../../dao/programDB.js'; +import { ProgramType } from '../../dao/entities/Program.js'; + +type SaveJellyfinProgramGroupingsRequest = { + programType: ProgramType; + jellyfinServerName: string; + programAndJellyfinIds: { + programId: string; + jellyfinItemId: string; + parentKey: string; + }[]; + parentKeys: string[]; + grandparentKey: string; +}; + +export class SaveJellyfinProgramGroupingsTask extends Task { + public ID: string | Tag; + + constructor( + private request: SaveJellyfinProgramGroupingsRequest, + private programDB: ProgramDB = new ProgramDB(), + ) { + super(); + } + + protected async runInternal(): Promise { + await new ProgramGroupingCalculator( + this.programDB, + ).createHierarchyForManyFromJellyfin( + this.request.programType, + this.request.jellyfinServerName, + this.request.programAndJellyfinIds, + this.request.parentKeys, + this.request.grandparentKey, + ); + return; + } +} diff --git a/server/src/tasks/SavePlexProgramExternalIdsTask.ts b/server/src/tasks/plex/SavePlexProgramExternalIdsTask.ts similarity index 65% rename from server/src/tasks/SavePlexProgramExternalIdsTask.ts rename to server/src/tasks/plex/SavePlexProgramExternalIdsTask.ts index a2901ea7..76058a4d 100644 --- a/server/src/tasks/SavePlexProgramExternalIdsTask.ts +++ b/server/src/tasks/plex/SavePlexProgramExternalIdsTask.ts @@ -1,17 +1,18 @@ import { ref } from '@mikro-orm/core'; import { PlexTerminalMedia } from '@tunarr/types/plex'; import { compact, isEmpty, isError, isUndefined, map } from 'lodash-es'; -import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js'; -import { getEm } from '../dao/dataSource.js'; -import { Program } from '../dao/entities/Program.js'; -import { ProgramExternalId } from '../dao/entities/ProgramExternalId.js'; -import { upsertProgramExternalIds_deprecated } from '../dao/programExternalIdHelpers.js'; -import { Plex, isPlexQueryError } from '../external/plex.js'; -import { PlexApiFactory } from '../external/PlexApiFactory.js'; -import { Maybe } from '../types/util.js'; -import { parsePlexExternalGuid } from '../util/externalIds.js'; -import { isDefined, isNonEmptyString } from '../util/index.js'; -import { Task } from './Task.js'; +import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType.js'; +import { getEm } from '../../dao/dataSource.js'; +import { Program } from '../../dao/entities/Program.js'; +import { ProgramExternalId } from '../../dao/entities/ProgramExternalId.js'; +import { upsertProgramExternalIds_deprecated } from '../../dao/programExternalIdHelpers.js'; +import { PlexApiClient } from '../../external/plex/PlexApiClient.js'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js'; +import { Maybe } from '../../types/util.js'; +import { parsePlexExternalGuid } from '../../util/externalIds.js'; +import { isDefined, isNonEmptyString } from '../../util/index.js'; +import { Task } from '../Task.js'; +import { isQueryError } from '../../external/BaseApiClient.js'; export class SavePlexProgramExternalIdsTask extends Task { ID = SavePlexProgramExternalIdsTask.name; @@ -36,13 +37,13 @@ export class SavePlexProgramExternalIdsTask extends Task { } let chosenId: Maybe = undefined; - let api: Maybe; + let api: Maybe; for (const id of plexIds) { if (!isNonEmptyString(id.externalSourceId)) { continue; } - api = await PlexApiFactory().getOrSet(id.externalSourceId); + api = await MediaSourceApiFactory().getOrSet(id.externalSourceId); if (isDefined(api)) { chosenId = id; @@ -56,7 +57,7 @@ export class SavePlexProgramExternalIdsTask extends Task { const metadataResult = await api.getItemMetadata(chosenId.externalKey); - if (isPlexQueryError(metadataResult)) { + if (isQueryError(metadataResult)) { this.logger.error( 'Error querying Plex for item %s', chosenId.externalKey, diff --git a/server/src/tasks/plex/SavePlexProgramGroupingsTask.ts b/server/src/tasks/plex/SavePlexProgramGroupingsTask.ts index 6950db30..8470e708 100644 --- a/server/src/tasks/plex/SavePlexProgramGroupingsTask.ts +++ b/server/src/tasks/plex/SavePlexProgramGroupingsTask.ts @@ -7,7 +7,7 @@ import { ProgramType } from '../../dao/entities/Program.js'; type SavePlexProgramGroupingsRequest = { programType: ProgramType; plexServerName: string; - programAndPlexIds: { programId: string; plexId: string, parentKey }[]; + programAndPlexIds: { programId: string; plexId: string; parentKey }[]; parentKeys: string[]; grandparentKey: string; }; @@ -23,8 +23,9 @@ export class SavePlexProgramGroupingsTask extends Task { } protected async runInternal(): Promise { - const calculator = new ProgramGroupingCalculator(this.programDB); - await calculator.createHierarchyForManyFromPlex( + await new ProgramGroupingCalculator( + this.programDB, + ).createHierarchyForManyFromPlex( this.request.programType, this.request.plexServerName, this.request.programAndPlexIds, diff --git a/server/src/tasks/UpdatePlexPlayStatusTask.ts b/server/src/tasks/plex/UpdatePlexPlayStatusTask.ts similarity index 85% rename from server/src/tasks/UpdatePlexPlayStatusTask.ts rename to server/src/tasks/plex/UpdatePlexPlayStatusTask.ts index 3e535595..6c9bbfca 100644 --- a/server/src/tasks/UpdatePlexPlayStatusTask.ts +++ b/server/src/tasks/plex/UpdatePlexPlayStatusTask.ts @@ -1,11 +1,11 @@ import { RecurrenceRule } from 'node-schedule'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings'; -import { PlexApiFactory } from '../external/PlexApiFactory'; -import { run } from '../util'; -import { ScheduledTask } from './ScheduledTask'; -import { Task } from './Task'; +import { MediaSource } from '../../dao/entities/MediaSource'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory'; +import { run } from '../../util'; +import { ScheduledTask } from '../ScheduledTask'; +import { Task } from '../Task'; import dayjs from 'dayjs'; -import { GlobalScheduler } from '../services/scheduler'; +import { GlobalScheduler } from '../../services/scheduler'; import { v4 } from 'uuid'; type UpdatePlexPlayStatusScheduleRequest = { @@ -33,7 +33,7 @@ export class UpdatePlexPlayStatusScheduledTask extends ScheduledTask { private playState: PlayState = 'playing'; constructor( - private plexServer: PlexServerSettings, + private plexServer: MediaSource, private request: UpdatePlexPlayStatusScheduleRequest, public sessionId: string = v4(), ) { @@ -96,14 +96,14 @@ class UpdatePlexPlayStatusTask extends Task { } constructor( - private plexServer: PlexServerSettings, + private plexServer: MediaSource, private request: UpdatePlexPlayStatusInvocation, ) { super(); } protected async runInternal(): Promise { - const plex = PlexApiFactory().get(this.plexServer); + const plex = MediaSourceApiFactory().get(this.plexServer); const deviceName = `tunarr-channel-${this.request.channelNumber}`; const params = { @@ -119,7 +119,7 @@ class UpdatePlexPlayStatusTask extends Task { }; try { - await plex.doPost('/:/timeline', params); + await plex.doPost({ url: '/:/timeline', params }); } catch (error) { this.logger.warn( error, diff --git a/server/src/types/serverType.ts b/server/src/types/serverType.ts index 85bc790d..e5f1d0a5 100644 --- a/server/src/types/serverType.ts +++ b/server/src/types/serverType.ts @@ -4,9 +4,14 @@ import { FastifyPluginAsync, FastifyPluginCallback, RawServerDefault, + RouteGenericInterface, } from 'fastify'; import { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { FastifyRequest } from 'fastify/types/request'; +import { FastifySchema } from 'fastify/types/schema'; +import { ResolveFastifyRequestType } from 'fastify/types/type-provider'; import { IncomingMessage, ServerResponse } from 'http'; +import { z } from 'zod'; export type ServerType = FastifyInstance< RawServerDefault, @@ -27,3 +32,27 @@ export type RouterPluginAsyncCallback = FastifyPluginAsync< RawServerDefault, ZodTypeProvider >; + +export type ZodFastifySchema = { + body?: z.AnyZodObject; + querystring?: z.AnyZodObject; + params?: z.AnyZodObject; + headers?: z.AnyZodObject; + response?: z.AnyZodObject; +}; + +export type ZodFastifyRequest = + FastifyRequest< + RouteGenericInterface, + RawServerDefault, + IncomingMessage, + Readonly, + ZodTypeProvider, + unknown, + FastifyBaseLogger, + ResolveFastifyRequestType< + ZodTypeProvider, + Readonly, + RouteGenericInterface + > + >; diff --git a/server/src/types/util.ts b/server/src/types/util.ts index 4a0f33de..3d844cdd 100644 --- a/server/src/types/util.ts +++ b/server/src/types/util.ts @@ -4,6 +4,8 @@ export type Maybe = T | undefined; export type Nullable = T | null; +export type Nilable = Maybe | Nullable; + export type TupleToUnion> = T[number]; export type Intersection = { diff --git a/server/src/util/ProgramMinter.ts b/server/src/util/ProgramMinter.ts index 390edd4b..a10ac4a9 100644 --- a/server/src/util/ProgramMinter.ts +++ b/server/src/util/ProgramMinter.ts @@ -1,17 +1,28 @@ -import { EntityManager, ref } from '@mikro-orm/better-sqlite'; +import { + EntityManager, + RequiredEntityData, + ref, +} from '@mikro-orm/better-sqlite'; import { PlexEpisode, PlexMovie, PlexMusicTrack, PlexTerminalMedia, } from '@tunarr/types/plex'; -import { compact, first, isError, map } from 'lodash-es'; +import { compact, first, isError, isNil, map } from 'lodash-es'; import { ProgramSourceType } from '../dao/custom_types/ProgramSourceType.js'; import { Program, ProgramType } from '../dao/entities/Program.js'; import { ProgramExternalId } from '../dao/entities/ProgramExternalId.js'; -import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js'; +import { + ProgramExternalIdType, + programExternalIdTypeFromJellyfinProvider, +} from '../dao/custom_types/ProgramExternalIdType.js'; import { LoggerFactory } from './logging/LoggerFactory.js'; import { parsePlexExternalGuid } from './externalIds.js'; +import { ContentProgramOriginalProgram } from '@tunarr/types/schemas'; +import { JellyfinItem } from '@tunarr/types/jellyfin'; +import { nullToUndefined } from '@tunarr/shared/util'; +import dayjs from 'dayjs'; /** * Generates Program DB entities for Plex media @@ -24,18 +35,46 @@ class PlexProgramMinter { this.#em = em; } - mint(serverName: string, plexItem: PlexTerminalMedia) { - switch (plexItem.type) { - case 'movie': - return this.mintMovieProgram(serverName, plexItem); - case 'episode': - return this.mintEpisodeProgram(serverName, plexItem); - case 'track': - return this.mintTrackProgram(serverName, plexItem); + mint(serverName: string, program: ContentProgramOriginalProgram) { + switch (program.sourceType) { + case 'plex': + switch (program.program.type) { + case 'movie': + return this.mintMovieProgramForPlex(serverName, program.program); + case 'episode': + return this.mintEpisodeProgramForPlex(serverName, program.program); + case 'track': + return this.mintTrackProgramForPlex(serverName, program.program); + } + // Disabled because eslint does not pickup the fact that the above is + // exhaustive. + // eslint-disable-next-line no-fallthrough + case 'jellyfin': + switch (program.program.Type) { + case 'Movie': + return this.mintMovieProgramForJellyfin( + serverName, + program.program, + ); + case 'Episode': + return this.mintEpisodeProgramForJellyfin( + serverName, + program.program, + ); + case 'Audio': + default: + return this.mintTrackProgramForJellyfin( + serverName, + program.program, + ); + } } } - private mintMovieProgram(serverName: string, plexMovie: PlexMovie): Program { + private mintMovieProgramForPlex( + serverName: string, + plexMovie: PlexMovie, + ): Program { const file = first(first(plexMovie.Media)?.Part ?? []); return this.#em.create( Program, @@ -58,7 +97,34 @@ class PlexProgramMinter { ); } - private mintEpisodeProgram( + private mintMovieProgramForJellyfin( + serverName: string, + item: JellyfinItem, + ): Program { + // const file = first(first(plexMovie.Media)?.Part ?? []); + return this.#em.create( + Program, + { + sourceType: ProgramSourceType.JELLYFIN, + originalAirDate: nullToUndefined(item.PremiereDate), + duration: (item.RunTimeTicks ?? 0) / 10_000, + filePath: nullToUndefined(item.Path), + externalSourceId: serverName, + externalKey: item.Id, + plexRatingKey: item.Id, + plexFilePath: '', + // plexFilePath: file?.key, + rating: nullToUndefined(item.OfficialRating), + summary: nullToUndefined(item.Overview), + title: nullToUndefined(item.Name) ?? '', + type: ProgramType.Movie, + year: nullToUndefined(item.ProductionYear), + } satisfies RequiredEntityData, + { persist: false }, + ); + } + + private mintEpisodeProgramForPlex( serverName: string, plexEpisode: PlexEpisode, ): Program { @@ -92,7 +158,39 @@ class PlexProgramMinter { return program; } - private mintTrackProgram(serverName: string, plexTrack: PlexMusicTrack) { + private mintEpisodeProgramForJellyfin( + serverName: string, + item: JellyfinItem, + ): Program { + // const file = first(first(plexEpisode.Media)?.Part ?? []); + return this.#em.create( + Program, + { + sourceType: ProgramSourceType.JELLYFIN, + originalAirDate: nullToUndefined(item.PremiereDate), + duration: (item.RunTimeTicks ?? 0) / 10_000, + externalSourceId: serverName, + externalKey: item.Id, + rating: nullToUndefined(item.OfficialRating), + summary: nullToUndefined(item.Overview), + title: nullToUndefined(item.Name) ?? '', + type: ProgramType.Episode, + year: nullToUndefined(item.ProductionYear), + showTitle: nullToUndefined(item.SeriesName), + showIcon: nullToUndefined(item.SeriesThumbImageTag), + seasonNumber: nullToUndefined(item.ParentIndexNumber), + episode: nullToUndefined(item.IndexNumber), + parentExternalKey: nullToUndefined(item.SeasonId), + grandparentExternalKey: nullToUndefined(item.SeriesId), + }, + { persist: false }, + ); + } + + private mintTrackProgramForPlex( + serverName: string, + plexTrack: PlexMusicTrack, + ) { const file = first(first(plexTrack.Media)?.Part ?? []); return this.#em.create( Program, @@ -121,7 +219,52 @@ class PlexProgramMinter { ); } + private mintTrackProgramForJellyfin(serverName: string, item: JellyfinItem) { + // const file = first(first(plexTrack.Media)?.Part ?? []); + return this.#em.create( + Program, + { + sourceType: ProgramSourceType.JELLYFIN, + originalAirDate: nullToUndefined(item.PremiereDate), + duration: (item.RunTimeTicks ?? 0) / 10_000, + externalSourceId: serverName, + externalKey: item.Id, + rating: nullToUndefined(item.OfficialRating), + summary: nullToUndefined(item.Overview), + title: nullToUndefined(item.Name) ?? '', + type: ProgramType.Track, + year: item.PremiereDate ? dayjs(item.PremiereDate).year() : undefined, + parentExternalKey: nullToUndefined(item.AlbumId), + grandparentExternalKey: first(item.AlbumArtists)?.Id, + albumName: nullToUndefined(item.Album), + artistName: nullToUndefined(item.AlbumArtist), + }, + { persist: false }, + ); + } + mintExternalIds( + serverName: string, + program: Program, + { sourceType, program: originalProgram }: ContentProgramOriginalProgram, + ) { + switch (sourceType) { + case 'plex': + return this.mintExternalIdsForPlex( + serverName, + program, + originalProgram, + ); + case 'jellyfin': + return this.mintExternalIdsForJellyfin( + serverName, + program, + originalProgram, + ); + } + } + + mintExternalIdsForPlex( serverName: string, program: Program, media: PlexTerminalMedia, @@ -167,6 +310,60 @@ class PlexProgramMinter { return [ratingId, guidId, ...externalGuids]; } + + mintExternalIdsForJellyfin( + serverName: string, + program: Program, + media: JellyfinItem, + ) { + const ratingId = this.#em.create( + ProgramExternalId, + { + externalKey: media.Id, + sourceType: ProgramExternalIdType.JELLYFIN, + program, + externalSourceId: serverName, + // externalFilePath: file?.key, + // directFilePath: file?.file, + }, + { persist: false }, + ); + + // const guidId = this.#em.create( + // ProgramExternalId, + // { + // externalKey: media.guid, + // sourceType: ProgramExternalIdType.PLEX_GUID, + // program, + // }, + // { persist: false }, + // ); + + const externalGuids = compact( + map(media.ProviderIds, (externalGuid, guidType) => { + if (isNil(externalGuid)) { + return; + } + + const typ = programExternalIdTypeFromJellyfinProvider(guidType); + if (typ) { + return this.#em.create( + ProgramExternalId, + { + externalKey: externalGuid, + sourceType: typ, + program, + }, + { persist: false }, + ); + } + + return; + }), + ); + + return [ratingId, ...externalGuids]; + } } export class ProgramMinterFactory { diff --git a/server/src/util/axios.ts b/server/src/util/axios.ts new file mode 100644 index 00000000..7ae56175 --- /dev/null +++ b/server/src/util/axios.ts @@ -0,0 +1,48 @@ +import { AxiosInstance, InternalAxiosRequestConfig, isAxiosError } from 'axios'; +import { Logger } from './logging/LoggerFactory'; +import querystring from 'node:querystring'; + +type AxiosConfigWithMetadata = InternalAxiosRequestConfig & { + metadata: { + startTime: number; + }; +}; + +export function configureAxiosLogging(instance: AxiosInstance, logger: Logger) { + const logAxiosRequest = (req: AxiosConfigWithMetadata, status: number) => { + const query = req.params + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + `?${querystring.stringify(req.params)}` + : ''; + const elapsedTime = new Date().getTime() - req.metadata.startTime; + logger.http( + `[Axios Request]: ${req.method?.toUpperCase()} ${req.baseURL}${ + req.url + }${query} - (${status}) ${elapsedTime}ms`, + ); + }; + + instance.interceptors.request.use((req) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + (req as AxiosConfigWithMetadata).metadata = { + startTime: new Date().getTime(), + }; + return req; + }); + + instance.interceptors.response.use( + (resp) => { + logAxiosRequest(resp.config as AxiosConfigWithMetadata, resp.status); + return resp; + }, + (err) => { + if (isAxiosError(err) && err.config) { + logAxiosRequest( + err.config as AxiosConfigWithMetadata, + err.status ?? -1, + ); + } + throw err; + }, + ); +} diff --git a/server/src/util/index.ts b/server/src/util/index.ts index 368043ba..0ed82122 100644 --- a/server/src/util/index.ts +++ b/server/src/util/index.ts @@ -16,6 +16,7 @@ import _, { map, once, range, + reject, zipWith, } from 'lodash-es'; import fs from 'node:fs/promises'; @@ -464,3 +465,7 @@ export function nullToUndefined(x: T | null | undefined): T | undefined { } return x; } + +export function removeErrors(coll: Try[] | null | undefined): T[] { + return reject(coll, isError) satisfies T[] as T[]; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index e131e5a1..854e8776 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,25 +1,35 @@ import { ExternalId, SingleExternalId, MultiExternalId } from '@tunarr/types'; import { PlexMedia } from '@tunarr/types/plex'; -import { type ExternalIdType } from '@tunarr/types/schemas'; +import { + SingleExternalIdType, + type ExternalIdType, +} from '@tunarr/types/schemas'; export { scheduleRandomSlots } from './services/randomSlotsService.js'; export { scheduleTimeSlots } from './services/timeSlotService.js'; export { mod as dayjsMod } from './util/dayjsExtensions.js'; // TODO replace first arg with shared type export function createExternalId( - sourceType: ExternalIdType, + sourceType: ExternalIdType, //StrictExclude, sourceId: string, itemId: string, ): `${string}|${string}|${string}` { return `${sourceType}|${sourceId}|${itemId}`; } +export function createGlobalExternalIdString( + sourceType: SingleExternalIdType, + id: string, +): `${string}|${string}` { + return `${sourceType}|${id}`; +} + export function createExternalIdFromMulti(multi: MultiExternalId) { return createExternalId(multi.source, multi.sourceId, multi.id); } export function createExternalIdFromGlobal(global: SingleExternalId) { - return createExternalId(global.source, '', global.id); + return createGlobalExternalIdString(global.source, global.id); } // We could type this better if we reuse the other ExternalId diff --git a/shared/src/util/index.ts b/shared/src/util/index.ts index 3196c141..d14a305e 100644 --- a/shared/src/util/index.ts +++ b/shared/src/util/index.ts @@ -4,6 +4,7 @@ import { PlexMedia } from '@tunarr/types/plex'; import isFunction from 'lodash-es/isFunction.js'; import { MarkRequired } from 'ts-essentials'; import type { PerTypeCallback } from '../types/index.js'; +import { isNull } from 'lodash-es'; export { mod as dayjsMod } from './dayjsExtensions.js'; export * as seq from './seq.js'; @@ -111,3 +112,10 @@ export function forPlexMedia(choices: PerTypeCallback) { return null; }; } + +export function nullToUndefined(x: T | null | undefined): T | undefined { + if (isNull(x)) { + return undefined; + } + return x; +} diff --git a/types/package.json b/types/package.json index 996e8cc9..45da8a5a 100644 --- a/types/package.json +++ b/types/package.json @@ -42,6 +42,10 @@ "types": "./build/plex/index.d.ts", "default": "./build/plex/index.js" }, + "./jellyfin": { + "types": "./build/jellyfin/index.d.ts", + "default": "./build/jellyfin/index.js" + }, "./api": { "types": "./build/api/index.d.ts", "default": "./build/api/index.js" diff --git a/types/src/PlexSettings.ts b/types/src/MediaSourceSettings.ts similarity index 60% rename from types/src/PlexSettings.ts rename to types/src/MediaSourceSettings.ts index fb596aae..0bad6985 100644 --- a/types/src/PlexSettings.ts +++ b/types/src/MediaSourceSettings.ts @@ -1,11 +1,19 @@ import z from 'zod'; import { + JellyfinServerSettingsSchema, + MediaSourceSettingsSchema, PlexServerSettingsSchema, PlexStreamSettingsSchema, } from './schemas/settingsSchemas.js'; export type PlexServerSettings = z.infer; +export type JellyfinServerSettings = z.infer< + typeof JellyfinServerSettingsSchema +>; + +export type MediaSourceSettings = z.infer; + export type PlexStreamSettings = z.infer; export const defaultPlexStreamSettings = PlexStreamSettingsSchema.parse({}); diff --git a/types/src/api/index.ts b/types/src/api/index.ts index 9e9041b1..10a2ba66 100644 --- a/types/src/api/index.ts +++ b/types/src/api/index.ts @@ -6,6 +6,7 @@ import { } from '../schemas/programmingSchema.js'; import { BackupSettingsSchema, + JellyfinServerSettingsSchema, PlexServerSettingsSchema, } from '../schemas/settingsSchemas.js'; import { @@ -118,19 +119,36 @@ export const RandomSlotProgramLineupSchema = z.object({ schedule: RandomSlotScheduleSchema, }); -export const UpdateChannelProgrammingRequestSchema = z.discriminatedUnion( +export const UpdateChannelProgrammingRequestSchema: z.ZodDiscriminatedUnion< 'type', [ - ManualProgramLineupSchema, - TimeBasedProgramLineupSchema, - RandomSlotProgramLineupSchema, - ], -); + typeof ManualProgramLineupSchema, + typeof TimeBasedProgramLineupSchema, + typeof RandomSlotProgramLineupSchema, + ] +> = z.discriminatedUnion('type', [ + ManualProgramLineupSchema, + TimeBasedProgramLineupSchema, + RandomSlotProgramLineupSchema, +]); export type UpdateChannelProgrammingRequest = z.infer< typeof UpdateChannelProgrammingRequestSchema >; +export const UpdateMediaSourceRequestSchema = z.discriminatedUnion('type', [ + PlexServerSettingsSchema.partial({ + sendChannelUpdates: true, + sendGuideUpdates: true, + clientIdentifier: true, + }), + JellyfinServerSettingsSchema, +]); + +export type UpdateMediaSourceRequest = z.infer< + typeof UpdateMediaSourceRequestSchema +>; + export const UpdatePlexServerRequestSchema = PlexServerSettingsSchema.partial({ sendChannelUpdates: true, sendGuideUpdates: true, @@ -142,17 +160,18 @@ export type UpdatePlexServerRequest = z.infer< typeof UpdatePlexServerRequestSchema >; -export const InsertPlexServerRequestSchema = PlexServerSettingsSchema.partial({ - sendChannelUpdates: true, - sendGuideUpdates: true, - index: true, - clientIdentifier: true, -}).omit({ - id: true, -}); +export const InsertMediaSourceRequestSchema = z.discriminatedUnion('type', [ + PlexServerSettingsSchema.partial({ + sendChannelUpdates: true, + sendGuideUpdates: true, + index: true, + clientIdentifier: true, + }).omit({ id: true }), + JellyfinServerSettingsSchema.omit({ id: true }), +]); -export type InsertPlexServerRequest = z.infer< - typeof InsertPlexServerRequestSchema +export type InsertMediaSourceRequest = z.infer< + typeof InsertMediaSourceRequestSchema >; export const VersionApiResponseSchema = z.object({ @@ -201,3 +220,9 @@ export type UpdateSystemSettingsRequest = z.infer< >; export const UpdateBackupSettingsRequestSchema = BackupSettingsSchema; + +export const JellyfinLoginRequest = z.object({ + url: z.string().url(), + username: z.string().min(1), + password: z.string().min(1), +}); diff --git a/types/src/index.ts b/types/src/index.ts index dd9096c9..e86b21e5 100644 --- a/types/src/index.ts +++ b/types/src/index.ts @@ -5,7 +5,7 @@ export * from './FfmpegSettings.js'; export * from './FillerList.js'; export * from './GuideApi.js'; export * from './HdhrSettings.js'; -export * from './PlexSettings.js'; +export * from './MediaSourceSettings.js'; export * from './Program.js'; export * from './Tasks.js'; export * from './Theme.js'; diff --git a/types/src/jellyfin/index.ts b/types/src/jellyfin/index.ts new file mode 100644 index 00000000..29b80795 --- /dev/null +++ b/types/src/jellyfin/index.ts @@ -0,0 +1,996 @@ +import { z } from 'zod'; + +// Some of this is generated from the Jellyfin 10.9.7 OpenAPI Schema + +export const JellyfinItemFields = z.enum([ + 'AirTime', + 'CanDelete', + 'CanDownload', + 'ChannelInfo', + 'Chapters', + 'Trickplay', + 'ChildCount', + 'CumulativeRunTimeTicks', + 'CustomRating', + 'DateCreated', + 'DateLastMediaAdded', + 'DisplayPreferencesId', + 'Etag', + 'ExternalUrls', + 'Genres', + 'HomePageUrl', + 'ItemCounts', + 'MediaSourceCount', + 'MediaSources', + 'OriginalTitle', + 'Overview', + 'ParentId', + 'Path', + 'People', + 'PlayAccess', + 'ProductionLocations', + 'ProviderIds', + 'PrimaryImageAspectRatio', + 'RecursiveItemCount', + 'Settings', + 'ScreenshotImageTags', + 'SeriesPrimaryImage', + 'SeriesStudio', + 'SortName', + 'SpecialEpisodeNumbers', + 'Studios', + 'Taglines', + 'Tags', + 'RemoteTrailers', + 'MediaStreams', + 'SeasonUserData', + 'ServiceName', + 'ThemeSongIds', + 'ThemeVideoIds', + 'ExternalEtag', + 'PresentationUniqueKey', + 'InheritedParentalRatingValue', + 'ExternalSeriesId', + 'SeriesPresentationUniqueKey', + 'DateLastRefreshed', + 'DateLastSaved', + 'RefreshState', + 'ChannelImage', + 'EnableMediaSourceDisplay', + 'Width', + 'Height', + 'ExtraIds', + 'LocalTrailerCount', + 'IsHD', + 'SpecialFeatureCount', +]); + +export type JellyfinItemFields = z.infer; + +export const JellyfinBaseItemKind = z.enum([ + 'AggregateFolder', + 'Audio', + 'AudioBook', + 'BasePluginFolder', + 'Book', + 'BoxSet', + 'Channel', + 'ChannelFolderItem', + 'CollectionFolder', + 'Episode', + 'Folder', + 'Genre', + 'ManualPlaylistsFolder', + 'Movie', + 'LiveTvChannel', + 'LiveTvProgram', + 'MusicAlbum', + 'MusicArtist', + 'MusicGenre', + 'MusicVideo', + 'Person', + 'Photo', + 'PhotoAlbum', + 'Playlist', + 'PlaylistsFolder', + 'Program', + 'Recording', + 'Season', + 'Series', + 'Studio', + 'Trailer', + 'TvChannel', + 'TvProgram', + 'UserRootFolder', + 'UserView', + 'Video', + 'Year', +]); + +export const JellyfinItemFilter = z.enum([ + 'IsFolder', + 'IsNotFolder', + 'IsUnplayed', + 'IsPlayed', + 'IsFavorite', + 'IsResumable', + 'Likes', + 'Dislikes', + 'IsFavoriteOrLikes', +]); + +export const JellyfinMediaType = z.enum([ + 'Unknown', + 'Video', + 'Audio', + 'Photo', + 'Book', +]); + +export const JellyfinImageType = z.enum([ + 'Primary', + 'Art', + 'Backdrop', + 'Banner', + 'Logo', + 'Thumb', + 'Disc', + 'Box', + 'Screenshot', + 'Menu', + 'Chapter', + 'BoxRear', + 'Profile', +]); + +export const JellyfinItemSortBy = z.enum([ + 'Default', + 'AiredEpisodeOrder', + 'Album', + 'AlbumArtist', + 'Artist', + 'DateCreated', + 'OfficialRating', + 'DatePlayed', + 'PremiereDate', + 'StartDate', + 'SortName', + 'Name', + 'Random', + 'Runtime', + 'CommunityRating', + 'ProductionYear', + 'PlayCount', + 'CriticRating', + 'IsFolder', + 'IsUnplayed', + 'IsPlayed', + 'SeriesSortName', + 'VideoBitRate', + 'AirTime', + 'Studio', + 'IsFavoriteOrLiked', + 'DateLastContentAdded', + 'SeriesDatePlayed', + 'ParentIndexNumber', + 'IndexNumber', + 'SimilarityScore', + 'SearchScore', +]); + +const CollectionType = z.enum([ + 'unknown', + 'movies', + 'tvshows', + 'music', + 'musicvideos', + 'trailers', + 'homevideos', + 'boxsets', + 'books', + 'photos', + 'livetv', + 'playlists', + 'folders', +]); + +export const JellyfinSortOrder = z.enum(['Ascending', 'Descending']); + +const ChapterInfo = z + .object({ + StartPositionTicks: z.number().int(), + Name: z.string().nullable().optional(), + ImagePath: z.string().nullable().optional(), + ImageDateModified: z.string().datetime({ offset: true }), + ImageTag: z.string().nullable().optional(), + }) + .partial(); + +export const JellyfinUserPolicyResponse = z.object({ + IsAdministrator: z.boolean(), + IsDisabled: z.boolean(), + EnableAllFolders: z.boolean(), +}); + +export type JellyfinUserPolicyResponse = z.infer< + typeof JellyfinUserPolicyResponse +>; + +export const JellyfinUserResponse = z.object({ + Name: z.string(), + Id: z.string(), + Policy: JellyfinUserPolicyResponse, +}); + +export type JellyfinUserResponse = z.infer; + +export const JellyfinLibraryPathInfo = z.object({ + Path: z.string(), + NetworkPath: z.string().nullable().optional(), +}); + +export const JellyfinLibraryOptions = z.object({ + PathInfos: z.array(JellyfinLibraryPathInfo), +}); + +export const JellyfinLibrary = z.object({ + Name: z.string(), + CollectionType: z.string(), + ItemId: z.string(), + LibraryOptions: JellyfinLibraryOptions, +}); + +export type JellyfinLibrary = z.infer; + +export const JellyfinLibraryResponse = z.array(JellyfinLibrary); + +export const JellyfinMediaStream = z.object({ + Type: z.string(), + Codec: z.string(), + Language: z.string(), + IsInterlaced: z.boolean().nullable().optional(), + Height: z.number().positive().nullable().optional().catch(undefined), + Width: z.number().positive().nullable().optional().catch(undefined), + Index: z.number(), + IsDefault: z.boolean(), + IsForced: z.boolean(), + IsExternal: z.boolean(), + IsHearingImpaired: z.boolean().nullable().optional(), + VideoRange: z.string().nullable().optional(), + AudioSpatialFormat: z.string().nullable().optional(), + AspectRatio: z.string().nullable().optional(), + BitRate: z.number().positive().nullable().optional(), + ChannelLayout: z.string().nullable().optional(), + Channels: z.number().positive().nullable().optional(), + RealFrameRate: z.number().positive().nullable().optional(), + PixelFormat: z.string().nullable().optional(), + Title: z.string().nullable().optional(), + Profile: z.string().nullable().optional(), + ColorRange: z.string().nullable().optional(), + ColorSpace: z.string().nullable().optional(), + ColorTransfer: z.string().nullable().optional(), + ColorPrimaries: z.string().nullable().optional(), + IsAnamorphic: z.boolean().nullable().optional(), +}); + +export const JellyfinImageBlurHashes = z.object({ + Backdrop: z.record(z.string()).nullable().optional(), + Primary: z.record(z.string()).nullable().optional(), + Logo: z.record(z.string()).nullable().optional(), + Thumb: z.record(z.string()).nullable().optional(), +}); + +export const JellyfinJoinItem = z.object({ + Name: z.string(), + Id: z.string(), +}); + +export const JellyfinPerson = JellyfinJoinItem.extend({ + Role: z.string().nullable().optional(), + Type: z.string().nullable().optional(), + PrimaryImageTag: z.string().nullable().optional(), + ImageBlurHashes: JellyfinImageBlurHashes.nullable().optional(), +}); + +export const JellyfinChapter = z.object({ + StartPositionTicks: z.number().positive(), + Name: z.string().nullable().optional(), +}); + +type Video3DFormat = + | 'HalfSideBySide' + | 'FullSideBySide' + | 'FullTopAndBottom' + | 'HalfTopAndBottom' + | 'MVC'; +type ExternalUrl = Partial<{ + Name: string | null; + Url: string | null; +}>; + +type MediaProtocol = 'File' | 'Http' | 'Rtmp' | 'Rtsp' | 'Udp' | 'Rtp' | 'Ftp'; +type MediaSourceType = 'Default' | 'Grouping' | 'Placeholder'; +type VideoType = 'VideoFile' | 'Iso' | 'Dvd' | 'BluRay'; +type IsoType = 'Dvd' | 'BluRay'; + +type VideoRange = 'Unknown' | 'SDR' | 'HDR'; + +type VideoRangeType = + | 'Unknown' + | 'SDR' + | 'HDR10' + | 'HLG' + | 'DOVI' + | 'DOVIWithHDR10' + | 'DOVIWithHLG' + | 'DOVIWithSDR' + | 'HDR10Plus'; + +type MediaStreamType = + | 'Audio' + | 'Video' + | 'Subtitle' + | 'EmbeddedImage' + | 'Data' + | 'Lyric'; + +type SubtitleDeliveryMethod = 'Encode' | 'Embed' | 'External' | 'Hls' | 'Drop'; + +export const JellyfinItemKind = z.enum([ + 'AggregateFolder', + 'Audio', + 'AudioBook', + 'BasePluginFolder', + 'Book', + 'BoxSet', + 'Channel', + 'ChannelFolderItem', + 'CollectionFolder', + 'Episode', + 'Folder', + 'Genre', + 'ManualPlaylistsFolder', + 'Movie', + 'LiveTvChannel', + 'LiveTvProgram', + 'MusicAlbum', + 'MusicArtist', + 'MusicGenre', + 'MusicVideo', + 'Person', + 'Photo', + 'PhotoAlbum', + 'Playlist', + 'PlaylistsFolder', + 'Program', + 'Recording', + 'Season', + 'Series', + 'Studio', + 'Trailer', + 'TvChannel', + 'TvProgram', + 'UserRootFolder', + 'UserView', + 'Video', + 'Year', +]); + +export type JellyfinItemKind = z.infer; + +export type PersonKind = + | 'Unknown' + | 'Actor' + | 'Director' + | 'Composer' + | 'Writer' + | 'GuestStar' + | 'Producer' + | 'Conductor' + | 'Lyricist' + | 'Arranger' + | 'Engineer' + | 'Mixer' + | 'Remixer' + | 'Creator' + | 'Artist' + | 'AlbumArtist' + | 'Author' + | 'Illustrator' + | 'Penciller' + | 'Inker' + | 'Colorist' + | 'Letterer' + | 'CoverArtist' + | 'Editor' + | 'Translator'; + +const NameGuidPair = z + .object({ Name: z.string().nullable().optional(), Id: z.string() }) + .partial(); + +type NameGuidPair = z.infer; + +type CollectionType = + | 'unknown' + | 'movies' + | 'tvshows' + | 'music' + | 'musicvideos' + | 'trailers' + | 'homevideos' + | 'boxsets' + | 'books' + | 'photos' + | 'livetv' + | 'playlists' + | 'folders'; + +type ChapterInfo = Partial<{ + StartPositionTicks: number; + Name: string | null; + ImagePath: string | null; + ImageDateModified: string; + ImageTag: string | null; +}>; + +const ExtraType = z.enum([ + 'Unknown', + 'Clip', + 'Trailer', + 'BehindTheScenes', + 'DeletedScene', + 'Interview', + 'Scene', + 'Sample', + 'ThemeSong', + 'ThemeVideo', + 'Featurette', + 'Short', +]); + +const Video3DFormat = z.enum([ + 'HalfSideBySide', + 'FullSideBySide', + 'FullTopAndBottom', + 'HalfTopAndBottom', + 'MVC', +]); +const ExternalUrl = z + .object({ + Name: z.string().nullable().optional(), + Url: z.string().nullable().optional(), + }) + .partial(); +const MediaProtocol = z.enum([ + 'File', + 'Http', + 'Rtmp', + 'Rtsp', + 'Udp', + 'Rtp', + 'Ftp', +]); +const MediaSourceType = z.enum(['Default', 'Grouping', 'Placeholder']); +const VideoType = z.enum(['VideoFile', 'Iso', 'Dvd', 'BluRay']); +const IsoType = z.enum(['Dvd', 'BluRay']); +const VideoRange = z.enum(['Unknown', 'SDR', 'HDR']); +const VideoRangeType = z.enum([ + 'Unknown', + 'SDR', + 'HDR10', + 'HLG', + 'DOVI', + 'DOVIWithHDR10', + 'DOVIWithHLG', + 'DOVIWithSDR', + 'HDR10Plus', +]); + +const SubtitleDeliveryMethod = z.enum([ + 'Encode', + 'Embed', + 'External', + 'Hls', + 'Drop', +]); + +const MediaStreamType = z.enum([ + 'Audio', + 'Video', + 'Subtitle', + 'EmbeddedImage', + 'Data', + 'Lyric', +]); + +const MediaAttachment = z + .object({ + Codec: z.string().nullable().optional(), + CodecTag: z.string().nullable().optional(), + Comment: z.string().nullable().optional(), + Index: z.number().int(), + FileName: z.string().nullable().optional(), + MimeType: z.string().nullable().optional(), + DeliveryUrl: z.string().nullable().optional(), + }) + .partial(); + +const MediaStream = z + .object({ + Codec: z.string().nullable().optional(), + CodecTag: z.string().nullable().optional(), + Language: z.string().nullable().optional(), + ColorRange: z.string().nullable().optional(), + ColorSpace: z.string().nullable().optional(), + ColorTransfer: z.string().nullable().optional(), + ColorPrimaries: z.string().nullable().optional(), + DvVersionMajor: z.number().int().nullable().optional(), + DvVersionMinor: z.number().int().nullable().optional(), + DvProfile: z.number().int().nullable().optional(), + DvLevel: z.number().int().nullable().optional(), + RpuPresentFlag: z.number().int().nullable().optional(), + ElPresentFlag: z.number().int().nullable().optional(), + BlPresentFlag: z.number().int().nullable().optional(), + DvBlSignalCompatibilityId: z.number().int().nullable().optional(), + Comment: z.string().nullable().optional(), + TimeBase: z.string().nullable().optional(), + CodecTimeBase: z.string().nullable().optional(), + Title: z.string().nullable().optional(), + VideoRange: VideoRange, + VideoRangeType: VideoRangeType, + VideoDoViTitle: z.string().nullable().optional(), + // AudioSpatialFormat: AudioSpatialFormat.default('None'), + LocalizedUndefined: z.string().nullable().optional(), + LocalizedDefault: z.string().nullable().optional(), + LocalizedForced: z.string().nullable().optional(), + LocalizedExternal: z.string().nullable().optional(), + LocalizedHearingImpaired: z.string().nullable().optional(), + DisplayTitle: z.string().nullable().optional(), + NalLengthSize: z.string().nullable().optional(), + IsInterlaced: z.boolean(), + IsAVC: z.boolean().nullable().optional(), + ChannelLayout: z.string().nullable().optional(), + BitRate: z.number().int().nullable().optional(), + BitDepth: z.number().int().nullable().optional(), + RefFrames: z.number().int().nullable().optional(), + PacketLength: z.number().int().nullable().optional(), + Channels: z.number().int().nullable().optional(), + SampleRate: z.number().int().nullable().optional(), + IsDefault: z.boolean(), + IsForced: z.boolean(), + IsHearingImpaired: z.boolean(), + Height: z.number().int().nullable().optional(), + Width: z.number().int().nullable().optional(), + AverageFrameRate: z.number().nullable().optional(), + RealFrameRate: z.number().nullable().optional(), + Profile: z.string().nullable().optional(), + Type: MediaStreamType, + AspectRatio: z.string().nullable().optional(), + Index: z.number().int(), + Score: z.number().int().nullable().optional(), + IsExternal: z.boolean(), + DeliveryMethod: SubtitleDeliveryMethod.nullable().optional(), + DeliveryUrl: z.string().nullable().optional(), + IsExternalUrl: z.boolean().nullable().optional(), + IsTextSubtitleStream: z.boolean(), + SupportsExternalStream: z.boolean(), + Path: z.string().nullable().optional(), + PixelFormat: z.string().nullable().optional(), + Level: z.number().nullable().optional(), + IsAnamorphic: z.boolean().nullable().optional(), + }) + .partial(); + +export type JellyfinMediaStream = z.infer; + +const MediaSourceInfo = z + .object({ + Protocol: MediaProtocol, + Id: z.string().nullable().optional(), + Path: z.string().nullable().optional(), + EncoderPath: z.string().nullable().optional(), + EncoderProtocol: MediaProtocol.nullable().optional(), + Type: MediaSourceType, + Container: z.string().nullable().optional(), + Size: z.number().int().nullable().optional(), + Name: z.string().nullable().optional(), + IsRemote: z.boolean(), + ETag: z.string().nullable().optional(), + RunTimeTicks: z.number().int().nullable().optional(), + ReadAtNativeFramerate: z.boolean(), + IgnoreDts: z.boolean(), + IgnoreIndex: z.boolean(), + GenPtsInput: z.boolean(), + SupportsTranscoding: z.boolean(), + SupportsDirectStream: z.boolean(), + SupportsDirectPlay: z.boolean(), + IsInfiniteStream: z.boolean(), + RequiresOpening: z.boolean(), + OpenToken: z.string().nullable().optional(), + RequiresClosing: z.boolean(), + LiveStreamId: z.string().nullable().optional(), + BufferMs: z.number().int().nullable().optional(), + RequiresLooping: z.boolean(), + SupportsProbing: z.boolean(), + VideoType: VideoType.nullable().optional(), + IsoType: IsoType.nullable().optional(), + Video3DFormat: Video3DFormat.nullable().optional(), + MediaStreams: z.array(MediaStream).nullable().optional(), + MediaAttachments: z.array(MediaAttachment).nullable().optional(), + Formats: z.array(z.string()).nullable().optional(), + Bitrate: z.number().int().nullable().optional(), + // Timestamp: TransportStreamTimestamp.nullable().optional(), + RequiredHttpHeaders: z + .record(z.string().nullable().optional()) + .nullable() + .optional(), + TranscodingUrl: z.string().nullable().optional(), + // TranscodingSubProtocol: MediaStreamProtocol, + TranscodingContainer: z.string().nullable().optional(), + AnalyzeDurationMs: z.number().int().nullable().optional(), + DefaultAudioStreamIndex: z.number().int().nullable().optional(), + DefaultSubtitleStreamIndex: z.number().int().nullable().optional(), + }) + .partial(); + +export const JellyfinItem = z.object({ + Name: z.string().nullable().optional(), + OriginalTitle: z.string().nullable().optional(), + ServerId: z.string().nullable().optional(), + Id: z.string(), + Etag: z.string().nullable().optional(), + SourceType: z.string().nullable().optional(), + PlaylistItemId: z.string().nullable().optional(), + DateCreated: z.string().datetime({ offset: true }).nullable().optional(), + DateLastMediaAdded: z + .string() + .datetime({ offset: true }) + .nullable() + .optional(), + ExtraType: ExtraType.nullable().optional(), + AirsBeforeSeasonNumber: z.number().int().nullable().optional(), + AirsAfterSeasonNumber: z.number().int().nullable().optional(), + AirsBeforeEpisodeNumber: z.number().int().nullable().optional(), + CanDelete: z.boolean().nullable().optional(), + CanDownload: z.boolean().nullable().optional(), + HasLyrics: z.boolean().nullable().optional(), + HasSubtitles: z.boolean().nullable().optional(), + PreferredMetadataLanguage: z.string().nullable().optional(), + PreferredMetadataCountryCode: z.string().nullable().optional(), + Container: z.string().nullable().optional(), + SortName: z.string().nullable().optional(), + ForcedSortName: z.string().nullable().optional(), + Video3DFormat: Video3DFormat.nullable().optional(), + PremiereDate: z.string().datetime({ offset: true }).nullable().optional(), + ExternalUrls: z.array(ExternalUrl).nullable().optional(), + MediaSources: z.array(MediaSourceInfo).nullable().optional(), + CriticRating: z.number().nullable().optional(), + ProductionLocations: z.array(z.string()).nullable().optional(), + Path: z.string().nullable().optional(), + EnableMediaSourceDisplay: z.boolean().nullable().optional(), + OfficialRating: z.string().nullable().optional(), + CustomRating: z.string().nullable().optional(), + ChannelId: z.string().nullable().optional(), + ChannelName: z.string().nullable().optional(), + Overview: z.string().nullable().optional(), + Taglines: z.array(z.string()).nullable().optional(), + Genres: z.array(z.string()).nullable().optional(), + CommunityRating: z.number().nullable().optional(), + CumulativeRunTimeTicks: z.number().int().nullable().optional(), + RunTimeTicks: z.number().int().nullable().optional(), + // PlayAccess: PlayAccess.nullable().optional(), + AspectRatio: z.string().nullable().optional(), + ProductionYear: z.number().int().nullable().optional(), + IsPlaceHolder: z.boolean().nullable().optional(), + Number: z.string().nullable().optional(), + ChannelNumber: z.string().nullable().optional(), + IndexNumber: z.number().int().nullable().optional(), + IndexNumberEnd: z.number().int().nullable().optional(), + ParentIndexNumber: z.number().int().nullable().optional(), + // RemoteTrailers: z.array(MediaUrl).nullable().optional(), + ProviderIds: z.record(z.string().nullable().optional()).nullable().optional(), + IsHD: z.boolean().nullable().optional(), + IsFolder: z.boolean().nullable().optional(), + ParentId: z.string().nullable().optional(), + Type: JellyfinItemKind, + // People: z.array(BaseItemPerson).nullable().optional(), + Studios: z.array(NameGuidPair).nullable().optional(), + GenreItems: z.array(NameGuidPair).nullable().optional(), + ParentLogoItemId: z.string().nullable().optional(), + ParentBackdropItemId: z.string().nullable().optional(), + ParentBackdropImageTags: z.array(z.string()).nullable().optional(), + LocalTrailerCount: z.number().int().nullable().optional(), + // UserData: UserItemDataDto.nullable().optional(), + RecursiveItemCount: z.number().int().nullable().optional(), + ChildCount: z.number().int().nullable().optional(), + SeriesName: z.string().nullable().optional(), + SeriesId: z.string().nullable().optional(), + SeasonId: z.string().nullable().optional(), + SpecialFeatureCount: z.number().int().nullable().optional(), + DisplayPreferencesId: z.string().nullable().optional(), + Status: z.string().nullable().optional(), + AirTime: z.string().nullable().optional(), + // AirDays: z.array(DayOfWeek).nullable().optional(), + Tags: z.array(z.string()).nullable().optional(), + PrimaryImageAspectRatio: z.number().nullable().optional(), + Artists: z.array(z.string()).nullable().optional(), + ArtistItems: z.array(NameGuidPair).nullable().optional(), + Album: z.string().nullable().optional(), + CollectionType: CollectionType.nullable().optional(), + DisplayOrder: z.string().nullable().optional(), + AlbumId: z.string().nullable().optional(), + AlbumPrimaryImageTag: z.string().nullable().optional(), + SeriesPrimaryImageTag: z.string().nullable().optional(), + AlbumArtist: z.string().nullable().optional(), + AlbumArtists: z.array(NameGuidPair).nullable().optional(), + SeasonName: z.string().nullable().optional(), + MediaStreams: z.array(MediaStream).nullable().optional(), + VideoType: VideoType.nullable().optional(), + PartCount: z.number().int().nullable().optional(), + MediaSourceCount: z.number().int().nullable().optional(), + ImageTags: z.record(z.string()).nullable().optional(), + BackdropImageTags: z.array(z.string()).nullable().optional(), + ScreenshotImageTags: z.array(z.string()).nullable().optional(), + ParentLogoImageTag: z.string().nullable().optional(), + ParentArtItemId: z.string().nullable().optional(), + ParentArtImageTag: z.string().nullable().optional(), + SeriesThumbImageTag: z.string().nullable().optional(), + ImageBlurHashes: z + .object({ + Primary: z.record(z.string()), + Art: z.record(z.string()), + Backdrop: z.record(z.string()), + Banner: z.record(z.string()), + Logo: z.record(z.string()), + Thumb: z.record(z.string()), + Disc: z.record(z.string()), + Box: z.record(z.string()), + Screenshot: z.record(z.string()), + Menu: z.record(z.string()), + Chapter: z.record(z.string()), + BoxRear: z.record(z.string()), + Profile: z.record(z.string()), + }) + .partial() + .passthrough() + .nullable() + .optional(), + SeriesStudio: z.string().nullable().optional(), + ParentThumbItemId: z.string().nullable().optional(), + ParentThumbImageTag: z.string().nullable().optional(), + ParentPrimaryImageItemId: z.string().nullable().optional(), + ParentPrimaryImageTag: z.string().nullable().optional(), + Chapters: z.array(ChapterInfo).nullable().optional(), + // Trickplay: z.record(z.record(TrickplayInfo)).nullable().optional(), + // LocationType: LocationType.nullable().optional(), + IsoType: IsoType.nullable().optional(), + MediaType: JellyfinMediaType, + EndDate: z.string().datetime({ offset: true }).nullable().optional(), + // LockedFields: z.array(MetadataField).nullable().optional(), + TrailerCount: z.number().int().nullable().optional(), + MovieCount: z.number().int().nullable().optional(), + SeriesCount: z.number().int().nullable().optional(), + ProgramCount: z.number().int().nullable().optional(), + EpisodeCount: z.number().int().nullable().optional(), + SongCount: z.number().int().nullable().optional(), + AlbumCount: z.number().int().nullable().optional(), + ArtistCount: z.number().int().nullable().optional(), + MusicVideoCount: z.number().int().nullable().optional(), + LockData: z.boolean().nullable().optional(), + Width: z.number().int().nullable().optional(), + Height: z.number().int().nullable().optional(), + CameraMake: z.string().nullable().optional(), + CameraModel: z.string().nullable().optional(), + Software: z.string().nullable().optional(), + ExposureTime: z.number().nullable().optional(), + FocalLength: z.number().nullable().optional(), + // ImageOrientation: ImageOrientation.nullable().optional(), + Aperture: z.number().nullable().optional(), + ShutterSpeed: z.number().nullable().optional(), + Latitude: z.number().nullable().optional(), + Longitude: z.number().nullable().optional(), + Altitude: z.number().nullable().optional(), + IsoSpeedRating: z.number().int().nullable().optional(), + SeriesTimerId: z.string().nullable().optional(), + ProgramId: z.string().nullable().optional(), + ChannelPrimaryImageTag: z.string().nullable().optional(), + StartDate: z.string().datetime({ offset: true }).nullable().optional(), + CompletionPercentage: z.number().nullable().optional(), + IsRepeat: z.boolean().nullable().optional(), + EpisodeTitle: z.string().nullable().optional(), + // ChannelType: ChannelType.nullable().optional(), + // Audio: ProgramAudio.nullable().optional(), + IsMovie: z.boolean().nullable().optional(), + IsSports: z.boolean().nullable().optional(), + IsSeries: z.boolean().nullable().optional(), + IsLive: z.boolean().nullable().optional(), + IsNews: z.boolean().nullable().optional(), + IsKids: z.boolean().nullable().optional(), + IsPremiere: z.boolean().nullable().optional(), + TimerId: z.string().nullable().optional(), + NormalizationGain: z.number().nullable().optional(), + // CurrentProgram: BaseItemDto.nullable().optional(), +}); +// ); + +export type JellyfinItem = z.infer; + +// export const JellyfinLibraryItem = z.object({ +// Name: z.string(), +// Id: z.string(), +// Etag: z.string().nullable().optional(), +// // We should always request this +// Path: z.string().nullable().optional(), +// OfficialRating: z.string().nullable().optional(), +// DateCreated: z.string().nullable().optional(), +// CommunityRating: z.number().nullable().optional(), +// RunTimeTicks: z.number(), +// Genres: z.array(z.string()).nullable().optional(), +// Tags: z.array(z.string()).nullable().optional(), +// ProductionYear: z.number().nullable().optional(), +// ProviderIds: z.object({ +// Imdb: z.string().nullable().optional(), +// Tmdb: z.string().nullable().optional(), +// TmdbCollection: z.string().nullable().optional(), +// Tvdb: z.string().nullable().optional(), +// }), +// PremiereDate: z.string().nullable().optional(), +// MediaStreams: z.array(JellyfinMediaStream).nullable().optional(), +// LocationType: z.string(), +// Overview: z.string(), +// Taglines: z.array(z.string()).nullable().optional(), +// Studios: z.array(JellyfinJoinItem).nullable().optional(), +// People: z.array(JellyfinPerson).nullable().optional(), +// ImageTags: z +// .object({ +// Primary: z.string().nullable().optional(), +// Logo: z.string().nullable().optional(), +// Thumb: z.string().nullable().optional(), +// }) +// .nullable().optional(), +// BackdropImageTags: z.array(z.string()).nullable().optional(), +// IndexNumber: z.number().nullable().optional(), +// Type: z.string(), +// Chapters: z.array(JellyfinChapter).nullable().optional(), +// }); + +export const JellyfinLibraryItemsResponse = z.object({ + Items: z.array(JellyfinItem), + TotalRecordCount: z.number(), + StartIndex: z.number().nullable().optional(), +}); + +export type JellyfinLibraryItemsResponse = z.infer< + typeof JellyfinLibraryItemsResponse +>; + +const JellyfinSessionInfo = z + .object({ + // PlayState: PlayerStateInfo.nullable().optional(), + // AdditionalUsers: z.array(SessionUserInfo).nullable().optional(), + // Capabilities: ClientCapabilities.nullable().optional(), + RemoteEndPoint: z.string().nullable().optional(), + PlayableMediaTypes: z.array(JellyfinMediaType).nullable().optional(), + Id: z.string().nullable().optional(), + UserId: z.string(), + UserName: z.string().nullable().optional(), + Client: z.string().nullable().optional(), + LastActivityDate: z.string().datetime({ offset: true }), + LastPlaybackCheckIn: z.string().datetime({ offset: true }), + LastPausedDate: z.string().datetime({ offset: true }).nullable().optional(), + DeviceName: z.string().nullable().optional(), + DeviceType: z.string().nullable().optional(), + // NowPlayingItem: BaseItemDto.nullable().optional(), + // NowViewingItem: BaseItemDto.nullable().optional(), + DeviceId: z.string().nullable().optional(), + ApplicationVersion: z.string().nullable().optional(), + // TranscodingInfo: TranscodingInfo.nullable().optional(), + IsActive: z.boolean(), + SupportsMediaControl: z.boolean(), + SupportsRemoteControl: z.boolean(), + // NowPlayingQueue: z.array(QueueItem).nullable().optional(), + // NowPlayingQueueFullItems: z.array(BaseItemDto).nullable().optional(), + HasCustomDeviceName: z.boolean(), + PlaylistItemId: z.string().nullable().optional(), + ServerId: z.string().nullable().optional(), + UserPrimaryImageTag: z.string().nullable().optional(), + // SupportedCommands: z.array(GeneralCommandType).nullable().optional(), + }) + .partial(); + +export const JellyfinUserConfiguration = z + .object({ + AudioLanguagePreference: z.string().nullable().optional(), + PlayDefaultAudioTrack: z.boolean(), + SubtitleLanguagePreference: z.string().nullable().optional(), + DisplayMissingEpisodes: z.boolean(), + GroupedFolders: z.array(z.string()), + // SubtitleMode: SubtitlePlaybackMode, + DisplayCollectionsView: z.boolean(), + EnableLocalPassword: z.boolean(), + OrderedViews: z.array(z.string()), + LatestItemsExcludes: z.array(z.string()), + MyMediaExcludes: z.array(z.string()), + HidePlayedInLatest: z.boolean(), + RememberAudioSelections: z.boolean(), + RememberSubtitleSelections: z.boolean(), + EnableNextEpisodeAutoPlay: z.boolean(), + CastReceiverId: z.string().nullable().optional(), + }) + .partial(); + +export const JellyfinUser = z + .object({ + Name: z.string().nullable().optional(), + ServerId: z.string().nullable().optional(), + ServerName: z.string().nullable().optional(), + Id: z.string(), + PrimaryImageTag: z.string().nullable().optional(), + HasPassword: z.boolean(), + HasConfiguredPassword: z.boolean(), + HasConfiguredEasyPassword: z.boolean(), + EnableAutoLogin: z.boolean().nullable().optional(), + LastLoginDate: z.string().datetime({ offset: true }).nullable().optional(), + LastActivityDate: z + .string() + .datetime({ offset: true }) + .nullable() + .optional(), + Configuration: JellyfinUserConfiguration.nullable().optional(), + // Policy: UserPolicy.nullable().optional(), + PrimaryImageAspectRatio: z.number().nullable().optional(), + }) + .partial(); + +export const JellyfinAuthenticationResult = z + .object({ + User: JellyfinUser.nullable().optional(), + SessionInfo: JellyfinSessionInfo.nullable().optional(), + AccessToken: z.string().nullable().optional(), + ServerId: z.string().nullable().optional(), + }) + .partial(); + +export const JellyfinSystemInfo = z + .object({ + LocalAddress: z.string().nullable().optional(), + ServerName: z.string().nullable().optional(), + Version: z.string().nullable().optional(), + ProductName: z.string().nullable().optional(), + OperatingSystem: z.string().nullable().optional(), + Id: z.string().nullable().optional(), + StartupWizardCompleted: z.boolean().nullable().optional(), + OperatingSystemDisplayName: z.string().nullable().optional(), + PackageName: z.string().nullable().optional(), + HasPendingRestart: z.boolean(), + IsShuttingDown: z.boolean(), + SupportsLibraryMonitor: z.boolean(), + WebSocketPortNumber: z.number().int(), + // CompletedInstallations: z.array(InstallationInfo).nullable().optional(), + CanSelfRestart: z.boolean().default(true), + CanLaunchWebBrowser: z.boolean().default(false), + ProgramDataPath: z.string().nullable().optional(), + WebPath: z.string().nullable().optional(), + ItemsByNamePath: z.string().nullable().optional(), + CachePath: z.string().nullable().optional(), + LogPath: z.string().nullable().optional(), + InternalMetadataPath: z.string().nullable().optional(), + TranscodingTempPath: z.string().nullable().optional(), + // CastReceiverApplications: z.array(CastReceiverApplication).nullable().optional(), + HasUpdateAvailable: z.boolean().default(false), + EncoderLocation: z.string().nullable().optional().default('System'), + SystemArchitecture: z.string().nullable().optional().default('X64'), + }) + .partial(); + +export function isTerminalJellyfinItem(item: JellyfinItem): boolean { + return ['Movie', 'Episode', 'Audio'].includes(item.Type); +} + +export function isJellyfinType( + item: JellyfinItem, + types: JellyfinItemKind[], +): boolean { + return types.includes(item.Type); +} diff --git a/types/src/plex/index.ts b/types/src/plex/index.ts index 02c8ba03..27e9105b 100644 --- a/types/src/plex/index.ts +++ b/types/src/plex/index.ts @@ -1,4 +1,5 @@ import z from 'zod'; +import { FindChild } from '../util.js'; export * from './dvr.js'; @@ -757,21 +758,12 @@ export type PlexMetadataType< T extends { Metadata: M[] } = { Metadata: M[] }, > = T['Metadata'][0]; -type FindChild0 = Arr extends [ - [infer Head, infer Child], - ...infer Tail, -] - ? Head extends Target - ? Child - : FindChild0 - : never; - export type PlexChildMediaType = Target extends PlexTerminalMedia ? Target - : FindChild0; + : FindChild; -export type PlexChildMediaApiType = FindChild0< +export type PlexChildMediaApiType = FindChild< Target, PlexMediaApiChildType >; diff --git a/types/src/schemas/programmingSchema.ts b/types/src/schemas/programmingSchema.ts index 4c5bfc85..4a68f356 100644 --- a/types/src/schemas/programmingSchema.ts +++ b/types/src/schemas/programmingSchema.ts @@ -9,6 +9,7 @@ import { PlexMusicTrackSchema, } from '../plex/index.js'; import { ChannelIconSchema, ExternalIdSchema } from './utilSchemas.js'; +import { JellyfinItem } from '../jellyfin/index.js'; export const ProgramTypeSchema = z.union([ z.literal('movie'), @@ -19,7 +20,10 @@ export const ProgramTypeSchema = z.union([ z.literal('flex'), ]); -export const ExternalSourceTypeSchema = z.literal('plex'); +export const ExternalSourceTypeSchema = z.union([ + z.literal('plex'), + z.literal('jellyfin'), +]); export const ProgramSchema = z.object({ artistName: z.string().optional(), @@ -80,17 +84,30 @@ export const RedirectProgramSchema = BaseProgramSchema.extend({ channelName: z.string(), }); +const OriginalProgramSchema = z.discriminatedUnion('sourceType', [ + z.object({ + sourceType: z.literal('plex'), + program: z.discriminatedUnion('type', [ + PlexEpisodeSchema, + PlexMovieSchema, + PlexMusicTrackSchema, + ]), + }), + z.object({ + sourceType: z.literal('jellyfin'), + program: JellyfinItem, + }), +]); + +export type ContentProgramOriginalProgram = z.infer< + typeof OriginalProgramSchema +>; + export const CondensedContentProgramSchema = BaseProgramSchema.extend({ type: z.literal('content'), id: z.string().optional(), // Populated if persisted // Only populated on client requests to the server - originalProgram: z - .discriminatedUnion('type', [ - PlexEpisodeSchema, - PlexMovieSchema, - PlexMusicTrackSchema, - ]) - .optional(), + originalProgram: OriginalProgramSchema.optional(), }); export const ContentProgramTypeSchema = z.union([ diff --git a/types/src/schemas/settingsSchemas.ts b/types/src/schemas/settingsSchemas.ts index 0b8854ac..4e5e2e53 100644 --- a/types/src/schemas/settingsSchemas.ts +++ b/types/src/schemas/settingsSchemas.ts @@ -1,6 +1,6 @@ import z from 'zod'; import { ResolutionSchema } from './miscSchemas.js'; -import { TupleToUnion } from '../util.js'; +import { Tag, TupleToUnion } from '../util.js'; import { ScheduleSchema } from './utilSchemas.js'; export const XmlTvSettingsSchema = z.object({ @@ -88,19 +88,42 @@ export const FfmpegSettingsSchema = z.object({ disableChannelPrelude: z.boolean().default(false), }); -export const PlexServerSettingsSchema = z.object({ - id: z.string(), +const mediaSourceId = z.custom((val) => { + return typeof val === 'string'; +}); + +export type MediaSourceId = Tag; + +const BaseMediaSourceSettingsSchema = z.object({ + id: mediaSourceId, name: z.string(), uri: z.string(), accessToken: z.string(), +}); + +export const PlexServerSettingsSchema = BaseMediaSourceSettingsSchema.extend({ + type: z.literal('plex'), sendGuideUpdates: z.boolean(), sendChannelUpdates: z.boolean(), index: z.number(), clientIdentifier: z.string().optional(), }); +export const JellyfinServerSettingsSchema = + BaseMediaSourceSettingsSchema.extend({ + type: z.literal('jellyfin'), + }); + +export const MediaSourceSettingsSchema = z.discriminatedUnion('type', [ + PlexServerSettingsSchema, + JellyfinServerSettingsSchema, +]); + export const PlexStreamSettingsSchema = z.object({ - streamPath: z.union([z.literal('plex'), z.literal('direct')]).default('plex'), + // Plex is deprecated here + streamPath: z + .union([z.literal('plex'), z.literal('direct'), z.literal('network')]) + .default('network'), enableDebugLogging: z.boolean().default(false), directStreamBitrate: z.number().default(20000), transcodeBitrate: z.number().default(2000), diff --git a/types/src/schemas/utilSchemas.ts b/types/src/schemas/utilSchemas.ts index 846cf5d9..67383e7c 100644 --- a/types/src/schemas/utilSchemas.ts +++ b/types/src/schemas/utilSchemas.ts @@ -9,6 +9,7 @@ export const ExternalIdType = [ 'imdb', 'tmdb', 'tvdb', + 'jellyfin', ] as const; export type ExternalIdType = TupleToUnion; @@ -26,7 +27,7 @@ export const SingleExternalIdSourceSchema = constructZodLiteralUnionType( SingleExternalIdType.map((typ) => z.literal(typ)), ); -export const MultiExternalIdType = ['plex'] as const; +export const MultiExternalIdType = ['plex', 'jellyfin'] as const; export type MultiExternalIdType = TupleToUnion; function inConstArr( @@ -64,7 +65,10 @@ export const SingleExternalIdSchema = z.object({ }); // When we have more sources, this will be a union -export const MultiExternalSourceSchema = z.literal('plex'); +export const MultiExternalSourceSchema = z.union([ + z.literal('plex'), + z.literal('jellyfin'), +]); // Represents components of an ID that can be // used to address an object (program or grouping) in diff --git a/types/src/util.ts b/types/src/util.ts index 9cc986ae..51902863 100644 --- a/types/src/util.ts +++ b/types/src/util.ts @@ -14,3 +14,16 @@ export const tag = < ): UTag => x as unknown as UTag; export type TupleToUnion> = T[number]; + +/** + * Given a type of an array of 2-tuples representing "parent" and "child", + * finds the matching "child" given the type of the "parent". + */ +export type FindChild = Arr extends [ + [infer Head, infer Child], + ...infer Tail, +] + ? Head extends Target + ? Child + : FindChild + : never; diff --git a/types/tsup.config.ts b/types/tsup.config.ts index 12d89f6c..d4fe6606 100644 --- a/types/tsup.config.ts +++ b/types/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'schemas/index': 'src/schemas/index.ts', 'plex/index': 'src/plex/index.ts', 'api/index': 'src/api/index.ts', + 'jellyfin/index': 'src/jellyfin/index.ts', }, format: 'esm', dts: true, diff --git a/web/package.json b/web/package.json index 4a590918..fc621fb9 100644 --- a/web/package.json +++ b/web/package.json @@ -73,6 +73,7 @@ "ts-essentials": "^9.4.1", "typescript": "5.4.3", "vite": "^5.4.1", + "vite-plugin-svgr": "^4.2.0", "vitest": "^2.0.5" } } diff --git a/web/src/assets/jellyfin.svg b/web/src/assets/jellyfin.svg new file mode 100644 index 00000000..be8668ad --- /dev/null +++ b/web/src/assets/jellyfin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/components/GridContainerTabPanel.tsx b/web/src/components/GridContainerTabPanel.tsx new file mode 100644 index 00000000..6d856ff4 --- /dev/null +++ b/web/src/components/GridContainerTabPanel.tsx @@ -0,0 +1,49 @@ +import { Unstable_Grid2 as Grid } from '@mui/material'; +import { ForwardedRef, forwardRef } from 'react'; +import useStore from '../store'; + +type TabPanelProps = { + children?: React.ReactNode; + index: number; + value: number; + ref?: React.RefObject; +}; + +export const GridContainerTabPanel = forwardRef( + (props: TabPanelProps, ref: ForwardedRef) => { + const { children, value, index, ...other } = props; + + const viewType = useStore((state) => state.theme.programmingSelectorView); + + return ( + + ); + }, +); diff --git a/web/src/components/InlineModal.tsx b/web/src/components/InlineModal.tsx index ff46915f..e84c5852 100644 --- a/web/src/components/InlineModal.tsx +++ b/web/src/components/InlineModal.tsx @@ -1,60 +1,62 @@ +import { + useCurrentMediaSource, + useKnownMedia, +} from '@/store/programmingSelector/selectors.ts'; import { Box, Collapse, List } from '@mui/material'; -import { PlexMedia, isPlexMedia, isPlexParentItem } from '@tunarr/types/plex'; +import { MediaSourceSettings } from '@tunarr/types'; import { usePrevious } from '@uidotdev/usehooks'; -import _ from 'lodash-es'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useIntersectionObserver } from 'usehooks-ts'; +import { chain, first } from 'lodash-es'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useBoolean, useIntersectionObserver } from 'usehooks-ts'; import { extractLastIndexes, findFirstItemInNextRowIndex, getEstimatedModalHeight, getImagesPerRow, } from '../helpers/inlineModalUtil'; -import { toggle } from '../helpers/util.ts'; import useStore from '../store'; -import { PlexGridItem } from './channel_config/PlexGridItem'; +import { GridInlineModalProps } from './channel_config/MediaItemGrid.tsx'; -type InlineModalProps = { - itemGuid: string; - modalIndex: number; - open?: boolean; - rowSize: number; - type: PlexMedia['type'] | 'all'; -}; +interface InlineModalProps + extends GridInlineModalProps { + getItemType: (item: ItemType) => ItemKind; // Tmp change //PlexMedia['type'] | 'all'; + getChildItemType: (item: ItemType) => ItemKind; + sourceType: MediaSourceSettings['type']; + extractItemId: (item: ItemType) => string; +} -export function InlineModal(props: InlineModalProps) { - const { itemGuid, modalIndex, open, rowSize, type } = props; +export function InlineModal( + props: InlineModalProps, +) { + const { + modalItemGuid: itemGuid, + modalIndex, + open, + rowSize, + getItemType, + getChildItemType, + extractItemId, + renderChildren, + } = props; const previousItemGuid = usePrevious(itemGuid); const [containerWidth, setContainerWidth] = useState(0); const [itemWidth, setItemWidth] = useState(0); - const [isOpen, setIsOpen] = useState(false); + const { + value: isOpen, + setTrue: setOpen, + setFalse: setClosed, + } = useBoolean(false); const ref = useRef(null); const gridItemRef = useRef(null); const inlineModalRef = useRef(null); const darkMode = useStore((state) => state.theme.darkMode); const [childLimit, setChildLimit] = useState(9); const [imagesPerRow, setImagesPerRow] = useState(0); - const modalChildren: PlexMedia[] = useStore((s) => { - const known = s.contentHierarchyByServer[s.currentServer!.name]; - if (known) { - const children = known[itemGuid]; - if (children) { - return _.chain(children) - .map((id) => s.knownMediaByServer[s.currentServer!.name][id]) - .compact() - .filter(isPlexMedia) - .value(); - } - } - - return []; - }); + const currentMediaSource = useCurrentMediaSource(props.sourceType); + const knownMedia = useKnownMedia(); + const modalChildren = knownMedia + .getChildren(currentMediaSource!.id, itemGuid ?? '') + .map((media) => media.item) as ItemType[]; const modalHeight = useMemo( () => @@ -63,15 +65,11 @@ export function InlineModal(props: InlineModalProps) { containerWidth, itemWidth, modalChildren.length, - type, + first(modalChildren) ? getItemType(first(modalChildren)!) : 'unknown', ), - [containerWidth, itemWidth, modalChildren?.length, rowSize, type], + [containerWidth, itemWidth, modalChildren, rowSize, getItemType], ); - const toggleModal = useCallback(() => { - setIsOpen(toggle); - }, []); - useEffect(() => { if (ref.current && previousItemGuid !== itemGuid) { const containerWidth = ref?.current?.getBoundingClientRect().width || 0; @@ -82,11 +80,17 @@ export function InlineModal(props: InlineModalProps) { setItemWidth(itemWidth); setContainerWidth(containerWidth); setImagesPerRow(imagesPerRow); + setChildModalInfo({ childItemGuid: '', childModalIndex: -1 }); } }, [ref, gridItemRef, previousItemGuid, itemGuid]); - const [childItemGuid, setChildItemGuid] = useState(null); - const [childModalIndex, setChildModalIndex] = useState(-1); + const [{ childModalIndex, childItemGuid }, setChildModalInfo] = useState<{ + childItemGuid: string | null; + childModalIndex: number; + }>({ + childItemGuid: null, + childModalIndex: -1, + }); const firstItemInNextRowIndex = useMemo( () => @@ -98,10 +102,25 @@ export function InlineModal(props: InlineModalProps) { [childModalIndex, modalChildren?.length, rowSize], ); - const handleMoveModal = useCallback((index: number, item: PlexMedia) => { - setChildItemGuid((prev) => (prev === item.guid ? null : item.guid)); - setChildModalIndex((prev) => (prev === index ? -1 : index)); - }, []); + const handleMoveModal = useCallback( + (index: number, item: ItemType) => { + const id = extractItemId(item); + setChildModalInfo((prev) => { + if (prev.childItemGuid === id) { + return { + childItemGuid: null, + childModalIndex: -1, + }; + } else { + return { + childItemGuid: id, + childModalIndex: index, + }; + } + }); + }, + [extractItemId], + ); // TODO: Complete this by updating the limit below, not doing this // right now because already working with a huge changeset. @@ -125,10 +144,52 @@ export function InlineModal(props: InlineModalProps) { ).includes(childModalIndex) : false; + const renderChild = useCallback( + (idx: number, item: ItemType) => { + return renderChildren( + { + index: idx, + item: item, + isModalOpen: modalIndex === idx, + moveModal: handleMoveModal, + ref: gridItemRef, + }, + { + modalItemGuid: childItemGuid ?? '', + modalIndex: childModalIndex, + open: idx === firstItemInNextRowIndex, + renderChildren, + rowSize: rowSize, + }, + ); + }, + [ + childItemGuid, + childModalIndex, + firstItemInNextRowIndex, + handleMoveModal, + modalIndex, + renderChildren, + rowSize, + ], + ); + const show = useCallback(() => { + setOpen(); + }, [setOpen]); + + const hide = useCallback(() => { + setClosed(); + }, [setClosed]); + return ( - {_.chain(modalChildren) - .filter(isPlexMedia) - .take(childLimit) - .map((child: PlexMedia, idx: number) => ( - - {isPlexParentItem(child) && ( - - )} - - - )) - .value()} - {/* This Modal is for last row items because they can't be inserted using the above inline modal */} - -
  • + {isOpen && ( + <> + {chain(modalChildren) + .take(childLimit) + .map((item, idx) => renderChild(idx, item)) + .value()} + + {childLimit < modalChildren.length && ( +
  • + )} + + )}
    diff --git a/web/src/components/ProgramDetailsDialog.tsx b/web/src/components/ProgramDetailsDialog.tsx index 6a3ea680..82a9c6c8 100644 --- a/web/src/components/ProgramDetailsDialog.tsx +++ b/web/src/components/ProgramDetailsDialog.tsx @@ -1,3 +1,6 @@ +import JellyfinIcon from '@/assets/jellyfin.svg?react'; +import PlexIcon from '@/assets/plex.svg?react'; +import { Maybe } from '@/types/util.ts'; import { Close as CloseIcon, OpenInNew } from '@mui/icons-material'; import { Box, @@ -9,15 +12,20 @@ import { IconButton, Skeleton, Stack, + SvgIcon, Typography, useMediaQuery, useTheme, } from '@mui/material'; import { createExternalId } from '@tunarr/shared'; import { forProgramType } from '@tunarr/shared/util'; -import { ChannelProgram, TvGuideProgram } from '@tunarr/types'; +import { + ChannelProgram, + TvGuideProgram, + isContentProgram, +} from '@tunarr/types'; import dayjs, { Dayjs } from 'dayjs'; -import { isUndefined } from 'lodash-es'; +import { capitalize, compact, find, isUndefined } from 'lodash-es'; import { ReactEventHandler, useCallback, @@ -94,9 +102,10 @@ export default function ProgramDetailsDialog({ forProgramType({ content: (program) => ( ), }), @@ -107,12 +116,83 @@ export default function ProgramDetailsDialog({ (program: ChannelProgram) => { const ratingString = rating(program); return ratingString ? ( - + ) : null; }, [rating], ); + const sourceChip = useCallback((program: ChannelProgram) => { + if (isContentProgram(program)) { + const id = find( + program.externalIds, + (eid) => + eid.type === 'multi' && + (eid.source === 'jellyfin' || eid.source === 'plex'), + ); + if (!id) { + return null; + } + + let icon: Maybe = undefined; + switch (id.source) { + case 'jellyfin': + icon = ; + break; + case 'plex': + icon = ; + break; + default: + break; + } + + if (icon) { + return ( + {icon}} + label={capitalize(id.source)} + sx={{ mr: 1, mt: 1 }} + /> + ); + } + } + + return null; + }, []); + + const timeChip = () => { + if (start && stop) { + return ( + + ); + } + + return null; + }; + + const chips = (program: ChannelProgram) => { + return compact([ + durationChip(program), + ratingChip(program), + timeChip(), + sourceChip(program), + ]); + }; + const thumbnailImage = useMemo( () => forProgramType({ @@ -131,16 +211,35 @@ export default function ProgramDetailsDialog({ } let key = p.uniqueId; - if ( - p.subtype === 'track' && - p.originalProgram?.type === 'track' && - isNonEmptyString(p.originalProgram.parentRatingKey) - ) { - key = createExternalId( - 'plex', - p.externalSourceName!, - p.originalProgram.parentRatingKey, - ); + if (p.subtype === 'track' && p.originalProgram) { + switch (p.originalProgram.sourceType) { + case 'plex': { + if ( + p.originalProgram.program.type === 'track' && + isNonEmptyString(p.originalProgram.program.parentRatingKey) + ) { + key = createExternalId( + p.originalProgram?.sourceType, + p.externalSourceName!, + p.originalProgram.program.parentRatingKey, + ); + } + break; + } + case 'jellyfin': { + if ( + p.originalProgram.program.Type === 'Audio' && + isNonEmptyString(p.originalProgram.program.AlbumId) + ) { + key = createExternalId( + p.originalProgram.sourceType, + p.externalSourceName!, + p.originalProgram.program.AlbumId, + ); + } + break; + } + } } return `${settings.backendUri}/api/metadata/external?id=${key}&mode=proxy&asset=thumb`; @@ -181,8 +280,33 @@ export default function ProgramDetailsDialog({ const isEpisode = program && program.type === 'content' && program.subtype === 'episode'; const imageWidth = smallViewport ? (isEpisode ? '100%' : '55%') : 240; - const programStart = dayjs(start); - const programEnd = dayjs(stop); + + let externalSourceName: string = ''; + if (program) { + switch (program.type) { + case 'content': { + const eid = find( + program.externalIds, + (eid) => + eid.type === 'multi' && + (eid.source === 'plex' || eid.source === 'jellyfin'), + ); + if (eid) { + switch (eid.source) { + case 'plex': + externalSourceName = 'Plex'; + break; + case 'jellyfin': + externalSourceName = 'Jellyfin'; + break; + } + } + break; + } + default: + break; + } + } return ( program && ( @@ -206,17 +330,7 @@ export default function ProgramDetailsDialog({ - - {durationChip(program)} - {ratingChip(program)} - - + {chips(program)} )} @@ -267,7 +387,7 @@ export default function ProgramDetailsDialog({ width={imageWidth} /> )} - {externalUrl && ( + {externalUrl && isNonEmptyString(externalSourceName) && ( )} diff --git a/web/src/components/TabPanel.tsx b/web/src/components/TabPanel.tsx index cb581f3f..a30ed758 100644 --- a/web/src/components/TabPanel.tsx +++ b/web/src/components/TabPanel.tsx @@ -1,51 +1,22 @@ -import { Unstable_Grid2 as Grid } from '@mui/material'; -import { ForwardedRef, forwardRef } from 'react'; -import useStore from '../store'; - -type TabPanelProps = { +interface TabPanelProps { children?: React.ReactNode; index: number; value: number; - ref?: React.RefObject; -}; +} -const CustomTabPanel = forwardRef( - (props: TabPanelProps, ref: ForwardedRef) => { - const { children, value, index, ...other } = props; +export function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; - const viewType = useStore((state) => state.theme.programmingSelectorView); - - return ( - - ); - }, -); - -export default CustomTabPanel; + return ( + + ); +} diff --git a/web/src/components/channel_config/AddSelectedMediaButton.tsx b/web/src/components/channel_config/AddSelectedMediaButton.tsx index 650c957c..39eef9ed 100644 --- a/web/src/components/channel_config/AddSelectedMediaButton.tsx +++ b/web/src/components/channel_config/AddSelectedMediaButton.tsx @@ -13,6 +13,8 @@ import useStore from '../../store/index.ts'; import { clearSelectedMedia } from '../../store/programmingSelector/actions.ts'; import { CustomShowSelectedMedia } from '../../store/programmingSelector/store.ts'; import { AddedCustomShowProgram, AddedMedia } from '../../types/index.ts'; +import { useKnownMedia } from '@/store/programmingSelector/selectors.ts'; +import { enumerateJellyfinItem } from '@/hooks/jellyfin/jellyfinHookUtil.ts'; type Props = { onAdd: (items: AddedMedia[]) => void; @@ -29,7 +31,7 @@ export default function AddSelectedMediaButton({ ...rest }: Props) { const apiClient = useTunarrApi(); - const knownMedia = useStore((s) => s.knownMediaByServer); + const knownMedia = useKnownMedia(); const selectedMedia = useStore((s) => s.selectedMedia); const [isLoading, setIsLoading] = useState(false); @@ -41,14 +43,45 @@ export default function AddSelectedMediaButton({ selectedMedia, forSelectedMediaType>({ plex: async (selected) => { - const media = knownMedia[selected.server][selected.guid]; + const media = knownMedia.getMediaOfType( + selected.serverId, + selected.id, + 'plex', + ); + + if (!media) { + return []; + } + const items = await enumeratePlexItem( apiClient, - selected.server, + selected.serverId, + selected.serverName, media, )(); + return map(items, (item) => ({ media: item, type: 'plex' })); }, + jellyfin: async (selected) => { + const media = knownMedia.getMediaOfType( + selected.serverId, + selected.id, + 'jellyfin', + ); + + if (!media) { + return []; + } + + const items = await enumerateJellyfinItem( + apiClient, + selected.serverId, + selected.serverName, + media, + )(); + + return map(items, (item) => ({ media: item, type: 'jellyfin' })); + }, 'custom-show': ( selected: CustomShowSelectedMedia, ): Promise => { diff --git a/web/src/components/channel_config/ChannelProgrammingList.tsx b/web/src/components/channel_config/ChannelProgrammingList.tsx index 8ab309e1..eaf91ce2 100644 --- a/web/src/components/channel_config/ChannelProgrammingList.tsx +++ b/web/src/components/channel_config/ChannelProgrammingList.tsx @@ -340,7 +340,7 @@ export default function ChannelProgrammingList({ const [focusedProgramDetails, setFocusedProgramDetails] = useState< ChannelProgram | undefined >(); - const [startStop, setStartStop] = useState({}); + const [, setStartStop] = useState({}); const [editProgram, setEditProgram] = useState< ((UIFlexProgram | UIRedirectProgram) & { index: number }) | undefined >(); @@ -474,8 +474,6 @@ export default function ChannelProgrammingList({ open={!isUndefined(focusedProgramDetails)} onClose={() => setFocusedProgramDetails(undefined)} program={focusedProgramDetails} - start={startStop.start} - stop={startStop.stop} /> {} + +const extractChildCount = forJellyfinItem({ + Season: (s) => s.ChildCount, + Series: (s) => s.ChildCount, + CollectionFolder: (s) => s.ChildCount, + Playlist: (s) => s.ChildCount, + default: 0, +}); + +const childItemType = forJellyfinItem({ + Season: 'episode', + Series: 'season', + CollectionFolder: 'item', + Playlist: (pl) => (pl.MediaType === 'Audio' ? 'track' : 'video'), + MusicArtist: 'album', + MusicAlbum: 'track', +}); + +const childJellyfinType = forJellyfinItem({ + Season: 'Episode', + Series: 'Season', +}); + +const subtitle = forJellyfinItem({ + Movie: (item) => ( + {prettyItemDuration((item.RunTimeTicks ?? 0) / 10_000)} + ), + default: (item) => { + const childCount = extractChildCount(item); + if (isNil(childCount)) { + return null; + } + + return ( + {`${childCount} ${pluralize( + childItemType(item) ?? 'item', + childCount, + )}`} + ); + }, +}); + +export const JellyfinGridItem = memo( + forwardRef( + (props: JellyfinGridItemProps, ref: ForwardedRef) => { + const { item, index, moveModal } = props; + const [modalOpen, setModalOpen] = useState(false); + const currentServer = useCurrentMediaSource('jellyfin'); + + const isMusicItem = useCallback( + (item: JellyfinItem) => + ['MusicArtist', 'MusicAlbum', 'Audio'].includes(item.Type), + [], + ); + + const isEpisode = useCallback( + (item: JellyfinItem) => item.Type === 'Episode', + [], + ); + + const hasChildren = ['Series', 'Season'].includes(item.Type); + const childKind = childJellyfinType(item); + + useJellyfinLibraryItems( + currentServer!.id, + item.Id, + childKind ? [childKind] : [], + null, + hasChildren && modalOpen, + ); + + const moveModalToItem = useCallback(() => { + moveModal(index, item); + }, [index, item, moveModal]); + + const handleItemClick = useCallback(() => { + setModalOpen(toggle); + moveModalToItem(); + }, [moveModalToItem]); + + const thumbnailUrlFunc = useCallback( + (item: JellyfinItem) => { + return `${currentServer?.uri}/Items/${ + item.Id + }/Images/Primary?fillHeight=300&fillWidth=200&quality=96&tag=${ + (item.ImageTags ?? {})['Primary'] + }`; + }, + [currentServer], + ); + + const selectedMediaFunc = useCallback( + (item: JellyfinItem): SelectedMedia => { + return { + type: 'jellyfin', + serverId: currentServer!.id, + serverName: currentServer!.name, + childCount: extractChildCount(item), + id: item.Id, + }; + }, + [currentServer], + ); + + const metadata = useMemo( + () => ({ + itemId: item.Id, + isPlaylist: item.Type === 'Playlist', + hasThumbnail: isNonEmptyString((item.ImageTags ?? {})['Primary']), + childCount: extractChildCount(item), + isMusicItem: isMusicItem(item), + isEpisode: isEpisode(item), + title: item.Name ?? '', + subtitle: subtitle(item), + thumbnailUrl: thumbnailUrlFunc(item), + selectedMedia: selectedMediaFunc(item), + }), + [isEpisode, isMusicItem, item, selectedMediaFunc, thumbnailUrlFunc], + ); + + return ( + currentServer && ( + addJellyfinSelectedMedia(currentServer, item)} + /> + ) + ); + }, + ), + (prev, next) => { + // if (!isEqual(prev, next)) { + // console.log(prev, next); + // } + return isEqual(prev, next); + }, +); diff --git a/web/src/components/channel_config/JellyfinListItem.tsx b/web/src/components/channel_config/JellyfinListItem.tsx new file mode 100644 index 00000000..40bf7922 --- /dev/null +++ b/web/src/components/channel_config/JellyfinListItem.tsx @@ -0,0 +1,172 @@ +import { typedProperty } from '@/helpers/util.ts'; +import { useJellyfinLibraryItems } from '@/hooks/jellyfin/useJellyfinApi.ts'; +import { + addJellyfinSelectedMedia, + addKnownMediaForJellyfinServer, + removePlexSelectedMedia, +} from '@/store/programmingSelector/actions.ts'; +import { + useCurrentMediaSource, + useSelectedMedia, +} from '@/store/programmingSelector/selectors.ts'; +import { ExpandLess, ExpandMore } from '@mui/icons-material'; +import { + Button, + Collapse, + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Skeleton, +} from '@mui/material'; +import { + JellyfinItem, + JellyfinItemKind, + isTerminalJellyfinItem, +} from '@tunarr/types/jellyfin'; +import { isNull, map } from 'lodash-es'; +import React, { MouseEvent, useCallback, useEffect, useState } from 'react'; + +export interface JellyfinListItemProps { + item: JellyfinItem; + style?: React.CSSProperties; + index?: number; + length?: number; + parent?: string; +} + +function jellyfinChildType(item: JellyfinItem): JellyfinItemKind | null { + switch (item.Type) { + case 'Audio': + case 'Episode': + case 'Movie': + return null; + case 'MusicAlbum': + return 'Audio'; + case 'MusicArtist': + return 'MusicAlbum'; + case 'MusicGenre': + return 'MusicAlbum'; + // case 'Playlist': + // case 'PlaylistsFolder': + // case 'Program': + // case 'Recording': + case 'Season': + return 'Episode'; + case 'Series': + return 'Season'; + // case 'Studio': + // case 'Trailer': + // case 'TvChannel': + // case 'TvProgram': + default: + return null; + } +} + +export function JellyfinListItem(props: JellyfinListItemProps) { + const selectedServer = useCurrentMediaSource('jellyfin')!; + const [open, setOpen] = useState(false); + const { item } = props; + const hasChildren = !isTerminalJellyfinItem(item); + const childType = jellyfinChildType(item); + const { isPending, data: children } = useJellyfinLibraryItems( + selectedServer.id, + props.item.Id, + childType ? [childType] : [], + null, + !isNull(childType) && open, + ); + // const selectedServer = useCurrentMediaSource('plex'); + // const selectedMedia = useStore((s) => + // filter(s.selectedMedia, (m): m is PlexSelectedMedia => m.type === 'plex'), + // ); + const selectedMedia = useSelectedMedia('jellyfin'); + const selectedMediaIds = map(selectedMedia, typedProperty('id')); + + const handleClick = () => { + setOpen(!open); + }; + + useEffect(() => { + if (children) { + addKnownMediaForJellyfinServer( + selectedServer.id, + children.Items, + item.Id, + ); + } + }, [item.Id, selectedServer.id, children]); + + const handleItem = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + + if (selectedMediaIds.includes(item.Id)) { + removePlexSelectedMedia(selectedServer.id, [item.Id]); + } else { + addJellyfinSelectedMedia(selectedServer, item); + } + }, + [item, selectedServer, selectedMediaIds], + ); + + const renderChildren = () => { + return isPending ? ( + + ) : ( + + {children?.Items.map((child, idx, arr) => ( + + ))} + + ); + }; + + const getSecondaryText = () => { + // if (isPlexShow(item)) { + // return `${prettyItemDuration(item.duration)} each`; + // } else if (isTerminalItem(item)) { + // return prettyItemDuration(item.duration); + // } else if (isPlexCollection(item)) { + // const childCount = parseInt(item.childCount); + // const count = isNaN(childCount) ? 0 : childCount; + // return `${count} item${count === 0 || count > 1 ? 's' : ''}`; + // } else if (isPlexMusicArtist(item)) { + // return first(item.Genre)?.tag ?? ' '; + // } else if (isPlexMusicAlbum(item)) { + // return item.year ?? ' '; + // } else { + // return ' '; + // } + return ' '; + }; + + return ( + + + {hasChildren && ( + {open ? : } + )} + + + + + {renderChildren()} + + + + ); +} diff --git a/web/src/components/channel_config/JellyfinProgrammingSelector.tsx b/web/src/components/channel_config/JellyfinProgrammingSelector.tsx new file mode 100644 index 00000000..637a8e1b --- /dev/null +++ b/web/src/components/channel_config/JellyfinProgrammingSelector.tsx @@ -0,0 +1,205 @@ +import { useInfiniteJellyfinLibraryItems } from '@/hooks/jellyfin/useJellyfinApi'; +import { + useCurrentMediaSource, + useCurrentSourceLibrary, +} from '@/store/programmingSelector/selectors'; +import React, { useCallback, useMemo, useState } from 'react'; +import { + GridInlineModalProps, + GridItemProps, + MediaItemGrid, +} from './MediaItemGrid.tsx'; +import { + Box, + Stack, + Tab, + Tabs, + ToggleButton, + ToggleButtonGroup, +} from '@mui/material'; +import { JellyfinGridItem } from './JellyfinGridItem.tsx'; +import { tag } from '@tunarr/types'; +import { MediaSourceId } from '@tunarr/types/schemas'; +import { JellyfinItem, JellyfinItemKind } from '@tunarr/types/jellyfin'; +import { InlineModal } from '../InlineModal.tsx'; +import { first } from 'lodash-es'; +import { forJellyfinItem, typedProperty } from '@/helpers/util.ts'; +import { JellyfinListItem } from './JellyfinListItem.tsx'; +import { ViewList, GridView } from '@mui/icons-material'; +import { setProgrammingSelectorViewState } from '@/store/themeEditor/actions.ts'; +import { ProgramSelectorViewType } from '@/types/index.ts'; +import useStore from '@/store/index.ts'; + +enum TabValues { + Library = 0, +} + +// TODO move this somewhere common +function isParentItem(item: JellyfinItem) { + switch (item.Type) { + // These are the currently supported item types + case 'AggregateFolder': + case 'Season': + case 'Series': + case 'CollectionFolder': + case 'MusicAlbum': + case 'MusicArtist': + case 'MusicGenre': + case 'Genre': + case 'Playlist': + case 'PlaylistsFolder': + return true; + default: + return false; + } +} + +const childJellyfinType = forJellyfinItem({ + Season: 'Episode', + Series: 'Season', + default: 'Video', +}); + +export function JellyfinProgrammingSelector() { + const viewType = useStore((state) => state.theme.programmingSelectorView); + const selectedServer = useCurrentMediaSource('jellyfin'); + const selectedLibrary = useCurrentSourceLibrary('jellyfin'); + + const [tabValue, setTabValue] = useState(TabValues.Library); + + const setViewType = (view: ProgramSelectorViewType) => { + setProgrammingSelectorViewState(view); + }; + + const handleFormat = ( + _event: React.MouseEvent, + newFormats: ProgramSelectorViewType, + ) => { + setViewType(newFormats); + }; + + const itemTypes: JellyfinItemKind[] = []; + if (selectedLibrary?.library.CollectionType) { + switch (selectedLibrary.library.CollectionType) { + case 'movies': + itemTypes.push('Movie'); + break; + case 'tvshows': + itemTypes.push('Series'); + break; + case 'music': + itemTypes.push('MusicArtist'); + break; + default: + break; + } + } + + const jellyfinItemsQuery = useInfiniteJellyfinLibraryItems( + selectedServer?.id ?? tag(''), + selectedLibrary?.library.Id ?? '', + itemTypes, + true, + 20, + ); + + const totalItems = useMemo(() => { + return first(jellyfinItemsQuery.data?.pages)?.TotalRecordCount ?? 0; + }, [jellyfinItemsQuery.data]); + + const renderGridItem = ( + gridItemProps: GridItemProps, + modalProps: GridInlineModalProps, + ) => { + const isLast = gridItemProps.index === totalItems - 1; + + const renderModal = + isParentItem(gridItemProps.item) && + ((gridItemProps.index + 1) % modalProps.rowSize === 0 || isLast); + + return ( + + + {renderModal && ( + + )} + + ); + }; + + return ( + <> + + + + + + + + + + + + setTabValue(value)} + aria-label="Jellyfin media selector tabs" + variant="scrollable" + allowScrollButtonsMobile + > + + {/* {!isUndefined(collectionsData) && + sumBy(collectionsData.pages, (page) => page.size) > 0 && ( + + )} + {!isUndefined(playlistData) && + sumBy(playlistData.pages, 'size') > 0 && ( + + )} */} + + + ({ + total: page.TotalRecordCount, + size: page.Items.length, + })} + extractItems={(page) => page.Items} + getItemKey={useCallback((item: JellyfinItem) => item.Id, [])} + renderGridItem={renderGridItem} + renderListItem={(item, index) => ( + + )} + // renderFinalRow={renderFinalRowInlineModal} + infiniteQuery={jellyfinItemsQuery} + /> + + ); +} diff --git a/web/src/components/channel_config/MediaGridItem.tsx b/web/src/components/channel_config/MediaGridItem.tsx new file mode 100644 index 00000000..65fe0b32 --- /dev/null +++ b/web/src/components/channel_config/MediaGridItem.tsx @@ -0,0 +1,249 @@ +import { + addSelectedMedia, + removeSelectedMedia, +} from '@/store/programmingSelector/actions.ts'; +import { CheckCircle, RadioButtonUnchecked } from '@mui/icons-material'; +import { + Box, + Fade, + Unstable_Grid2 as Grid, + IconButton, + ImageListItem, + ImageListItemBar, + Skeleton, + alpha, + useTheme, +} from '@mui/material'; +import { MediaSourceSettings } from '@tunarr/types'; +import { filter, isUndefined, some } from 'lodash-es'; +import React, { + ForwardedRef, + MouseEvent, + forwardRef, + useCallback, + useState, +} from 'react'; +import { useIntersectionObserver } from 'usehooks-ts'; +import useStore from '../../store/index.ts'; +import { + JellyfinSelectedMedia, + PlexSelectedMedia, + SelectedMedia, +} from '../../store/programmingSelector/store.ts'; + +export type GridItemMetadataExtractors = { + id: (item: T) => string; + isPlaylist: (item: T) => boolean; + hasThumbnail: (item: T) => boolean; + childCount: (item: T) => number | null; + isMusicItem: (item: T) => boolean; + isEpisode: (item: T) => boolean; + title: (item: T) => string; + subtitle: (item: T) => JSX.Element | string | null; + thumbnailUrl: (item: T) => string; + selectedMedia: (item: T) => SelectedMedia; +}; + +export type GridItemMetadata = { + itemId: string; + isPlaylist: boolean; + hasThumbnail: boolean; + childCount: number | null; + isMusicItem: boolean; + isEpisode: boolean; + title: string; + subtitle: JSX.Element | string | null; + thumbnailUrl: string; + selectedMedia: SelectedMedia; +}; + +type Props = { + item: T; + itemSource: MediaSourceSettings['type']; + // extractors: GridItemMetadataExtractors; + metadata: GridItemMetadata; + style?: React.CSSProperties; + index: number; + isModalOpen: boolean; + onClick: (item: T) => void; + onSelect: (item: T) => void; +}; + +const MediaGridItemInner = ( + props: Props, + ref: ForwardedRef, +) => { + // const settings = useSettings(); + const theme = useTheme(); + const skeletonBgColor = alpha( + theme.palette.text.primary, + theme.palette.mode === 'light' ? 0.11 : 0.13, + ); + // const server = useCurrentMediaSource('plex')!; // We have to have a server at this point + const darkMode = useStore((state) => state.theme.darkMode); + const { + item, + metadata: { + hasThumbnail, + thumbnailUrl, + itemId, + selectedMedia: selectedMediaItem, + isMusicItem, + isEpisode: isEpisodeItem, + title, + subtitle, + childCount, + }, + style, + isModalOpen, + onClick, + } = props; + const [imageLoaded, setImageLoaded] = useState(false); + + const selectedMedia = useStore((s) => + filter( + s.selectedMedia, + (p): p is PlexSelectedMedia | JellyfinSelectedMedia => + p.type !== 'custom-show', + ), + ); + + const handleClick = useCallback(() => { + // moveModal(index, item); + onClick(item); + }, [item, onClick]); + + const isSelected = some( + selectedMedia, + (sm) => sm.type === props.itemSource && sm.id === itemId, + ); + + const handleItem = useCallback( + (e: MouseEvent) => { + console.log('handle'); + e.stopPropagation(); + if (isSelected) { + removeSelectedMedia([selectedMediaItem]); + } else { + addSelectedMedia(selectedMediaItem); + } + }, + [isSelected, selectedMediaItem], + ); + + const { isIntersecting: isInViewport, ref: imageContainerRef } = + useIntersectionObserver({ + threshold: 0, + rootMargin: '0px', + freezeOnceVisible: true, + }); + + return ( + +
    + + isModalOpen + ? darkMode + ? theme.palette.grey[800] + : theme.palette.grey[400] + : 'transparent', + ...style, + }} + onClick={(e) => + (childCount ?? 0) === 0 ? handleItem(e) : handleClick() + } + ref={ref} + > + {isInViewport && // TODO: Eventually turn this into isNearViewport so images load before they hit the viewport + (hasThumbnail ? ( + + setImageLoaded(true)} + onError={() => setImageLoaded(true)} + /> + + + ) : ( + + ))} + ) => + handleItem(event) + } + > + {isSelected ? : } + + } + actionPosition="right" + /> + +
    +
    + ); +}; +// ); + +export const MediaGridItem = forwardRef(MediaGridItemInner) as ( + props: Props & { ref?: ForwardedRef }, +) => ReturnType; diff --git a/web/src/components/channel_config/MediaItemGrid.tsx b/web/src/components/channel_config/MediaItemGrid.tsx new file mode 100644 index 00000000..fcc00b41 --- /dev/null +++ b/web/src/components/channel_config/MediaItemGrid.tsx @@ -0,0 +1,296 @@ +import { + findLastItemInRowIndex, + getImagesPerRow, + isNewModalAbove, +} from '@/helpers/inlineModalUtil.ts'; +import useStore from '@/store/index.ts'; +import { + Box, + CircularProgress, + Divider, + Grid, + Typography, +} from '@mui/material'; +import { InfiniteData, UseInfiniteQueryResult } from '@tanstack/react-query'; +import { usePrevious } from '@uidotdev/usehooks'; +import { compact, flatMap, map, sumBy } from 'lodash-es'; +import { + ForwardedRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + useDebounceCallback, + useIntersectionObserver, + useResizeObserver, +} from 'usehooks-ts'; +import { Nullable } from '@/types/util'; + +export interface GridItemProps { + item: ItemType; + index: number; + isModalOpen: boolean; + moveModal: (index: number, item: ItemType) => void; + ref: ForwardedRef; +} + +export interface GridInlineModalProps { + open: boolean; + modalItemGuid: Nullable; + modalIndex: number; + rowSize: number; + renderChildren: ( + gridItemProps: GridItemProps, + modalProps: GridInlineModalProps, + ) => JSX.Element; +} + +type Props = { + getPageDataSize: (page: PageDataType) => { total?: number; size: number }; + extractItems: (page: PageDataType) => ItemType[]; + renderGridItem: ( + gridItemProps: GridItemProps, + modalProps: GridInlineModalProps, + ) => JSX.Element; + renderListItem: (item: ItemType, index: number) => JSX.Element; + getItemKey: (item: ItemType) => string; + infiniteQuery: UseInfiniteQueryResult>; +}; + +type Size = { + width?: number; + height?: number; +}; + +type ModalState = { + modalIndex: number; + modalGuid: Nullable; +}; + +// magic number for top bar padding; TODO: calc it off ref +const TopBarPadddingPx = 64; + +export function MediaItemGrid({ + getPageDataSize, + renderGridItem, + renderListItem, + getItemKey, + extractItems, + infiniteQuery: { + data, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + isLoading, + }, +}: Props) { + const viewType = useStore((state) => state.theme.programmingSelectorView); + const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 }); + const [rowSize, setRowSize] = useState(9); + const [{ modalIndex, modalGuid }, setModalState] = useState({ + modalGuid: null, + modalIndex: -1, + }); + const gridContainerRef = useRef(null); + const selectedModalItemRef = useRef(null); + + // We only need a single grid item ref because all grid items are the same + // width. This ref is simply used to determine the width of grid items based + // on window/container size in order to derive other details about the grid. + // If the modal is open, this ref points to the selected dmain grid element, + // otherwise, it uses the first element in the grid + const gridItemRef = useRef(null); + + const previousModalIndex = usePrevious(modalIndex); + + const [{ width }, setSize] = useState({ + width: undefined, + height: undefined, + }); + + const onResize = useDebounceCallback(setSize, 200); + + useResizeObserver({ + ref: gridContainerRef, + onResize, + }); + + useEffect(() => { + if (viewType === 'grid') { + // 16 is additional padding available in the parent container + const rowSize = getImagesPerRow( + width ? width + 16 : 0, + gridItemRef.current?.getBoundingClientRect().width ?? 0, + ); + setRowSize(rowSize); + setScrollParams(({ max }) => ({ max, limit: rowSize * 4 })); + } + }, [width, viewType, modalGuid, gridItemRef]); + + const scrollToGridItem = useCallback( + (index: number) => { + if (index === -1) { + return; + } + + const selectedElement = selectedModalItemRef.current; + const includeModalInHeightCalc = isNewModalAbove( + previousModalIndex, + index, + rowSize, + ); + + if (selectedElement) { + // New modal is opening in a row above previous modal + const modalMovesUp = selectedElement.offsetTop - TopBarPadddingPx; + // New modal is opening in the same row or a row below the current modal + const modalMovesDown = + selectedElement.offsetTop - + selectedElement.offsetHeight - + TopBarPadddingPx; + + window.scrollTo({ + top: includeModalInHeightCalc ? modalMovesDown : modalMovesUp, + behavior: 'smooth', + }); + } + }, + [previousModalIndex, rowSize], + ); + + // Scroll to new selected item when modalIndex changes + // Doing this on modalIndex change negates the need to calc inline modal height since it's collapsed at this time + useEffect(() => { + scrollToGridItem(modalIndex); + }, [modalIndex, scrollToGridItem]); + + const handleMoveModal = useCallback( + (index: number, item: ItemType) => { + const key = getItemKey(item); + setModalState((prev) => { + if (prev.modalIndex === index) { + return { modalGuid: null, modalIndex: -1 }; + } else { + return { modalGuid: key, modalIndex: index }; + } + }); + }, + [getItemKey], + ); + + useEffect(() => { + if (data?.pages.length === 1) { + const { total, size } = getPageDataSize(data.pages[0]); + const actualSize = total ?? size; + if (scrollParams.max !== actualSize) { + setScrollParams(({ limit }) => ({ + limit, + max: actualSize, + })); + } + } + }, [data?.pages, getPageDataSize, scrollParams.max]); + + const { ref } = useIntersectionObserver({ + onChange: (_, entry) => { + if (entry.isIntersecting) { + if (scrollParams.limit < scrollParams.max) { + setScrollParams(({ limit: prevLimit, max }) => ({ + max, + limit: prevLimit + rowSize * 4, + })); + } + + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage().catch(console.error); + } + } + }, + threshold: 0.5, + }); + + // InlineModals are only potentially rendered for the last item in each row + // As such, we need to find the index of the last item in a given row, relative + // to the index of the selected item, row size, and total items. + const lastItemIndex = useMemo( + () => + findLastItemInRowIndex( + modalIndex, + rowSize, + sumBy(data?.pages, (page) => getPageDataSize(page).size) ?? 0, + ), + [modalIndex, rowSize, data?.pages, getPageDataSize], + ); + + const renderItems = () => { + return map(compact(flatMap(data?.pages, extractItems)), (item, index) => { + const isOpen = index === lastItemIndex; + // const shouldAttachRef = + // (modalIndex >= 0 && modalIndex === index) || index === 0; + + return viewType === 'list' + ? renderListItem(item, index) + : renderGridItem( + { + item, + index, + isModalOpen: modalIndex === index, + moveModal: handleMoveModal, + ref: + index === 0 + ? gridItemRef + : modalIndex === index + ? selectedModalItemRef + : null, + }, + { + open: isOpen, + modalItemGuid: modalGuid, + modalIndex: modalIndex, + rowSize: rowSize, + renderChildren: renderGridItem, + }, + ); + }); + }; + + return ( + <> + + + {renderItems()} + + + {!isLoading &&
    } + {isFetchingNextPage && ( + + )} + {data && !hasNextPage && ( + + fin. + + )} + + + ); +} diff --git a/web/src/components/channel_config/PlexDirectoryListItem.tsx b/web/src/components/channel_config/PlexDirectoryListItem.tsx index 9e790f3f..45003eca 100644 --- a/web/src/components/channel_config/PlexDirectoryListItem.tsx +++ b/web/src/components/channel_config/PlexDirectoryListItem.tsx @@ -23,7 +23,7 @@ import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react'; import { usePlexTyped2 } from '../../hooks/plex/usePlex.ts'; import useStore from '../../store/index.ts'; import { - addKnownMediaForServer, + addKnownMediaForPlexServer, addPlexSelectedMedia, } from '../../store/programmingSelector/actions.ts'; import { PlexListItem } from './PlexListItem.tsx'; @@ -43,20 +43,20 @@ export function PlexDirectoryListItem(props: { PlexLibraryCollections >([ { - serverName: props.server.name, + serverId: props.server.id, path: `/library/sections/${item.key}/all`, enabled: open, }, { - serverName: props.server.name, + serverId: props.server.id, path: `/library/sections/${item.key}/collections`, enabled: open, }, ]); - const listings = useStore((s) => s.knownMediaByServer[server.name]); + const listings = useStore((s) => s.knownMediaByServer[server.id]); const hierarchy = useStore( - (s) => s.contentHierarchyByServer[server.name][item.uuid], + (s) => s.contentHierarchyByServer[server.id][item.uuid], ); const observerTarget = useRef(null); @@ -86,13 +86,13 @@ export function PlexDirectoryListItem(props: { useEffect(() => { if (children && children.Metadata) { - addKnownMediaForServer(server.name, children.Metadata, item.uuid); + addKnownMediaForPlexServer(server.id, children.Metadata, item.uuid); } if (collections && collections.Metadata) { - addKnownMediaForServer(server.name, collections.Metadata, item.uuid); + addKnownMediaForPlexServer(server.id, collections.Metadata, item.uuid); } - }, [item.uuid, item.key, server.name, children, collections]); + }, [item.uuid, item.key, server.id, children, collections]); const handleClick = () => { setLimit(Math.min(hierarchy.length, 20)); @@ -102,17 +102,17 @@ export function PlexDirectoryListItem(props: { const addItems = useCallback( (e: MouseEvent) => { e.stopPropagation(); - addPlexSelectedMedia(server.name, [item]); + addPlexSelectedMedia(server, [item]); }, - [item, server.name], + [item, server], ); const renderCollectionRow = (id: string) => { - const media = listings[id]; + const { type, item } = listings[id]; - if (isPlexMedia(media)) { + if (type === 'plex' && isPlexMedia(item)) { return ( - + ); } }; diff --git a/web/src/components/channel_config/PlexGridItem.tsx b/web/src/components/channel_config/PlexGridItem.tsx index 26f02eb5..0b95e881 100644 --- a/web/src/components/channel_config/PlexGridItem.tsx +++ b/web/src/components/channel_config/PlexGridItem.tsx @@ -1,59 +1,41 @@ -import { useSettings } from '@/store/settings/selectors.ts'; -import { CheckCircle, RadioButtonUnchecked } from '@mui/icons-material'; -import { - Box, - Fade, - Unstable_Grid2 as Grid, - IconButton, - ImageListItem, - ImageListItemBar, - Skeleton, - alpha, - useTheme, -} from '@mui/material'; -import { createExternalId } from '@tunarr/shared'; import { PlexChildMediaApiType, PlexMedia, isPlexPlaylist, isTerminalItem, } from '@tunarr/types/plex'; -import { filter, isNaN, isNil, isUndefined } from 'lodash-es'; +import { isEmpty, isEqual, isNil, isUndefined } from 'lodash-es'; import pluralize from 'pluralize'; -import React, { +import { ForwardedRef, - MouseEvent, forwardRef, + memo, useCallback, useEffect, + useMemo, useState, } from 'react'; -import { useIntersectionObserver } from 'usehooks-ts'; import { forPlexMedia, isNonEmptyString, prettyItemDuration, toggle, } from '../../helpers/util.ts'; -import { usePlexTyped } from '../../hooks/plex/usePlex.ts'; -import useStore from '../../store/index.ts'; -import { - addKnownMediaForServer, - addPlexSelectedMedia, - removePlexSelectedMedia, -} from '../../store/programmingSelector/actions.ts'; -import { PlexSelectedMedia } from '../../store/programmingSelector/store.ts'; -export interface PlexGridItemProps { - item: T; - style?: React.CSSProperties; - index?: number; - parent?: string; - moveModal?: (index: number, item: T) => void; - modalIndex?: number; - onClick?: () => void; - ref?: React.RefObject; -} +import { usePlexTyped } from '@/hooks/plex/usePlex.ts'; +import { + addKnownMediaForPlexServer, + addPlexSelectedMedia, +} from '@/store/programmingSelector/actions.ts'; +import { useCurrentMediaSource } from '@/store/programmingSelector/selectors.ts'; +import { SelectedMedia } from '@/store/programmingSelector/store.ts'; +import { useSettings } from '@/store/settings/selectors.ts'; +import { createExternalId } from '@tunarr/shared'; +import { MediaGridItem } from './MediaGridItem.tsx'; +import { GridItemProps } from './MediaItemGrid.tsx'; + +export interface PlexGridItemProps + extends GridItemProps {} const genPlexChildPath = forPlexMedia({ collection: (collection) => @@ -67,6 +49,7 @@ const extractChildCount = forPlexMedia({ show: (s) => s.childCount, collection: (s) => parseInt(s.childCount), playlist: (s) => s.leafCount, + default: 0, }); const childItemType = forPlexMedia({ @@ -95,213 +78,131 @@ const subtitle = forPlexMedia({ }, }); -export const PlexGridItem = forwardRef( - ( - props: PlexGridItemProps, - ref: ForwardedRef, - ) => { - const settings = useSettings(); - const theme = useTheme(); - const skeletonBgColor = alpha( - theme.palette.text.primary, - theme.palette.mode === 'light' ? 0.11 : 0.13, - ); - const server = useStore((s) => s.currentServer!); // We have to have a server at this point - const darkMode = useStore((state) => state.theme.darkMode); - const [open, setOpen] = useState(false); - const { item, index, style, moveModal } = props; - const hasThumb = isNonEmptyString( - isPlexPlaylist(props.item) ? props.item.composite : props.item.thumb, - ); - const [imageLoaded, setImageLoaded] = useState(!hasThumb); - const hasChildren = !isTerminalItem(item); - const { data: children } = usePlexTyped>( - server.name, - genPlexChildPath(props.item), - hasChildren && open, - ); - const selectedServer = useStore((s) => s.currentServer); - const selectedMedia = useStore((s) => - filter(s.selectedMedia, (p): p is PlexSelectedMedia => p.type === 'plex'), - ); - const selectedMediaIds = selectedMedia.map((item) => item.guid); +export const PlexGridItem = memo( + forwardRef( + ( + props: PlexGridItemProps, + ref: ForwardedRef, + ) => { + const { item, index, moveModal } = props; + const server = useCurrentMediaSource('plex')!; // We have to have a server at this point + const settings = useSettings(); + const [modalOpen, setModalOpen] = useState(false); + const currentServer = useCurrentMediaSource('plex'); - const handleClick = () => { - setOpen(toggle); + const isMusicItem = useCallback( + (item: PlexMedia) => + ['MusicArtist', 'MusicAlbum', 'Audio'].includes(item.type), + [], + ); - if (!isUndefined(index) && !isUndefined(moveModal)) { - moveModal(index, item); - } - }; + const isEpisode = useCallback( + (item: PlexMedia) => item.type === 'episode', + [], + ); - useEffect(() => { - if (!isUndefined(children?.Metadata)) { - addKnownMediaForServer(server.name, children.Metadata, item.guid); - } - }, [item.guid, server.name, children]); + const onSelect = useCallback( + (item: PlexMedia) => { + addPlexSelectedMedia(server, [item]); + }, + [server], + ); - const handleItem = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); + const { data: childItems } = usePlexTyped>( + server.id, + genPlexChildPath(props.item), + !isTerminalItem(item) && modalOpen, + ); - if (selectedMediaIds.includes(item.guid)) { - removePlexSelectedMedia(selectedServer!.name, [item.guid]); - } else { - addPlexSelectedMedia(selectedServer!.name, [item]); + useEffect(() => { + if ( + !isUndefined(childItems) && + !isEmpty(childItems.Metadata) && + isNonEmptyString(currentServer?.id) + ) { + addKnownMediaForPlexServer( + currentServer.id, + childItems.Metadata, + item.guid, + ); } - }, - [item, selectedServer, selectedMediaIds], - ); + }, [childItems, currentServer?.id, item.guid]); - const { isIntersecting: isInViewport, ref: imageContainerRef } = - useIntersectionObserver({ - threshold: 0, - rootMargin: '0px', - freezeOnceVisible: true, - }); + const moveModalToItem = useCallback(() => { + moveModal(index, item); + }, [index, item, moveModal]); - const extractChildCount = forPlexMedia({ - season: (s) => s.leafCount, - show: (s) => s.childCount, - collection: (s) => parseInt(s.childCount), - }); + const handleItemClick = useCallback(() => { + setModalOpen(toggle); + moveModalToItem(); + }, [moveModalToItem]); - let childCount = isUndefined(item) ? null : extractChildCount(item); - if (isNaN(childCount)) { - childCount = null; - } + const thumbnailUrlFunc = useCallback( + (item: PlexMedia) => { + if (isPlexPlaylist(item)) { + return `${server.uri}${item.composite}?X-Plex-Token=${server.accessToken}`; + } else { + const query = new URLSearchParams({ + mode: 'proxy', + asset: 'thumb', + id: createExternalId('plex', server.name, item.ratingKey), + // Commenting this out for now as temporary solution for image loading issue + // thumbOptions: JSON.stringify({ width: 480, height: 720 }), + }); - const isMusicItem = ['artist', 'album', 'track', 'playlist'].includes( - item.type, - ); + return `${ + settings.backendUri + }/api/metadata/external?${query.toString()}`; + } + }, + [server.accessToken, server.name, server.uri, settings.backendUri], + ); - const isEpisodeItem = ['episode'].includes(item.type); + const selectedMediaFunc = useCallback( + (item: PlexMedia): SelectedMedia => { + return { + type: 'plex', + serverId: currentServer!.id, + serverName: currentServer!.name, + childCount: extractChildCount(item) ?? 0, + id: item.guid, + }; + }, + [currentServer], + ); - let thumbSrc: string; - if (isPlexPlaylist(item)) { - thumbSrc = `${server.uri}${item.composite}?X-Plex-Token=${server.accessToken}`; - } else { - const query = new URLSearchParams({ - mode: 'proxy', - asset: 'thumb', - id: createExternalId('plex', server.name, item.ratingKey), - // Commenting this out for now as temporary solution for image loading issue - // thumbOptions: JSON.stringify({ width: 480, height: 720 }), - }); + const metadata = useMemo( + () => ({ + itemId: item.guid, + hasThumbnail: isNonEmptyString( + isPlexPlaylist(item) ? item.composite : item.thumb, + ), + childCount: extractChildCount(item), + title: item.title, + subtitle: subtitle(item), + thumbnailUrl: thumbnailUrlFunc(item), + selectedMedia: selectedMediaFunc(item), + isMusicItem: isMusicItem(item), + isEpisode: isEpisode(item), + isPlaylist: isPlexPlaylist(item), + }), + [isEpisode, isMusicItem, item, selectedMediaFunc, thumbnailUrlFunc], + ); - thumbSrc = `${ - settings.backendUri - }/api/metadata/external?${query.toString()}`; - } - - return ( - -
    - - props.modalIndex === props.index - ? darkMode - ? theme.palette.grey[800] - : theme.palette.grey[400] - : 'transparent', - ...style, - }} - onClick={ - hasChildren - ? handleClick - : (event: MouseEvent) => handleItem(event) - } + return ( + currentServer && ( + - {isInViewport && // TODO: Eventually turn this into isNearViewport so images load before they hit the viewport - (hasThumb ? ( - - setImageLoaded(true)} - onError={() => setImageLoaded(true)} - /> - - - ) : ( - - ))} - ) => - handleItem(event) - } - > - {selectedMediaIds.includes(item.guid) ? ( - - ) : ( - - )} - - } - actionPosition="right" - /> - -
    -
    - ); - }, + metadata={metadata} + onClick={handleItemClick} + onSelect={onSelect} + /> + ) + ); + }, + ), + isEqual, ); diff --git a/web/src/components/channel_config/PlexListItem.tsx b/web/src/components/channel_config/PlexListItem.tsx index 030173ca..84699ded 100644 --- a/web/src/components/channel_config/PlexListItem.tsx +++ b/web/src/components/channel_config/PlexListItem.tsx @@ -28,11 +28,12 @@ import { import { usePlexTyped } from '../../hooks/plex/usePlex.ts'; import useStore from '../../store/index.ts'; import { - addKnownMediaForServer, + addKnownMediaForPlexServer, addPlexSelectedMedia, removePlexSelectedMedia, } from '../../store/programmingSelector/actions.ts'; import { PlexSelectedMedia } from '../../store/programmingSelector/store.ts'; +import { useCurrentMediaSource } from '@/store/programmingSelector/selectors.ts'; export interface PlexListItemProps { item: T; @@ -61,15 +62,15 @@ export function PlexListItem(props: PlexListItemProps) { const hasChildren = !isTerminalItem(item); const childPath = isPlexCollection(item) ? 'collections' : 'metadata'; const { isPending, data: children } = usePlexTyped>( - server.name, + server.id, `/library/${childPath}/${props.item.ratingKey}/children`, hasChildren && open, ); - const selectedServer = useStore((s) => s.currentServer); + const selectedServer = useCurrentMediaSource('plex'); const selectedMedia = useStore((s) => filter(s.selectedMedia, (m): m is PlexSelectedMedia => m.type === 'plex'), ); - const selectedMediaIds = map(selectedMedia, typedProperty('guid')); + const selectedMediaIds = map(selectedMedia, typedProperty('id')); const handleClick = () => { setOpen(!open); @@ -77,18 +78,18 @@ export function PlexListItem(props: PlexListItemProps) { useEffect(() => { if (children) { - addKnownMediaForServer(server.name, children.Metadata, item.guid); + addKnownMediaForPlexServer(server.id, children.Metadata, item.guid); } - }, [item.guid, server.name, children]); + }, [item.guid, server.id, children]); const handleItem = useCallback( (e: MouseEvent) => { e.stopPropagation(); if (selectedMediaIds.includes(item.guid)) { - removePlexSelectedMedia(selectedServer!.name, [item.guid]); + removePlexSelectedMedia(selectedServer!.id, [item.guid]); } else { - addPlexSelectedMedia(selectedServer!.name, [item]); + addPlexSelectedMedia(selectedServer!, [item]); } }, [item, selectedServer, selectedMediaIds], diff --git a/web/src/components/channel_config/PlexProgrammingSelector.tsx b/web/src/components/channel_config/PlexProgrammingSelector.tsx index 4ebef609..e5a0b411 100644 --- a/web/src/components/channel_config/PlexProgrammingSelector.tsx +++ b/web/src/components/channel_config/PlexProgrammingSelector.tsx @@ -1,14 +1,16 @@ import { usePlexCollectionsInfinite } from '@/hooks/plex/usePlexCollections.ts'; import { usePlexPlaylistsInfinite } from '@/hooks/plex/usePlexPlaylists.ts'; import { usePlexSearchInfinite } from '@/hooks/plex/usePlexSearch.ts'; +import { + useCurrentMediaSource, + useCurrentSourceLibrary, +} from '@/store/programmingSelector/selectors.ts'; import FilterAlt from '@mui/icons-material/FilterAlt'; import GridView from '@mui/icons-material/GridView'; import ViewList from '@mui/icons-material/ViewList'; import { Box, - CircularProgress, Collapse, - Divider, Grow, LinearProgress, Stack, @@ -16,59 +18,32 @@ import { Tabs, ToggleButton, ToggleButtonGroup, - Typography, } from '@mui/material'; -import { - PlexMedia, - PlexMovie, - PlexMusicArtist, - PlexTvShow, - isPlexParentItem, -} from '@tunarr/types/plex'; -import { usePrevious } from '@uidotdev/usehooks'; -import _, { - chain, - compact, - first, - flatMap, - isNil, - isUndefined, - map, - sumBy, -} from 'lodash-es'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { - useDebounceCallback, - useIntersectionObserver, - useResizeObserver, -} from 'usehooks-ts'; -import { - extractLastIndexes, - findFirstItemInNextRowIndex, - getImagesPerRow, - isNewModalAbove, -} from '../../helpers/inlineModalUtil'; -import { isNonEmptyString, toggle } from '../../helpers/util'; -import { usePlex } from '../../hooks/plex/usePlex.ts'; -import { usePlexServerSettings } from '../../hooks/settingsHooks'; -import useStore from '../../store'; -import { addKnownMediaForServer } from '../../store/programmingSelector/actions'; -import { setProgrammingSelectorViewState } from '../../store/themeEditor/actions'; -import { ProgramSelectorViewType } from '../../types'; -import { InlineModal } from '../InlineModal'; -import CustomTabPanel from '../TabPanel'; +import { PlexMedia, isPlexParentItem } from '@tunarr/types/plex'; +import { chain, filter, first, isNil, isUndefined, sumBy } from 'lodash-es'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { isNonEmptyString, toggle } from '../../helpers/util.ts'; +import { usePlexLibraries } from '../../hooks/plex/usePlex.ts'; +import { useMediaSources } from '../../hooks/settingsHooks.ts'; +import useStore from '../../store/index.ts'; +import { addKnownMediaForPlexServer } from '../../store/programmingSelector/actions.ts'; +import { setProgrammingSelectorViewState } from '../../store/themeEditor/actions.ts'; +import { ProgramSelectorViewType } from '../../types/index.ts'; +import { InlineModal } from '../InlineModal.tsx'; +import { TabPanel } from '../TabPanel.tsx'; import StandaloneToggleButton from '../base/StandaloneToggleButton.tsx'; -import ConnectPlex from '../settings/ConnectPlex'; +import ConnectPlex from '../settings/ConnectPlex.tsx'; +import { + GridInlineModalProps, + GridItemProps, + MediaItemGrid, +} from './MediaItemGrid.tsx'; import { PlexFilterBuilder } from './PlexFilterBuilder.tsx'; -import { PlexGridItem } from './PlexGridItem'; -import { PlexListItem } from './PlexListItem'; +import { PlexGridItem } from './PlexGridItem.tsx'; import { PlexSortField } from './PlexSortField.tsx'; +import { PlexListItem } from './PlexListItem.tsx'; +import { tag } from '@tunarr/types'; +import { MediaSourceId } from '@tunarr/types/schemas'; function a11yProps(index: number) { return { @@ -77,15 +52,6 @@ function a11yProps(index: number) { }; } -type RefMap = { - [k: string]: HTMLDivElement | null; -}; - -type Size = { - width?: number; - height?: number; -}; - enum TabValues { Library = 0, Collections = 1, @@ -93,125 +59,23 @@ enum TabValues { } export default function PlexProgrammingSelector() { - const { data: plexServers } = usePlexServerSettings(); - const selectedServer = useStore((s) => s.currentServer); - const selectedLibrary = useStore((s) => - s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, - ); + const { data: mediaSources } = useMediaSources(); + const plexServers = filter(mediaSources, { type: 'plex' }); + const selectedServer = useCurrentMediaSource('plex'); + const selectedLibrary = useCurrentSourceLibrary('plex'); const viewType = useStore((state) => state.theme.programmingSelectorView); const [tabValue, setTabValue] = useState(TabValues.Library); - const [rowSize, setRowSize] = useState(9); - const [modalIndex, setModalIndex] = useState(-1); - const [modalGuid, setModalGuid] = useState(''); const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 }); const [searchVisible, setSearchVisible] = useState(false); const [useAdvancedSearch, setUseAdvancedSearch] = useState(false); - const gridContainerRef = useRef(null); - const gridImageRefs = useRef({}); - const previousModalIndex = usePrevious(modalIndex); - - const [{ width }, setSize] = useState({ - width: undefined, - height: undefined, - }); - - const onResize = useDebounceCallback(setSize, 200); - - useResizeObserver({ - ref: gridContainerRef, - onResize, - }); - - useEffect(() => { - if (viewType === 'grid') { - let imageRef: HTMLDivElement | null = null; - - if (modalGuid === '') { - // Grab the first non-null ref for an image - for (const key in gridImageRefs.current) { - if (gridImageRefs.current[key] !== null) { - imageRef = gridImageRefs.current[key]; - break; - } - } - } else { - imageRef = _.get(gridImageRefs.current, modalGuid); - } - - const imageWidth = imageRef?.getBoundingClientRect().width; - - // 16 is additional padding available in the parent container - const rowSize = getImagesPerRow(width ? width + 16 : 0, imageWidth ?? 0); - setRowSize(rowSize); - setScrollParams(({ max }) => ({ max, limit: rowSize * 4 })); - } - }, [width, tabValue, viewType, modalGuid]); - - useEffect(() => { - setTabValue(0); - }, [selectedLibrary]); - - useEffect(() => { - setModalIndex(-1); - setModalGuid(''); - }, [tabValue, selectedLibrary]); const handleChange = (_: React.SyntheticEvent, newValue: TabValues) => { setTabValue(newValue); }; - const scrollToGridItem = useCallback( - (guid: string, index: number) => { - const selectedElement = gridImageRefs.current[guid]; - const includeModalInHeightCalc = isNewModalAbove( - previousModalIndex, - index, - rowSize, - ); - - if (selectedElement) { - // magic number for top bar padding; to do: calc it off ref - const topBarPadding = 64; - // New modal is opening in a row above previous modal - const modalMovesUp = selectedElement.offsetTop - topBarPadding; - // New modal is opening in the same row or a row below the current modal - const modalMovesDown = - selectedElement.offsetTop - - selectedElement.offsetHeight - - topBarPadding; - - window.scrollTo({ - top: includeModalInHeightCalc ? modalMovesDown : modalMovesUp, - behavior: 'smooth', - }); - } - }, - [previousModalIndex, rowSize], - ); - - // Scroll to new selected item when modalIndex changes - // Doing this on modalIndex change negates the need to calc inline modal height since it's collapsed at this time - useEffect(() => { - scrollToGridItem(modalGuid, modalIndex); - }, [modalGuid, modalIndex, scrollToGridItem]); - - const handleMoveModal = useCallback( - (index: number, item: PlexMedia) => { - if (index === modalIndex) { - setModalIndex(-1); - setModalGuid(''); - } else { - setModalIndex(index); - setModalGuid(item.guid); - } - }, - [modalIndex], - ); - - const { data: directoryChildren } = usePlex( - selectedServer?.name ?? '', - '/library/sections', - !isUndefined(selectedServer), + const { data: directoryChildren } = usePlexLibraries( + selectedServer?.id ?? tag(''), + selectedServer?.type === 'plex', ); const setViewType = (view: ProgramSelectorViewType) => { @@ -225,27 +89,24 @@ export default function PlexProgrammingSelector() { setViewType(newFormats); }; - const { - isLoading: isCollectionLoading, - data: collectionsData, - fetchNextPage: fetchNextCollectionsPage, - isFetchingNextPage: isFetchingNextCollectionsPage, - hasNextPage: hasNextCollectionsPage, - } = usePlexCollectionsInfinite(selectedServer, selectedLibrary, rowSize * 4); - - const { - isLoading: isPlaylistLoading, - data: playlistData, - fetchNextPage: fetchNextPlaylistPage, - isFetchingNextPage: isFetchingNextPlaylistPage, - hasNextPage: hasNextPlaylistPage, - } = usePlexPlaylistsInfinite( + const plexCollectionsQuery = usePlexCollectionsInfinite( selectedServer, selectedLibrary, - rowSize * 4, - // selectedLibrary?.library.type === 'artist', + 24, ); + const { isLoading: isCollectionLoading, data: collectionsData } = + plexCollectionsQuery; + + const plexPlaylistsQuery = usePlexPlaylistsInfinite( + selectedServer, + selectedLibrary, + 24, + ); + + const { isLoading: isPlaylistLoading, data: playlistData } = + plexPlaylistsQuery; + useEffect(() => { // When switching between Libraries, if a collection doesn't exist switch back to 'Library' tab if ( @@ -274,19 +135,15 @@ export default function PlexProgrammingSelector() { ({ plexSearch: plexQuery }) => plexQuery, ); - const { - isLoading: searchLoading, - data: searchData, - fetchNextPage: fetchNextItemsPage, - hasNextPage: hasNextItemsPage, - isFetchingNextPage: isFetchingNextItemsPage, - } = usePlexSearchInfinite( + const plexSearchQuery = usePlexSearchInfinite( selectedServer, selectedLibrary, searchKey, - rowSize * 4, + 24, ); + const { isLoading: searchLoading, data: searchData } = plexSearchQuery; + useEffect(() => { if (searchData?.pages.length === 1) { const size = searchData.pages[0].totalSize ?? searchData.pages[0].size; @@ -309,235 +166,141 @@ export default function PlexProgrammingSelector() { .map((page) => page.Metadata) .flatten() .value(); - addKnownMediaForServer(selectedServer.name, allMedia); + addKnownMediaForPlexServer(selectedServer.id, allMedia); } } - }, [scrollParams, selectedServer, searchData, rowSize]); + }, [scrollParams, selectedServer, searchData]); useEffect(() => { - if ( - isNonEmptyString(selectedServer?.name) && - !isUndefined(collectionsData) - ) { + if (isNonEmptyString(selectedServer?.id) && !isUndefined(collectionsData)) { const allCollections = chain(collectionsData.pages) .reject((page) => page.size === 0) .map((page) => page.Metadata) .compact() .flatten() .value(); - addKnownMediaForServer(selectedServer.name, allCollections); + addKnownMediaForPlexServer(selectedServer.id, allCollections); } - }, [selectedServer?.name, collectionsData]); + }, [selectedServer?.id, collectionsData]); - const { ref } = useIntersectionObserver({ - onChange: (_, entry) => { - if (entry.isIntersecting) { - if (scrollParams.limit < scrollParams.max) { - setScrollParams(({ limit: prevLimit, max }) => ({ - max, - limit: prevLimit + rowSize * 4, - })); - } - - if ( - tabValue === TabValues.Library && - hasNextItemsPage && - !isFetchingNextItemsPage - ) { - fetchNextItemsPage().catch(console.error); - } - - if ( - tabValue === TabValues.Collections && - hasNextCollectionsPage && - !isFetchingNextCollectionsPage - ) { - fetchNextCollectionsPage().catch(console.error); - } - - if ( - tabValue === TabValues.Playlists && - hasNextPlaylistPage && - !isFetchingNextPlaylistPage - ) { - fetchNextPlaylistPage().catch(console.error); - } - } - }, - threshold: 0.5, - }); - - const firstItemInNextLibraryRowIndex = useMemo( - () => - findFirstItemInNextRowIndex( - modalIndex, - rowSize, - sumBy(searchData?.pages, (p) => p.size) ?? 0, - ), - [searchData, rowSize, modalIndex], - ); - - const firstItemInNextCollectionRowIndex = useMemo( - () => - findFirstItemInNextRowIndex( - modalIndex, - rowSize, - sumBy(collectionsData?.pages, (p) => p.size) ?? 0, - ), - [collectionsData, rowSize, modalIndex], - ); - - const firstItemInNextPlaylistRowIndex = useMemo( - () => - findFirstItemInNextRowIndex( - modalIndex, - rowSize, - sumBy(playlistData?.pages, (p) => p.size) ?? 0, - ), - [playlistData, rowSize, modalIndex], - ); - - const renderGridItems = (item: PlexMedia, index: number) => { - let firstItemIndex: number; + const totalItems = useMemo(() => { switch (tabValue) { case TabValues.Library: - firstItemIndex = firstItemInNextLibraryRowIndex; - break; + return first(plexSearchQuery.data?.pages)?.totalSize ?? 0; case TabValues.Collections: - firstItemIndex = firstItemInNextCollectionRowIndex; - break; + return first(collectionsData?.pages)?.totalSize ?? 0; case TabValues.Playlists: - firstItemIndex = firstItemInNextPlaylistRowIndex; - break; + return first(playlistData?.pages)?.totalSize ?? 0; } + }, [ + collectionsData?.pages, + playlistData?.pages, + plexSearchQuery.data?.pages, + tabValue, + ]); - const isOpen = index === firstItemIndex; + const getPlexItemKey = useCallback((item: PlexMedia) => item.guid, []); + + const renderGridItem = ( + gridItemProps: GridItemProps, + modalProps: GridInlineModalProps, + ) => { + const isLast = gridItemProps.index === totalItems - 1; + + const renderModal = + isPlexParentItem(gridItemProps.item) && + ((gridItemProps.index + 1) % modalProps.rowSize === 0 || isLast); return ( - - {isPlexParentItem(item) && - (item.type === 'playlist' ? (item.leafCount ?? 0) < 500 : true) && ( - - )} - {/* TODO: Consider forking this to a separate component for non-parent items, because - currently it erroneously creates a lot of tracked queries in react-query that will never be enabled */} - (gridImageRefs.current[item.guid] = element)} - /> + + + {renderModal && ( + item.guid} + sourceType="plex" + getItemType={(item) => item.type} + getChildItemType={() => 'season'} + /> + )} ); }; - const renderFinalRowInlineModal = (arr: PlexMedia[]) => { - // /This Modal is for last row items because they can't be inserted using the above inline modal - // Check how many items are in the last row - const remainingItems = - arr.length % rowSize === 0 ? rowSize : arr.length % rowSize; - - const open = extractLastIndexes(arr, remainingItems).includes(modalIndex); - - return ( - - ); - }; - - const renderListItems = () => { + const renderPanels = () => { const elements: JSX.Element[] = []; + if ((first(searchData?.pages)?.size ?? 0) > 0) { + elements.push( + + ({ + total: page.totalSize, + size: page.size, + })} + extractItems={(page) => page.Metadata} + getItemKey={getPlexItemKey} + renderGridItem={renderGridItem} + renderListItem={(item) => ( + + )} + infiniteQuery={plexSearchQuery} + /> + , + ); + } if ( - tabValue === TabValues.Collections && - collectionsData && - (first(collectionsData.pages)?.size ?? 0) > 0 + // tabValue === TabValues.Collections && + (first(collectionsData?.pages)?.size ?? 0) > 0 ) { elements.push( - - {map( - compact(flatMap(collectionsData.pages, (page) => page.Metadata)), - (item, index: number) => - viewType === 'list' ? ( - - ) : ( - renderGridItems(item, index) - ), - )} - {renderFinalRowInlineModal( - compact(flatMap(collectionsData.pages, (page) => page.Metadata)), - )} - , + ({ + total: page.totalSize, + size: page.size, + })} + extractItems={(page) => page.Metadata ?? []} + getItemKey={getPlexItemKey} + renderGridItem={renderGridItem} + renderListItem={(item) => ( + + )} + infiniteQuery={plexCollectionsQuery} + /> + , ); - } - if ( - tabValue === TabValues.Playlists && - (first(playlistData?.pages)?.size ?? 0) > 0 - ) { - elements.push( - - {map( - compact(flatMap(playlistData?.pages, (page) => page.Metadata)), - (item, index: number) => - viewType === 'list' ? ( + if ( + // tabValue === TabValues.Collections && + (first(playlistData?.pages)?.size ?? 0) > 0 + ) { + elements.push( + + ({ + total: page.totalSize, + size: page.size, + })} + extractItems={(page) => page.Metadata ?? []} + getItemKey={getPlexItemKey} + renderGridItem={renderGridItem} + renderListItem={(item) => ( - ) : ( - renderGridItems(item, index) - ), - )} - {renderFinalRowInlineModal( - compact(flatMap(playlistData?.pages, (page) => page.Metadata)), - )} - , - ); - } - - if (searchData && (first(searchData.pages)?.size ?? 0) > 0) { - const items = chain(searchData.pages) - .map((page) => page.Metadata) - .flatten() - .value(); - - const totalSearchDataSize = - searchData.pages[0].totalSize || searchData.pages[0].size; - - elements.push( - - {map( - items, - (item: PlexMovie | PlexTvShow | PlexMusicArtist, index: number) => - viewType === 'list' ? ( - - ) : ( - renderGridItems(item, index) - ), - )} - - {items.length >= totalSearchDataSize && - renderFinalRowInlineModal(items)} - , - ); + )} + infiniteQuery={plexPlaylistsQuery} + /> + , + ); + } } return elements; @@ -640,42 +403,25 @@ export default function PlexProgrammingSelector() { - {!isUndefined(collectionsData) && - sumBy(collectionsData.pages, (page) => page.size) > 0 && ( - - )} - {!isUndefined(playlistData) && - sumBy(playlistData.pages, 'size') > 0 && ( - - )} + {sumBy(collectionsData?.pages, (page) => page.size) > 0 && ( + + )} + {sumBy(playlistData?.pages, 'size') > 0 && ( + + )} - - {renderListItems()} - - {!searchLoading &&
    } - {isFetchingNextItemsPage && ( - - )} - {searchData && !hasNextItemsPage && ( - - fin. - - )} - + {renderPanels()} )} diff --git a/web/src/components/channel_config/PlexSortField.tsx b/web/src/components/channel_config/PlexSortField.tsx index 464bc483..5e69ad7b 100644 --- a/web/src/components/channel_config/PlexSortField.tsx +++ b/web/src/components/channel_config/PlexSortField.tsx @@ -8,9 +8,9 @@ import find from 'lodash-es/find'; import isUndefined from 'lodash-es/isUndefined'; import map from 'lodash-es/map'; import { useCallback, useEffect, useState } from 'react'; -import { usePlexFilters } from '../../hooks/plex/usePlexFilters'; -import useStore from '../../store'; +import { useSelectedLibraryPlexFilters } from '../../hooks/plex/usePlexFilters'; import { setPlexSort } from '../../store/programmingSelector/actions'; +import { useCurrentSourceLibrary } from '@/store/programmingSelector/selectors.ts'; type PlexSort = { key: string; @@ -19,10 +19,7 @@ type PlexSort = { }; export function PlexSortField() { - const selectedServer = useStore((s) => s.currentServer); - const selectedLibrary = useStore((s) => - s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, - ); + const selectedLibrary = useCurrentSourceLibrary('plex'); const [sort, setSort] = useState({ key: '', @@ -31,10 +28,7 @@ export function PlexSortField() { }); const { data: plexFilterMetadata, isLoading: filterMetadataLoading } = - usePlexFilters( - selectedServer?.name ?? '', - selectedLibrary?.library.key ?? '', - ); + useSelectedLibraryPlexFilters(); const libraryFilterMetadata = find( plexFilterMetadata?.Type, diff --git a/web/src/components/channel_config/ProgrammingSelector.tsx b/web/src/components/channel_config/ProgrammingSelector.tsx index 1f7d9b54..2805fe2e 100644 --- a/web/src/components/channel_config/ProgrammingSelector.tsx +++ b/web/src/components/channel_config/ProgrammingSelector.tsx @@ -9,20 +9,60 @@ import { Typography, } from '@mui/material'; import { PlexMedia, isPlexDirectory } from '@tunarr/types/plex'; -import { find, isEmpty, isNil, isUndefined, map } from 'lodash-es'; +import { + capitalize, + chain, + find, + isEmpty, + isNil, + isUndefined, + map, + sortBy, +} from 'lodash-es'; import React, { useCallback, useEffect, useState } from 'react'; import { usePlexLibraries } from '../../hooks/plex/usePlex.ts'; -import { usePlexServerSettings } from '../../hooks/settingsHooks.ts'; +import { useMediaSources } from '../../hooks/settingsHooks.ts'; import { useCustomShows } from '../../hooks/useCustomShows.ts'; import useStore from '../../store/index.ts'; import { - addKnownMediaForServer, + addKnownMediaForJellyfinServer, + addKnownMediaForPlexServer, setProgrammingListLibrary, setProgrammingListingServer, } from '../../store/programmingSelector/actions.ts'; import AddPlexServer from '../settings/AddPlexServer.tsx'; import { CustomShowProgrammingSelector } from './CustomShowProgrammingSelector.tsx'; import PlexProgrammingSelector from './PlexProgrammingSelector.tsx'; +import { useJellyfinUserLibraries } from '@/hooks/jellyfin/useJellyfinApi.ts'; +import { JellyfinProgrammingSelector } from './JellyfinProgrammingSelector.tsx'; +import { useKnownMedia } from '@/store/programmingSelector/selectors.ts'; +import { JellyfinItem } from '@tunarr/types/jellyfin'; +import { tag } from '@tunarr/types'; + +const sortJellyfinLibraries = (item: JellyfinItem) => { + if (item.CollectionType) { + switch (item.CollectionType) { + case 'tvshows': + return 0; + case 'movies': + case 'music': + return 1; + case 'unknown': + case 'musicvideos': + case 'trailers': + case 'homevideos': + case 'boxsets': + case 'books': + case 'photos': + case 'livetv': + case 'playlists': + case 'folders': + return 2; + } + } + + return Number.MAX_SAFE_INTEGER; +}; export interface PlexListItemProps { item: T; @@ -32,50 +72,76 @@ export interface PlexListItemProps { parent?: string; } -export default function ProgrammingSelector() { - const { data: plexServers, isLoading: plexServersLoading } = - usePlexServerSettings(); +type Props = { + initialMediaSourceId?: string; + initialLibraryId?: string; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function ProgrammingSelector(_: Props) { + const { data: mediaSources, isLoading: mediaSourcesLoading } = + useMediaSources(); const selectedServer = useStore((s) => s.currentServer); const selectedLibrary = useStore((s) => s.currentLibrary); - const knownMedia = useStore((s) => s.knownMediaByServer); + const knownMedia = useKnownMedia(); const [mediaSource, setMediaSource] = useState(selectedServer?.name); // Convenience sub-selectors for specific library types const selectedPlexLibrary = selectedLibrary?.type === 'plex' ? selectedLibrary.library : undefined; + const selectedJellyfinLibrary = + selectedLibrary?.type === 'jellyfin' ? selectedLibrary.library : undefined; const viewingCustomShows = mediaSource === 'custom-shows'; - /** - * Load Plex libraries - */ const { data: plexLibraryChildren } = usePlexLibraries( - selectedServer?.name ?? '', - !isUndefined(selectedServer), + selectedServer?.id ?? tag(''), + selectedServer?.type === 'plex', + ); + + const { data: jellyfinLibraries } = useJellyfinUserLibraries( + selectedServer?.id ?? '', + selectedServer?.type === 'jellyfin', ); useEffect(() => { const server = - !isUndefined(plexServers) && !isEmpty(plexServers) - ? plexServers[0] + !isUndefined(mediaSources) && !isEmpty(mediaSources) + ? mediaSources[0] : undefined; setProgrammingListingServer(server); - }, [plexServers]); + }, [mediaSources]); useEffect(() => { - if (selectedServer && plexLibraryChildren) { - if (plexLibraryChildren.size > 0) { + if (selectedServer?.type === 'plex' && plexLibraryChildren) { + if ( + plexLibraryChildren.size > 0 && + (!selectedLibrary || selectedLibrary.type !== 'plex') + ) { setProgrammingListLibrary({ type: 'plex', library: plexLibraryChildren.Directory[0], }); } - addKnownMediaForServer(selectedServer.name, [ + addKnownMediaForPlexServer(selectedServer.id, [ ...plexLibraryChildren.Directory, ]); + } else if (selectedServer?.type === 'jellyfin' && jellyfinLibraries) { + if ( + jellyfinLibraries.Items.length > 0 && + (!selectedLibrary || selectedLibrary.type !== 'jellyfin') + ) { + setProgrammingListLibrary({ + type: 'jellyfin', + library: sortBy(jellyfinLibraries.Items, sortJellyfinLibraries)[0], + }); + } + addKnownMediaForJellyfinServer(selectedServer.id, [ + ...jellyfinLibraries.Items, + ]); } - }, [selectedServer, plexLibraryChildren]); + }, [selectedServer, plexLibraryChildren, jellyfinLibraries, selectedLibrary]); /** * Load custom shows @@ -83,44 +149,65 @@ export default function ProgrammingSelector() { const { data: customShows } = useCustomShows(); const onMediaSourceChange = useCallback( - (newMediaSource: string) => { - if (newMediaSource === 'custom-shows') { + (newMediaSourceId: string) => { + if (newMediaSourceId === 'custom-shows') { // Not dealing with a server setProgrammingListLibrary({ type: 'custom-show' }); setProgrammingListingServer(undefined); - setMediaSource(newMediaSource); + setMediaSource(newMediaSourceId); } else { - const server = find(plexServers, { name: newMediaSource }); + const server = find( + mediaSources, + (source) => source.id === newMediaSourceId, + ); if (server) { setProgrammingListingServer(server); setMediaSource(server.name); } } }, - [plexServers], + [mediaSources], ); const onLibraryChange = useCallback( (libraryUuid: string) => { - if (selectedServer) { - const known = knownMedia[selectedServer.name] ?? {}; - const library = known[libraryUuid]; + if (selectedServer?.type === 'plex') { + const library = knownMedia.getMediaOfType( + selectedServer.id, + libraryUuid, + 'plex', + ); if (library && isPlexDirectory(library)) { setProgrammingListLibrary({ type: 'plex', library }); } + } else if (selectedServer?.type === 'jellyfin') { + const library = knownMedia.getMediaOfType( + selectedServer.id, + libraryUuid, + 'jellyfin', + ); + if (library) { + setProgrammingListLibrary({ type: 'jellyfin', library }); + } } }, [knownMedia, selectedServer], ); const renderMediaSourcePrograms = () => { - if (selectedLibrary?.type === 'custom-show') { - return ; - } else if (selectedLibrary?.type === 'plex') { - return ; + if (selectedLibrary) { + switch (selectedLibrary.type) { + case 'plex': + return ; + case 'jellyfin': + return ; + case 'custom-show': + return ; + } } - if (!plexServersLoading && !selectedServer) { + // TODO: change the wording here to not be Plex-specific + if (!mediaSourcesLoading && !selectedServer) { return ( <> @@ -146,8 +233,64 @@ export default function ProgrammingSelector() { return null; }; + const renderLibraryChoices = () => { + if (isUndefined(selectedServer)) { + return; + } + + switch (selectedServer.type) { + case 'plex': { + return ( + !isNil(plexLibraryChildren) && + plexLibraryChildren.size > 0 && + selectedPlexLibrary && ( + + Library + + + ) + ); + } + case 'jellyfin': { + return ( + !isNil(jellyfinLibraries) && + jellyfinLibraries.Items.length > 0 && + selectedJellyfinLibrary && ( + + Library + + + ) + ); + } + } + }; + const hasAnySources = - (plexServers && plexServers.length > 0) || customShows.length > 0; + (mediaSources && mediaSources.length > 0) || customShows.length > 0; return ( @@ -168,15 +311,13 @@ export default function ProgrammingSelector() { onLibraryChange(e.target.value)} - > - {plexLibraryChildren.Directory.map((dir) => ( - - {dir.title} - - ))} - - - )} + {renderLibraryChoices()}
    {renderMediaSourcePrograms()} diff --git a/web/src/components/channel_config/SelectedProgrammingActions.tsx b/web/src/components/channel_config/SelectedProgrammingActions.tsx index aa7528d1..45773abf 100644 --- a/web/src/components/channel_config/SelectedProgrammingActions.tsx +++ b/web/src/components/channel_config/SelectedProgrammingActions.tsx @@ -6,13 +6,14 @@ import { useSnackbar } from 'notistack'; import { useCallback, useState } from 'react'; import useStore from '../../store/index.ts'; import { - addKnownMediaForServer, + addKnownMediaForPlexServer, addPlexSelectedMedia, clearSelectedMedia, } from '../../store/programmingSelector/actions.ts'; import { AddedMedia } from '../../types/index.ts'; import { RotatingLoopIcon } from '../base/LoadingIcon.tsx'; import AddSelectedMediaButton from './AddSelectedMediaButton.tsx'; +import { useCurrentMediaSourceAndLibrary } from '@/store/programmingSelector/selectors.ts'; type Props = { onAddSelectedMedia: (media: AddedMedia[]) => void; @@ -28,10 +29,8 @@ export default function SelectedProgrammingActions({ selectAllEnabled = true, toggleOrSetSelectedProgramsDrawer, // onSelectionModalClose, }: Props) { - const [selectedServer, selectedLibrary] = useStore((s) => [ - s.currentServer, - s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, - ]); + const [selectedServer, selectedLibrary] = + useCurrentMediaSourceAndLibrary('plex'); const { urlFilter: plexSearch } = useStore( ({ plexSearch: plexQuery }) => plexQuery, @@ -64,8 +63,11 @@ export default function SelectedProgrammingActions({ setSelectAllLoading(true); directPlexSearchFn() .then((response) => { - addKnownMediaForServer(selectedServer.name, response.Metadata ?? []); - addPlexSelectedMedia(selectedServer.name, response.Metadata); + addKnownMediaForPlexServer( + selectedServer.id, + response.Metadata ?? [], + ); + addPlexSelectedMedia(selectedServer, response.Metadata); }) .catch((e) => { console.error('Error while attempting to select all Plex items', e); diff --git a/web/src/components/channel_config/SelectedProgrammingList.tsx b/web/src/components/channel_config/SelectedProgrammingList.tsx index 0f2f4541..d1e1649a 100644 --- a/web/src/components/channel_config/SelectedProgrammingList.tsx +++ b/web/src/components/channel_config/SelectedProgrammingList.tsx @@ -4,7 +4,9 @@ import { KeyboardArrowRight, Close as RemoveIcon, } from '@mui/icons-material'; +import JellyfinLogo from '@/assets/jellyfin.svg'; import { + Box, Chip, ClickAwayListener, Drawer, @@ -24,7 +26,14 @@ import { isPlexSeason, isPlexShow, } from '@tunarr/types/plex'; -import { first, groupBy, isUndefined, mapValues, reduce } from 'lodash-es'; +import { + find, + first, + groupBy, + isUndefined, + mapValues, + reduce, +} from 'lodash-es'; import pluralize from 'pluralize'; import { ReactNode, useState } from 'react'; import { FixedSizeList, ListChildComponentProps } from 'react-window'; @@ -36,6 +45,8 @@ import { removeSelectedMedia } from '../../store/programmingSelector/actions.ts' import { AddedMedia } from '../../types/index.ts'; import AddSelectedMediaButton from './AddSelectedMediaButton.tsx'; import SelectedProgrammingActions from './SelectedProgrammingActions.tsx'; +import { useKnownMedia } from '@/store/programmingSelector/selectors.ts'; +import { useMediaSources } from '@/hooks/settingsHooks.ts'; type Props = { onAddSelectedMedia: (media: AddedMedia[]) => void; @@ -48,8 +59,9 @@ export default function SelectedProgrammingList({ onAddMediaSuccess, selectAllEnabled = true, }: Props) { + const { data: mediaSources } = useMediaSources(); const { data: customShows } = useCustomShows(); - const knownMedia = useStore((s) => s.knownMediaByServer); + const knownMedia = useKnownMedia(); const selectedMedia = useStore((s) => s.selectedMedia); const [open, setOpen] = useState(false); const windowSize = useWindowSize(); @@ -74,7 +86,11 @@ export default function SelectedProgrammingList({ [ListChildComponentProps] >({ plex: (selected, { style }) => { - const media = knownMedia[selected.server][selected.guid]; + const media = knownMedia.getMediaOfType( + selected.serverId, + selected.id, + 'plex', + )!; let title: string = media.title; let secondary: ReactNode = null; @@ -105,13 +121,69 @@ export default function SelectedProgrammingList({ } return ( - + + + + removeSelectedMedia([selected])}> + + + + + ); + }, + jellyfin: (selected, { style }) => { + const media = knownMedia.getMediaOfType( + selected.serverId, + selected.id, + 'jellyfin', + )!; + + let title: string = media.Name ?? ''; + let secondary: ReactNode = null; + if (media.Type === 'CollectionFolder') { + // TODO: Show the size + title = `Media - ${media.Name}`; + } else if (media.Type === 'Series') { + secondary = `${media.ChildCount ?? 0} ${pluralize( + 'season', + media.ChildCount ?? 0, + )}, ${media.RecursiveItemCount ?? 0} total ${pluralize( + 'episode', + media.RecursiveItemCount ?? 0, + )}`; + } else if (media.Type === 'Season') { + secondary = `eh help - ${media.Name} (${ + media.ChildCount ?? 0 + } ${pluralize('episode', media.ChildCount ?? 0)})`; + // } else if (media.Type === '') { + // secondary = `${media.title} (${media.childCount} ${pluralize( + // 'item', + // parseInt(media.childCount), + // )})`; + // } + } else if (media.Type === 'Movie') { + secondary = `Movie${ + media.ProductionYear ? ', ' + media.ProductionYear : '' + }`; + } + // else if (isPlexPlaylist(media) && !isUndefined(media.leafCount)) { + // secondary = `Playlist with ${media.leafCount} ${pluralize( + // 'tracks', + // media.leafCount, + // )}`; + // } + + return ( + + + + removeSelectedMedia([selected])}> @@ -154,7 +226,9 @@ export default function SelectedProgrammingList({ const item = data[index]; switch (item.type) { case 'plex': - return item.guid; + return `plex.${item.serverId}.${item.id}`; + case 'jellyfin': + return `jellyfin.${item.serverId}.${item.id}`; case 'custom-show': return `custom_${item.customShowId}_${index}`; } diff --git a/web/src/components/custom-shows/EditCustomShowForm.tsx b/web/src/components/custom-shows/EditCustomShowForm.tsx index 208a095d..06f85284 100644 --- a/web/src/components/custom-shows/EditCustomShowForm.tsx +++ b/web/src/components/custom-shows/EditCustomShowForm.tsx @@ -21,6 +21,8 @@ import { useNavigate, Link } from '@tanstack/react-router'; import { CustomShow } from '@tunarr/types'; import { useEffect, useCallback } from 'react'; import { useForm, SubmitHandler, Controller } from 'react-hook-form'; +import { createExternalId } from '@tunarr/shared'; +import { isNonEmptyString } from '@/helpers/util.ts'; type CustomShowForm = { id?: string; @@ -120,11 +122,21 @@ export function EditCustomShowsForm({ } else { title = p.title; } - id = p.persisted - ? p.id! - : `${p.externalSourceType}|${p.externalSourceName}|${ - p.originalProgram!.key - }`; + if (p.persisted) { + id = p.id!; + } else if ( + isNonEmptyString(p.externalSourceType) && + isNonEmptyString(p.externalSourceName) && + isNonEmptyString(p.externalKey) + ) { + id = createExternalId( + p.externalSourceType, + p.externalSourceName, + p.externalKey, + ); + } else { + id = 'unknown'; + } break; } diff --git a/web/src/components/filler/EditFillerListForm.tsx b/web/src/components/filler/EditFillerListForm.tsx index 194518e0..bb1a122d 100644 --- a/web/src/components/filler/EditFillerListForm.tsx +++ b/web/src/components/filler/EditFillerListForm.tsx @@ -21,6 +21,8 @@ import { Controller, SubmitHandler, useForm } from 'react-hook-form'; import { useTunarrApi } from '../../hooks/useTunarrApi.ts'; import { removeFillerListProgram } from '@/store/entityEditor/util.ts'; import { UIFillerListProgram } from '../../types/index.ts'; +import { isNonEmptyString } from '@/helpers/util.ts'; +import { createExternalId } from '@tunarr/shared'; export type FillerListMutationArgs = { id?: string; @@ -117,11 +119,22 @@ export function EditFillerListForm({ title = p.title; } - id = p.persisted - ? p.id! - : `${p.externalSourceType}|${p.externalSourceName}|${ - p.originalProgram!.key - }`; + if (p.persisted) { + id = p.id!; + } else if ( + isNonEmptyString(p.externalSourceType) && + isNonEmptyString(p.externalSourceName) && + isNonEmptyString(p.externalKey) + ) { + id = createExternalId( + p.externalSourceType, + p.externalSourceName, + p.externalKey, + ); + } else { + id = 'unknown'; + } + break; } diff --git a/web/src/components/settings/AddPlexServer.tsx b/web/src/components/settings/AddPlexServer.tsx index fa5cbc89..436c1e1c 100644 --- a/web/src/components/settings/AddPlexServer.tsx +++ b/web/src/components/settings/AddPlexServer.tsx @@ -1,11 +1,11 @@ import { AddCircle, SvgIconComponent } from '@mui/icons-material'; import { Button } from '@mui/material'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { InsertPlexServerRequest } from '@tunarr/types/api'; -import { checkNewPlexServers, plexLoginFlow } from '../../helpers/plexLogin.ts'; -import { useTunarrApi } from '../../hooks/useTunarrApi.ts'; +import { InsertMediaSourceRequest } from '@tunarr/types/api'; import { isEmpty } from 'lodash-es'; import { useSnackbar } from 'notistack'; +import { checkNewPlexServers, plexLoginFlow } from '../../helpers/plexLogin.ts'; +import { useTunarrApi } from '../../hooks/useTunarrApi.ts'; type AddPlexServer = { title?: string; @@ -20,12 +20,12 @@ export default function AddPlexServer(props: AddPlexServer) { const snackbar = useSnackbar(); const addPlexServerMutation = useMutation({ - mutationFn: (newServer: InsertPlexServerRequest) => { - return apiClient.createPlexServer(newServer); + mutationFn: (newServer: InsertMediaSourceRequest) => { + return apiClient.createMediaSource(newServer); }, onSuccess: () => { return queryClient.invalidateQueries({ - queryKey: ['settings', 'plex-servers'], + queryKey: ['settings', 'media-sources'], }); }, }); @@ -53,6 +53,7 @@ export default function AddPlexServer(props: AddPlexServer) { uri: connection.uri, accessToken: server.accessToken, clientIdentifier: server.clientIdentifier, + type: 'plex', }), ); }) diff --git a/web/src/components/settings/ConnectPlex.tsx b/web/src/components/settings/ConnectPlex.tsx index 0b227e2d..62735c64 100644 --- a/web/src/components/settings/ConnectPlex.tsx +++ b/web/src/components/settings/ConnectPlex.tsx @@ -15,7 +15,7 @@ import { } from '@mui/material'; import { Link as RouterLink } from '@tanstack/react-router'; import plexSvg from '../../assets/plex.svg'; -import { usePlexServerSettings } from '../../hooks/settingsHooks.ts'; +import { useMediaSources } from '../../hooks/settingsHooks.ts'; import AddPlexServer from './AddPlexServer.tsx'; export default function ConnectPlex(props: CardProps) { @@ -28,7 +28,7 @@ export default function ConnectPlex(props: CardProps) { ...restProps } = props; - const { data: plexServers } = usePlexServerSettings(); + const { data: plexServers } = useMediaSources(); const isPlexConnected = plexServers && plexServers.length > 0; const title = isPlexConnected ? 'Add Plex Library' : 'Connect Plex Now'; diff --git a/web/src/components/settings/media_source/JelllyfinServerEditDialog.tsx b/web/src/components/settings/media_source/JelllyfinServerEditDialog.tsx new file mode 100644 index 00000000..108c3197 --- /dev/null +++ b/web/src/components/settings/media_source/JelllyfinServerEditDialog.tsx @@ -0,0 +1,536 @@ +import { isNonEmptyString, isValidUrl, toggle } from '@/helpers/util'; +import { useTunarrApi } from '@/hooks/useTunarrApi'; + +import { RotatingLoopIcon } from '@/components/base/LoadingIcon.tsx'; +import { + CloudDoneOutlined, + CloudOff, + Visibility, + VisibilityOff, +} from '@mui/icons-material'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + FormControl, + FormHelperText, + IconButton, + InputAdornment, + InputLabel, + OutlinedInput, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { JellyfinServerSettings, PlexServerSettings } from '@tunarr/types'; +import { isEmpty, isUndefined } from 'lodash-es'; +import { FormEvent, useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { MarkOptional } from 'ts-essentials'; +import { useDebounceCallback, useDebounceValue } from 'usehooks-ts'; +import { useMediaSourceBackendStatus } from '@/hooks/media-sources/useMediaSourceBackendStatus'; +import { jellyfinLogin } from '@/hooks/jellyfin/useJellyfinLogin.ts'; +import { useSnackbar } from 'notistack'; + +type Props = { + open: boolean; + onClose: () => void; + server?: JellyfinServerSettings; +}; + +type PlexServerSettingsForm = MarkOptional< + Omit, + 'id' +>; + +export type JellyfinServerSettingsForm = MarkOptional< + JellyfinServerSettings, + 'id' +> & { + username?: string; + password?: string; +}; + +export type FormType = { + plex?: PlexServerSettingsForm; + jellyfin?: JellyfinServerSettingsForm; +}; + +const emptyDefaults: JellyfinServerSettingsForm = { + type: 'jellyfin', + uri: '', + name: '', + accessToken: '', + username: '', + password: '', +}; + +export function JellyfinServerEditDialog({ open, onClose, server }: Props) { + const apiClient = useTunarrApi(); + const queryClient = useQueryClient(); + const snackbar = useSnackbar(); + + const [showAccessToken, setShowAccessToken] = useState(false); + + const title = server ? `Editing "${server.name}"` : 'New Media Source'; + + const handleClose = () => { + reset(emptyDefaults); + setShowAccessToken(false); + onClose(); + }; + + const { + control, + watch, + reset, + formState: { isDirty, isValid, defaultValues, errors }, + handleSubmit, + setError, + clearErrors, + getValues, + } = useForm({ + mode: 'onChange', + defaultValues: server ?? emptyDefaults, + }); + + // These are updated in a watch callback, so we debounce them + // along with the details we use to check server status. Otherwise + // setting the error will cause us to check server status on every + // keystroke due to re-renders + const debounceSetError = useDebounceCallback(setError); + const debounceClearError = useDebounceCallback(clearErrors); + + const updateSourceMutation = useMutation({ + mutationFn: async (newOrUpdatedServer: JellyfinServerSettingsForm) => { + if (isNonEmptyString(newOrUpdatedServer.id)) { + await apiClient.updateMediaSource( + { ...newOrUpdatedServer, id: newOrUpdatedServer.id }, + { + params: { id: newOrUpdatedServer.id }, + }, + ); + return { id: newOrUpdatedServer.id }; + } else { + return apiClient.createMediaSource(newOrUpdatedServer); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['settings', 'media-sources'], + }); + handleClose(); + }, + }); + + // const { + // data: derivedAccessToken, + // isLoading: derivedAccessTokenLoading, + // error: derivedAccessTokenError, + // refetch: refetchAccessToken, + // } = useJellyinLogin(serverStatusDetails, false /* TODO is this right */); + + const showErrorSnack = (e: unknown) => { + snackbar.enqueueSnackbar({ + variant: 'error', + message: + 'Error saving new Jellyfin server. See browser console and server logs for details', + }); + console.error(e); + }; + + const onSubmit = async (e: FormEvent) => { + e.stopPropagation(); + + const { accessToken, username, password, uri } = getValues(); + + if (isNonEmptyString(accessToken)) { + void handleSubmit( + (data) => updateSourceMutation.mutate(data), + showErrorSnack, + )(e); + } else if (isNonEmptyString(username) && isNonEmptyString(password)) { + try { + const result = await jellyfinLogin(apiClient, { + username, + password, + uri, + }); + + if (isNonEmptyString(result.accessToken)) { + void handleSubmit( + (data) => + updateSourceMutation.mutate({ + ...data, + accessToken: result.accessToken!, + }), + showErrorSnack, + )(e); + } else { + // Pop snackbar + } + } catch (e) { + showErrorSnack(e); + } + } + }; + + const [showPassword, setShowPassword] = useState(false); + + const [serverStatusDetails, updateServerStatusDetails] = useDebounceValue( + { + id: server?.id && !isDirty ? server.id : undefined, + accessToken: defaultValues?.accessToken ?? '', + uri: defaultValues?.uri ?? '', + // type: 'jellyfin' as const, + }, + 1000, + { + equalityFn: (left, right) => { + return ( + left.id === right.id && + left.uri === right.uri && + left.accessToken === right.accessToken + ); + }, + }, + ); + + // This probably isn't the best way to do this...but it was the only + // way to get it working without infinite re-renders. Idea here is: + // Update the debounced value if relevant details change. Do not rely + // on the debounced value itself in this effect, because then we'll + // just update every time. This watch will fire off every time accessToken + // or URI changes, but the status query will only fire every 500ms + useEffect(() => { + const sub = watch((value, { name }) => { + if ( + isNonEmptyString(value.accessToken) || + (isNonEmptyString(value.username) && isNonEmptyString(value.password)) + ) { + debounceClearError('root.auth'); + } else { + debounceSetError('root.auth', { + message: 'Must provide either access token or username/password', + }); + } + + if (name === 'uri' || name === 'accessToken') { + updateServerStatusDetails({ + id: server?.id && !isDirty ? server.id : undefined, + accessToken: value.accessToken ?? '', + uri: value.uri ?? '', + // type: 'jellyfin' as const, + }); + } + }); + + return () => sub.unsubscribe(); + }, [ + watch, + updateServerStatusDetails, + server?.id, + isDirty, + debounceClearError, + debounceSetError, + errors, + ]); + + // useEffect(() => { + // if ( + // isNonEmptyString(serverStatusDetails.url) && + // isNonEmptyString(serverStatusDetails.username) && + // isNonEmptyString(serverStatusDetails.password) + // ) { + // jellyfinLogin(apiClient, serverStatusDetails) + // .then(({ accessToken }) => { + // if (isNonEmptyString(accessToken) && !accessTokenTouched) { + // setValue('accessToken', derivedAccessToken?.accessToken ?? '', { + // shouldValidate: true, + // }); + // } + // }) + // .catch(console.error); + // } + // }, [ + // serverStatusDetails, + // apiClient, + // accessTokenTouched, + // setValue, + // derivedAccessToken?.accessToken, + // ]); + + const { data: serverStatus, isLoading: serverStatusLoading } = + useMediaSourceBackendStatus( + { ...serverStatusDetails, type: 'jellyfin' }, + open, // && isNonEmptyString(accessToken), + ); + + console.log(serverStatus); + + // useEffect(() => { + // if (!isUndefined(derivedAccessToken) && !accessTokenTouched) { + // setValue('accessToken', derivedAccessToken?.accessToken ?? '', { + // shouldValidate: true, + // }); + // } + // // else if (!isUndefined(serverError) && !accessTokenTouched) { + // // setValue('accessToken', ''); + // // } + // }, [ + // derivedAccessToken, + // derivedAccessTokenError, + // setValue, + // getValues, + // accessTokenTouched, + // ]); + + // TODO: Block creation if an existing server with the same URL/name + // already exist + return ( + onClose()}> + {title} + + + + { + return isValidUrl(value) ? undefined : 'Not a valid URL'; + }, + }, + }} + render={({ field, fieldState: { error } }) => ( + {error.message} + ) : !isUndefined(serverStatus) && + !serverStatus.healthy && + isNonEmptyString(field.value) ? ( + <> + Server is unreachable +
    + + ) : null + } + InputProps={{ + endAdornment: serverStatusLoading ? ( + + ) : !isUndefined(serverStatus) && serverStatus.healthy ? ( + + ) : ( + + ), + }} + /> + )} + /> + ( + + )} + /> + + ( + + + Username{' '} + + + + {error && isNonEmptyString(error.message) && ( + {error.message} + )} + + + )} + /> + ( + + + Password{' '} + + + setShowPassword(toggle)} + edge="end" + > + {showPassword ? : } + + + } + label="Access Token" + {...field} + /> + + {error && isNonEmptyString(error.message) && ( + <> + {error.message} +
    + + )} + {/* + Enter your Jellyfin password to generate a new access token, + or enter the token you want to use below. + */} +
    +
    + )} + /> + + Enter your Jellyfin password to generate a new access token. + +
    + + + OR + + + ( + + Access Token + + setShowAccessToken(toggle)} + edge="end" + > + {showAccessToken ? : } + + + } + label="Access Token" + {...field} + /> + + <> + {error && isNonEmptyString(error.message) && ( + <> + {error.message} +
    + + )} + + Manually add an access token from your Jellyfin server + + +
    +
    + )} + /> +
    +
    +
    + + + + +
    + ); +} diff --git a/web/src/components/settings/plex/PlexServerDeleteDialog.tsx b/web/src/components/settings/media_source/MediaSourceDeleteDialog.tsx similarity index 73% rename from web/src/components/settings/plex/PlexServerDeleteDialog.tsx rename to web/src/components/settings/media_source/MediaSourceDeleteDialog.tsx index 9b50a558..34af9721 100644 --- a/web/src/components/settings/plex/PlexServerDeleteDialog.tsx +++ b/web/src/components/settings/media_source/MediaSourceDeleteDialog.tsx @@ -9,30 +9,30 @@ import { import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTunarrApi } from '@/hooks/useTunarrApi.ts'; -export function PlexServerDeleteDialog({ +export function MediaSourceDeleteDialog({ open, onClose, serverId, }: PlexServerDeleteDialogProps) { const apiClient = useTunarrApi(); const queryClient = useQueryClient(); - const removePlexServerMutation = useMutation({ + const deleteMediaSourceMutation = useMutation({ mutationFn: (id: string) => { - return apiClient.deletePlexServer(null, { params: { id } }); + return apiClient.deleteMediaSource(null, { params: { id } }); }, onSuccess: () => { return queryClient.invalidateQueries({ - queryKey: ['settings', 'plex-servers'], + queryKey: ['settings', 'media-sources'], }); }, }); - const titleId = `delete-plex-server-${serverId}-title`; - const descId = `delete-plex-server-${serverId}-description`; + const titleId = `delete-media-source-${serverId}-title`; + const descId = `delete-media-source-${serverId}-description`; return ( - Delete Plex Server? + Delete Media Source? Deleting a Plex server will remove all programming from your channels @@ -45,7 +45,7 @@ export function PlexServerDeleteDialog({ Cancel - - - - ); - }; - - const removePlexServer = (id: string) => { - removePlexServerMutation.mutate(id); - setDeletePlexConfirmation(undefined); - }; - const getTableRows = () => { return map(servers, (server) => { - return ; + return ; }); }; @@ -214,31 +183,32 @@ export default function PlexSettingsPage() { + Type Name URL - {/* - UI - - - - - - */} - + Healthy? + The connection to the media source +
    + from the Tunarr server. + + } >
    - +
    @@ -290,9 +260,20 @@ export default function PlexSettingsPage() { ); }; + const handleOpenMediaSourceDialog = (source: 'plex' | 'jellyfin') => { + switch (source) { + case 'plex': + setPlexEditDialogOpen(true); + break; + case 'jellyfin': + setJellyfinEditDialogOpen(true); + break; + } + closeManualAddButtonMenu(); + }; + return ( - {renderConfirmationDialog()} - - Plex Servers + ({ + flexGrow: 1, + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + })} + > + Media Sources - + + + + handleOpenMediaSourceDialog('plex')}> + + + + + + Plex + + handleOpenMediaSourceDialog('jellyfin')} + > + + + + + + Jellyfin + + + - Add Plex Servers as content sources for your channel. "Discover" - will use the Plex login flow to discover servers associated with - your account, however you can also manually add Plex server - details using the "Manual Add" button. + Add sources of content for your channels.
    "Discover" will + attempt to automatically find sources and is supported for Plex + only.
    {renderServersTable()}
    - Plex Streaming + Streaming Options @@ -339,7 +363,6 @@ export default function PlexSettingsPage() {