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
This commit is contained in:
Christian Benincasa
2024-08-22 07:41:33 -04:00
committed by GitHub
parent 69b14fc387
commit f52df44ef0
147 changed files with 9504 additions and 4289 deletions

7
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,7 @@
# Contributing to Tunarr
## Setting up the dev environment
## Coding Standard
##

330
pnpm-lock.yaml generated
View File

@@ -100,14 +100,14 @@ importers:
specifier: ^1.0.1 specifier: ^1.0.1
version: 1.0.1 version: 1.0.1
'@mikro-orm/better-sqlite': '@mikro-orm/better-sqlite':
specifier: ^6.2.0 specifier: ^6.3.3
version: 6.2.0(@mikro-orm/core@6.2.0) version: 6.3.3(@mikro-orm/core@6.3.3)
'@mikro-orm/core': '@mikro-orm/core':
specifier: ^6.2.0 specifier: ^6.3.3
version: 6.2.0 version: 6.3.3
'@mikro-orm/migrations': '@mikro-orm/migrations':
specifier: 6.2.0 specifier: 6.3.3
version: 6.2.0(@mikro-orm/core@6.2.0)(@types/node@20.11.1)(better-sqlite3@9.4.5) version: 6.3.3(@mikro-orm/core@6.3.3)(@types/node@20.11.1)(better-sqlite3@9.4.5)
'@tunarr/shared': '@tunarr/shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
@@ -209,11 +209,11 @@ importers:
version: 3.22.4 version: 3.22.4
devDependencies: devDependencies:
'@mikro-orm/cli': '@mikro-orm/cli':
specifier: ^6.2.0 specifier: ^6.3.3
version: 6.2.0(better-sqlite3@9.4.5) version: 6.3.3(better-sqlite3@9.4.5)
'@mikro-orm/reflection': '@mikro-orm/reflection':
specifier: ^6.2.0 specifier: ^6.3.3
version: 6.2.0(@mikro-orm/core@6.2.0) version: 6.3.3(@mikro-orm/core@6.3.3)
'@types/archiver': '@types/archiver':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2 version: 6.0.2
@@ -601,6 +601,9 @@ importers:
vite: vite:
specifier: ^5.4.1 specifier: ^5.4.1
version: 5.4.1(@types/node@20.11.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: vitest:
specifier: ^2.0.5 specifier: ^2.0.5
version: 2.0.5(@types/node@20.11.1) version: 2.0.5(@types/node@20.11.1)
@@ -2571,18 +2574,20 @@ packages:
resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==} resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==}
dev: true dev: true
/@mikro-orm/better-sqlite@6.2.0(@mikro-orm/core@6.2.0): /@mikro-orm/better-sqlite@6.3.3(@mikro-orm/core@6.3.3):
resolution: {integrity: sha512-ixSZ9QRQJo408YSZFmnTMEm3jsmQRRDHEcq6lnz2YwYC/RJh8t5XObhHP4dvV7vdP9l9Y9M+AJ2UDZrRoRz24g==} resolution: {integrity: sha512-W0wyijGR8o9DvT+vVOOVH67OA94d8PT7goA4JF3Ty7Pcw2RkWUFi6/8kbKiGEI3SWU64IA5VHdFsx21vNEyi5g==}
engines: {node: '>= 18.12.0'} engines: {node: '>= 18.12.0'}
peerDependencies: peerDependencies:
'@mikro-orm/core': ^6.0.0 '@mikro-orm/core': ^6.0.0
dependencies: dependencies:
'@mikro-orm/core': 6.2.0 '@mikro-orm/core': 6.3.3
'@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5) '@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5)
better-sqlite3: 9.4.5 better-sqlite3: 9.4.5
fs-extra: 11.2.0 fs-extra: 11.2.0
sqlstring-sqlite: 0.1.1 sqlstring-sqlite: 0.1.1
transitivePeerDependencies: transitivePeerDependencies:
- libsql
- mariadb
- mysql - mysql
- mysql2 - mysql2
- pg - pg
@@ -2592,19 +2597,21 @@ packages:
- tedious - tedious
dev: false dev: false
/@mikro-orm/cli@6.2.0(better-sqlite3@9.4.5): /@mikro-orm/cli@6.3.3(better-sqlite3@9.4.5):
resolution: {integrity: sha512-R3JwXOdCT0YOMjoPckxW45izIYhA/9+WvFNXlzA5p58lUzQ3eGgITaPPTuZkf0/j7t1uwGM5fq7UD2tpGorz8Q==} resolution: {integrity: sha512-TbGuPDBt1V98c1g1hwjg+FizqdaGQ0kCAn8CccfO0nfvVYs0lBaLtRgt3w5jDot7eli7bfQ+DxfZsXRFUnmMyA==}
engines: {node: '>= 18.12.0'} engines: {node: '>= 18.12.0'}
hasBin: true hasBin: true
dependencies: dependencies:
'@jercle/yargonaut': 1.1.5 '@jercle/yargonaut': 1.1.5
'@mikro-orm/core': 6.2.0 '@mikro-orm/core': 6.3.3
'@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5) '@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5)
fs-extra: 11.2.0 fs-extra: 11.2.0
tsconfig-paths: 4.2.0 tsconfig-paths: 4.2.0
yargs: 17.7.2 yargs: 17.7.2
transitivePeerDependencies: transitivePeerDependencies:
- better-sqlite3 - better-sqlite3
- libsql
- mariadb
- mysql - mysql
- mysql2 - mysql2
- pg - pg
@@ -2614,8 +2621,8 @@ packages:
- tedious - tedious
dev: true dev: true
/@mikro-orm/core@6.2.0: /@mikro-orm/core@6.3.3:
resolution: {integrity: sha512-Kqsb3S+ab1dbo2joVFLQtdCPTyqSXqiRUnVoMOgAV5uS35nC15SUwDpQMivjLi+8Auz+vcjlbJNK5PrsV+lROQ==} resolution: {integrity: sha512-P4kqaRIKDmgmtfn1RfUYPCdjkWRsHKEo41gi5G81Ia5Jb7toQ6O3T6GOeBckGt/rZfUNMGDPMeyYqAR93NVV5w==}
engines: {node: '>= 18.12.0'} engines: {node: '>= 18.12.0'}
dependencies: dependencies:
dataloader: 2.2.2 dataloader: 2.2.2
@@ -2623,21 +2630,31 @@ packages:
esprima: 4.0.1 esprima: 4.0.1
fs-extra: 11.2.0 fs-extra: 11.2.0
globby: 11.1.0 globby: 11.1.0
mikro-orm: 6.2.0 mikro-orm: 6.3.3
reflect-metadata: 0.2.2 reflect-metadata: 0.2.2
/@mikro-orm/knex@6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5): /@mikro-orm/knex@6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5):
resolution: {integrity: sha512-jo/KKtIkwqCrfBmU0TLe4Y6kCQIbyio8PcvLtphBiMoLca6Utd8cEednsLYW5OdNyzTF3Vw/XHkm7T68MiT/cQ==} resolution: {integrity: sha512-eG3SW4GFQKA7SVj5VCkQQyxlQMC/ZUKHWhFwSDgbyF0sYTAV/78Ir0XX1m69nxK1zD+KJXA+4nf0g5HAoWQxUQ==}
engines: {node: '>= 18.12.0'} engines: {node: '>= 18.12.0'}
peerDependencies: peerDependencies:
'@mikro-orm/core': ^6.0.0 '@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: dependencies:
'@mikro-orm/core': 6.2.0 '@mikro-orm/core': 6.3.3
better-sqlite3: 9.4.5
fs-extra: 11.2.0 fs-extra: 11.2.0
knex: 3.1.0(better-sqlite3@9.4.5) knex: 3.1.0(better-sqlite3@9.4.5)
sqlstring: 2.3.3 sqlstring: 2.3.3
transitivePeerDependencies: transitivePeerDependencies:
- better-sqlite3
- mysql - mysql
- mysql2 - mysql2
- pg - pg
@@ -2646,19 +2663,21 @@ packages:
- supports-color - supports-color
- tedious - tedious
/@mikro-orm/migrations@6.2.0(@mikro-orm/core@6.2.0)(@types/node@20.11.1)(better-sqlite3@9.4.5): /@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-9Nl46QdHBto0fWXdpdfF8rqqG416uc7csA+wi0H+qvY3m4cjiCWYQcsRcmI7FkMbpJq/USkmcDS5yUSU8Sic9Q==} resolution: {integrity: sha512-wdUG1+zrofNetlsxS+qJfM+hvDuuqblElt/BgZEkPWy41B/IxPRKU+hiSUqznjD69BrqNoLrj7sIdacZjFjIBg==}
engines: {node: '>= 18.12.0'} engines: {node: '>= 18.12.0'}
peerDependencies: peerDependencies:
'@mikro-orm/core': ^6.0.0 '@mikro-orm/core': ^6.0.0
dependencies: dependencies:
'@mikro-orm/core': 6.2.0 '@mikro-orm/core': 6.3.3
'@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5) '@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5)
fs-extra: 11.2.0 fs-extra: 11.2.0
umzug: 3.8.0(@types/node@20.11.1) umzug: 3.8.1(@types/node@20.11.1)
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node' - '@types/node'
- better-sqlite3 - better-sqlite3
- libsql
- mariadb
- mysql - mysql
- mysql2 - mysql2
- pg - pg
@@ -2668,15 +2687,15 @@ packages:
- tedious - tedious
dev: false dev: false
/@mikro-orm/reflection@6.2.0(@mikro-orm/core@6.2.0): /@mikro-orm/reflection@6.3.3(@mikro-orm/core@6.3.3):
resolution: {integrity: sha512-KkepzpY/u/67wfR59990EQvvDNF2HoWeI3vusS7xEHfHLa/hzwjGcj5SG7MRhdYBvjG626FlsXaYFCIwBB9S+g==} resolution: {integrity: sha512-tNwKb1EDco7Wb1BuVrUkGPRNnGNfeJPWe/y9aFmf/77qORPonzXTQQLcW4qzq0nLsoId5VXe4G9mmlhWbP3dEQ==}
engines: {node: '>= 18.12.0'} engines: {node: '>= 18.12.0'}
peerDependencies: peerDependencies:
'@mikro-orm/core': ^6.0.0 '@mikro-orm/core': ^6.0.0
dependencies: dependencies:
'@mikro-orm/core': 6.2.0 '@mikro-orm/core': 6.3.3
globby: 11.1.0 globby: 11.1.0
ts-morph: 22.0.0 ts-morph: 23.0.0
dev: true dev: true
/@mui/base@5.0.0-beta.23(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0): /@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==} resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==}
dev: false 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: /@rollup/rollup-android-arm-eabi@4.20.0:
resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==} resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==}
cpu: [arm] cpu: [arm]
@@ -3274,6 +3307,132 @@ packages:
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
dev: true 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: /@swc/core-darwin-arm64@1.3.96:
resolution: {integrity: sha512-8hzgXYVd85hfPh6mJ9yrG26rhgzCmcLO0h1TIl8U31hwmTbfZLzRitFQ/kqMJNbIBCwmNH1RU2QcJnL3d7f69A==} resolution: {integrity: sha512-8hzgXYVd85hfPh6mJ9yrG26rhgzCmcLO0h1TIl8U31hwmTbfZLzRitFQ/kqMJNbIBCwmNH1RU2QcJnL3d7f69A==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3562,11 +3721,11 @@ packages:
resolution: {integrity: sha512-vd2A2TnM5lbnWZnHi9B+L2gPtkSeOtJOAw358JqokIH1+v2J7vUAzFVPwB/wrye12RFOurffXu33plm4uQ+JBQ==} resolution: {integrity: sha512-vd2A2TnM5lbnWZnHi9B+L2gPtkSeOtJOAw358JqokIH1+v2J7vUAzFVPwB/wrye12RFOurffXu33plm4uQ+JBQ==}
dev: false dev: false
/@ts-morph/common@0.23.0: /@ts-morph/common@0.24.0:
resolution: {integrity: sha512-m7Lllj9n/S6sOkCkRftpM7L24uvmfXQFedlW/4hENcuJH1HHm9u5EgxZb9uVjQSCGrbBWBkOGgcTxNg36r6ywA==} resolution: {integrity: sha512-c1xMmNHWpNselmpIqursHeOHHBTIsJLbB+NuovbTTRCNiTLEr/U9dbJ8qy0jd/O2x5pc3seWuOUN5R2IoOTp8A==}
dependencies: dependencies:
fast-glob: 3.3.2 fast-glob: 3.3.2
minimatch: 9.0.3 minimatch: 9.0.5
mkdirp: 3.0.1 mkdirp: 3.0.1
path-browserify: 1.0.1 path-browserify: 1.0.1
dev: true dev: true
@@ -5434,6 +5593,11 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: true dev: true
/camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
dev: true
/caniuse-lite@1.0.30001570: /caniuse-lite@1.0.30001570:
resolution: {integrity: sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw==} resolution: {integrity: sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw==}
dev: true dev: true
@@ -5864,6 +6028,22 @@ packages:
yaml: 1.10.2 yaml: 1.10.2
dev: false 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): /cosmiconfig@9.0.0(typescript@5.4.3):
resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -6338,6 +6518,13 @@ packages:
engines: {node: '>=0.4', npm: '>=1.2'} engines: {node: '>=0.4', npm: '>=1.2'}
dev: true 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: /dot-prop@5.3.0:
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -6470,6 +6657,11 @@ packages:
strip-ansi: 6.0.1 strip-ansi: 6.0.1
dev: true dev: true
/entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
dev: true
/env-paths@2.2.1: /env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -6889,6 +7081,10 @@ packages:
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
dev: true dev: true
/estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
dev: true
/estree-walker@3.0.3: /estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
dependencies: dependencies:
@@ -8920,6 +9116,12 @@ packages:
steno: 4.0.2 steno: 4.0.2
dev: false dev: false
/lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
dependencies:
tslib: 2.6.2
dev: true
/lowercase-keys@1.0.0: /lowercase-keys@1.0.0:
resolution: {integrity: sha512-RPlX0+PHuvxVDZ7xX+EBVAp4RsVxP/TdDSN2mJYdiq1Lc4Hz7EUSjUI7RZrKKlmrIzVhf6Jo2stj7++gVarS0A==} resolution: {integrity: sha512-RPlX0+PHuvxVDZ7xX+EBVAp4RsVxP/TdDSN2mJYdiq1Lc4Hz7EUSjUI7RZrKKlmrIzVhf6Jo2stj7++gVarS0A==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -9089,8 +9291,8 @@ packages:
braces: 3.0.2 braces: 3.0.2
picomatch: 2.3.1 picomatch: 2.3.1
/mikro-orm@6.2.0: /mikro-orm@6.3.3:
resolution: {integrity: sha512-/cjVKrpjtIG1bUm5BvumO6DRo0lkH52C4Waw5qzUqKrg8wl/rigXKYLoLVBPPi7bB0XNwyQbxeh5bRcWJD+y4Q==} resolution: {integrity: sha512-Hpm/LdpU8c0jNSJNnp6hJ+zwB13Db/fDCni95SJvuR3H4tFtixTrvxPRtz8iIWdLHXt/7kVdbBZdOLKj249r5A==}
engines: {node: '>= 18.12.0'} engines: {node: '>= 18.12.0'}
/miller-rabin@4.0.1: /miller-rabin@4.0.1:
@@ -9178,6 +9380,13 @@ packages:
dependencies: dependencies:
brace-expansion: 2.0.1 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: /minimist-options@3.0.2:
resolution: {integrity: sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ==} resolution: {integrity: sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -9342,6 +9551,13 @@ packages:
- webpack-dev-server - webpack-dev-server
dev: true 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: /node-abi@3.51.0:
resolution: {integrity: sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA==} resolution: {integrity: sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -11149,6 +11365,13 @@ packages:
is-fullwidth-code-point: 5.0.0 is-fullwidth-code-point: 5.0.0
dev: true 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: /sonic-boom@3.7.0:
resolution: {integrity: sha512-IudtNvSqA/ObjN97tfgNmOKyDOs4dNcg4cUUsHDebqsgb8wGBBwb31LIgShNO8fye0dFI52X1+tFoKKI6Rq1Gg==} resolution: {integrity: sha512-IudtNvSqA/ObjN97tfgNmOKyDOs4dNcg4cUUsHDebqsgb8wGBBwb31LIgShNO8fye0dFI52X1+tFoKKI6Rq1Gg==}
dependencies: dependencies:
@@ -11591,6 +11814,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
/svg-parser@2.0.4:
resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==}
dev: true
/syntax-error@1.4.0: /syntax-error@1.4.0:
resolution: {integrity: sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==} resolution: {integrity: sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==}
dependencies: dependencies:
@@ -11960,10 +12187,10 @@ packages:
webpack: 5.91.0(esbuild@0.21.5)(webpack-cli@5.1.4) webpack: 5.91.0(esbuild@0.21.5)(webpack-cli@5.1.4)
dev: true dev: true
/ts-morph@22.0.0: /ts-morph@23.0.0:
resolution: {integrity: sha512-M9MqFGZREyeb5fTl6gNHKZLqBQA0TjA1lea+CR48R8EBTDuWrNqW6ccC5QvjNR4s6wDumD3LTCjOFSp9iwlzaw==} resolution: {integrity: sha512-FcvFx7a9E8TUe6T3ShihXJLiJOiqyafzFKUO4aqIHDUCIvADdGNShcbc2W5PMr3LerXRv7mafvFZ9lRENxJmug==}
dependencies: dependencies:
'@ts-morph/common': 0.23.0 '@ts-morph/common': 0.24.0
code-block-writer: 13.0.1 code-block-writer: 13.0.1
dev: true dev: true
@@ -12338,8 +12565,8 @@ packages:
hasBin: true hasBin: true
dev: true dev: true
/umzug@3.8.0(@types/node@20.11.1): /umzug@3.8.1(@types/node@20.11.1):
resolution: {integrity: sha512-FRBvdZxllW3eUzsqG3CIfgOVChUONrKNZozNOJfvmcfBn5pMKcJjICuMMEsDLHYa/aqd7a2NtXfYEG86XHe1lQ==} resolution: {integrity: sha512-k0HjOc3b/s8vH24BUTvnaFiKhfWI9UQAGpqHDG+3866CGlBTB83Xs5wZ1io1mwYLj/GHvQ34AxKhbpYnWtkRJg==}
engines: {node: '>=12'} engines: {node: '>=12'}
dependencies: dependencies:
'@rushstack/ts-command-line': 4.19.1(@types/node@20.11.1) '@rushstack/ts-command-line': 4.19.1(@types/node@20.11.1)
@@ -12533,6 +12760,21 @@ packages:
- terser - terser
dev: true 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): /vite@5.0.11(@types/node@20.11.1):
resolution: {integrity: sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==} resolution: {integrity: sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}

View File

@@ -12,7 +12,7 @@ import { CustomShow } from './src/dao/entities/CustomShow.js';
import { CustomShowContent } from './src/dao/entities/CustomShowContent.js'; import { CustomShowContent } from './src/dao/entities/CustomShowContent.js';
import { FillerListContent } from './src/dao/entities/FillerListContent.js'; import { FillerListContent } from './src/dao/entities/FillerListContent.js';
import { FillerShow } from './src/dao/entities/FillerShow.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 { Program } from './src/dao/entities/Program.js';
import { DATABASE_LOCATION_ENV_VAR } from './src/util/constants.js'; import { DATABASE_LOCATION_ENV_VAR } from './src/util/constants.js';
import { Migration20240124115044 } from './src/migrations/Migration20240124115044.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 { Migration20240603204638 } from './src/migrations/Migration20240603204638.js';
import { Migration20240618005544 } from './src/migrations/Migration20240618005544.js'; import { Migration20240618005544 } from './src/migrations/Migration20240618005544.js';
import { LoggerFactory } from './src/util/logging/LoggerFactory.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 __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -49,7 +51,7 @@ export default defineConfig({
CustomShowContent, CustomShowContent,
FillerListContent, FillerListContent,
FillerShow, FillerShow,
PlexServerSettings, MediaSource,
Program, Program,
], ],
flushMode: 'commit', flushMode: 'commit',
@@ -127,6 +129,14 @@ export default defineConfig({
name: 'Force regenerate program_external_id table', name: 'Force regenerate program_external_id table',
class: Migration20240618005544, class: Migration20240618005544,
}, },
{
name: 'rename_plex_server_settings_table',
class: Migration20240719145409,
},
{
name: 'add_jellyfin_sources',
class: Migration20240805185042,
},
], ],
}, },
extensions: [Migrator], extensions: [Migrator],

View File

@@ -36,9 +36,9 @@
"@fastify/swagger": "^8.12.1", "@fastify/swagger": "^8.12.1",
"@fastify/swagger-ui": "^4.0.0", "@fastify/swagger-ui": "^4.0.0",
"@iptv/xmltv": "^1.0.1", "@iptv/xmltv": "^1.0.1",
"@mikro-orm/better-sqlite": "^6.2.0", "@mikro-orm/better-sqlite": "^6.3.3",
"@mikro-orm/core": "^6.2.0", "@mikro-orm/core": "^6.3.3",
"@mikro-orm/migrations": "6.2.0", "@mikro-orm/migrations": "6.3.3",
"@tunarr/shared": "workspace:*", "@tunarr/shared": "workspace:*",
"@tunarr/types": "workspace:*", "@tunarr/types": "workspace:*",
"archiver": "^7.0.1", "archiver": "^7.0.1",
@@ -74,8 +74,8 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@mikro-orm/cli": "^6.2.0", "@mikro-orm/cli": "^6.3.3",
"@mikro-orm/reflection": "^6.2.0", "@mikro-orm/reflection": "^6.3.3",
"@types/archiver": "^6.0.2", "@types/archiver": "^6.0.2",
"@types/async-retry": "^1.4.8", "@types/async-retry": "^1.4.8",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.8",

View File

@@ -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),
);
},
);
};

View File

@@ -4,28 +4,29 @@ import { ChannelLineupQuery } from '@tunarr/types/api';
import { ChannelLineupSchema } from '@tunarr/types/schemas'; import { ChannelLineupSchema } from '@tunarr/types/schemas';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { FastifyRequest } from 'fastify'; 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 os from 'node:os';
import z from 'zod'; import z from 'zod';
import { ArchiveDatabaseBackup } from '../dao/backup/ArchiveDatabaseBackup.js'; import { ArchiveDatabaseBackup } from '../dao/backup/ArchiveDatabaseBackup.js';
import { getEm } from '../dao/dataSource.js'; import { getEm } from '../dao/dataSource.js';
import { import { StreamLineupItem } from '../dao/derived_types/StreamLineup.js';
StreamLineupItem,
isContentBackedLineupIteam,
} from '../dao/derived_types/StreamLineup.js';
import { Channel } from '../dao/entities/Channel.js'; import { Channel } from '../dao/entities/Channel.js';
import { LineupCreator } from '../services/dynamic_channels/LineupCreator.js'; import { LineupCreator } from '../services/dynamic_channels/LineupCreator.js';
import { PlayerContext } from '../stream/Player.js'; import { PlayerContext } from '../stream/Player.js';
import { generateChannelContext } from '../stream/StreamProgramCalculator.js'; import { generateChannelContext } from '../stream/StreamProgramCalculator.js';
import { PlexPlayer } from '../stream/plex/PlexPlayer.js'; import { PlexPlayer } from '../stream/plex/PlexPlayer.js';
import { PlexTranscoder } from '../stream/plex/PlexTranscoder.js';
import { StreamContextChannel } from '../stream/types.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 { PlexTaskQueue } from '../tasks/TaskQueue.js';
import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js';
import { Maybe } from '../types/util.js'; import { Maybe } from '../types/util.js';
import { ifDefined, mapAsyncSeq } from '../util/index.js'; import { ifDefined, mapAsyncSeq } from '../util/index.js';
import { LoggerFactory } from '../util/logging/LoggerFactory.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 = { const ChannelQuerySchema = {
querystring: z.object({ querystring: z.object({
@@ -33,10 +34,13 @@ const ChannelQuerySchema = {
}), }),
}; };
// eslint-disable-next-line @typescript-eslint/require-await
export const debugApi: RouterPluginAsyncCallback = async (fastify) => { export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
const logger = LoggerFactory.child({ caller: import.meta }); const logger = LoggerFactory.child({ caller: import.meta });
await fastify.register(DebugJellyfinApiRouter, {
prefix: '/debug',
});
fastify.get( fastify.get(
'/debug/plex', '/debug/plex',
{ schema: ChannelQuerySchema }, { 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( async function getLineupItemForDebug(
req: FastifyRequest, req: FastifyRequest,
channel: Loaded<Channel, 'programs'>, channel: Loaded<Channel, 'programs'>,
@@ -403,17 +346,54 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
return res.send(); return res.send();
}); });
fastify.get('/debug/db/test_direct_access', async (_req, res) => { fastify.get(
// const result = await directDbAccess() '/debug/db/test_direct_access',
// .selectFrom('channel_programs') {
// .where('channel_uuid', '=', '0ff3ec64-1022-4afd-9178-3f27f1121d47') schema: {
// .innerJoin('program', 'channel_programs.program_uuid', 'program.uuid') querystring: z.object({
// .leftJoin('program_grouping', join => { id: z.string(),
// join.onRef('') }),
// }) },
// .select(['program']) },
// .execute(); async (_req, res) => {
// return res.send(result); const mediaSource = (await _req.serverCtx.mediaSourceDB.getById(
return res.send(); _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);
},
);
}; };

View File

@@ -4,28 +4,14 @@ import { isError, isNil } from 'lodash-es';
import path from 'path'; import path from 'path';
import { pipeline } from 'stream/promises'; import { pipeline } from 'stream/promises';
import { z } from 'zod'; import { z } from 'zod';
import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js';
import { Plex } from '../external/plex.js'; import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js';
import { FFMPEGInfo } from '../ffmpeg/ffmpegInfo.js'; import { FFMPEGInfo } from '../ffmpeg/ffmpegInfo.js';
import { serverOptions } from '../globals.js'; import { serverOptions } from '../globals.js';
import { GlobalScheduler } from '../services/scheduler.js'; import { GlobalScheduler } from '../services/scheduler.js';
import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js';
import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js';
import { fileExists } from '../util/fsUtil.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 { import {
isEdgeBuild, isEdgeBuild,
isNonEmptyString, isNonEmptyString,
@@ -33,7 +19,22 @@ import {
run, run,
tunarrBuild, tunarrBuild,
} from '../util/index.js'; } 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 { systemSettingsRouter } from './systemSettingsApi.js';
import { tasksApiRouter } from './tasksApi.js';
import { xmlTvSettingsRouter } from './xmltvSettingsApi.js';
import { getTunarrVersion } from '../util/version.js'; import { getTunarrVersion } from '../util/version.js';
export const apiRouter: RouterPluginAsyncCallback = async (fastify) => { export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
@@ -56,13 +57,14 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
.register(programmingApi) .register(programmingApi)
.register(debugApi) .register(debugApi)
.register(metadataApiRouter) .register(metadataApiRouter)
.register(plexServersRouter) .register(mediaSourceRouter)
.register(ffmpegSettingsRouter) .register(ffmpegSettingsRouter)
.register(plexSettingsRouter) .register(plexSettingsRouter)
.register(xmlTvSettingsRouter) .register(xmlTvSettingsRouter)
.register(hdhrSettingsRouter) .register(hdhrSettingsRouter)
.register(systemSettingsRouter) .register(systemSettingsRouter)
.register(guideRouter); .register(guideRouter)
.register(jellyfinApiRouter);
fastify.get( fastify.get(
'/version', '/version',
@@ -214,21 +216,21 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
'/plex', '/plex',
{ {
schema: { schema: {
querystring: z.object({ name: z.string(), path: z.string() }), querystring: z.object({ id: z.string(), path: z.string() }),
}, },
}, },
async (req, res) => { async (req, res) => {
const server = await req.entityManager const server = await req.entityManager
.repo(PlexServerSettings) .repo(MediaSource)
.findOne({ name: req.query.name }); .findOne({ uuid: req.query.id, type: MediaSourceType.Plex });
if (isNil(server)) { if (isNil(server)) {
return res return res
.status(404) .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); const plex = MediaSourceApiFactory().get(server);
return res.send(await plex.doGet(req.query.path)); return res.send(await plex.doGetPath(req.query.path));
}, },
); );
}; };

View File

@@ -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<FastifyReply>,
) {
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();
};

View File

@@ -1,40 +1,41 @@
import { import {
BaseErrorSchema, BaseErrorSchema,
BasicIdParamSchema, BasicIdParamSchema,
InsertPlexServerRequestSchema, InsertMediaSourceRequestSchema,
UpdatePlexServerRequestSchema, UpdateMediaSourceRequestSchema,
} from '@tunarr/types/api'; } from '@tunarr/types/api';
import { PlexServerSettingsSchema } from '@tunarr/types/schemas'; import { MediaSourceSettingsSchema } from '@tunarr/types/schemas';
import { isError, isNil, isObject } from 'lodash-es'; import { isError, isNil, isObject } from 'lodash-es';
import z from 'zod'; import z from 'zod';
import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js';
import { Plex } from '../external/plex.js'; import { PlexApiClient } from '../external/plex/PlexApiClient.js';
import { PlexApiFactory } from '../external/PlexApiFactory.js'; import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js';
import { GlobalScheduler } from '../services/scheduler.js'; import { GlobalScheduler } from '../services/scheduler.js';
import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js';
import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js';
import { firstDefined, wait } from '../util/index.js'; import { firstDefined, wait } from '../util/index.js';
import { LoggerFactory } from '../util/logging/LoggerFactory.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, fastify,
// eslint-disable-next-line @typescript-eslint/require-await // eslint-disable-next-line @typescript-eslint/require-await
) => { ) => {
const logger = LoggerFactory.child({ caller: import.meta }); const logger = LoggerFactory.child({ caller: import.meta });
fastify.get( fastify.get(
'/plex-servers', '/media-sources',
{ {
schema: { schema: {
response: { response: {
200: z.array(PlexServerSettingsSchema), 200: z.array(MediaSourceSettingsSchema),
500: z.string(), 500: z.string(),
}, },
}, },
}, },
async (req, res) => { async (req, res) => {
try { try {
const servers = await req.serverCtx.plexServerDB.getAll(); const servers = await req.serverCtx.mediaSourceDB.getAll();
const dtos = servers.map((server) => server.toDTO()); const dtos = servers.map((server) => server.toDTO());
return res.send(dtos); return res.send(dtos);
} catch (err) { } catch (err) {
@@ -45,7 +46,7 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
); );
fastify.get( fastify.get(
'/plex-servers/:id/status', '/media-sources/:id/status',
{ {
schema: { schema: {
params: BasicIdParamSchema, params: BasicIdParamSchema,
@@ -60,27 +61,44 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
}, },
async (req, res) => { async (req, res) => {
try { try {
const server = await req.serverCtx.plexServerDB.getById(req.params.id); const server = await req.serverCtx.mediaSourceDB.getById(req.params.id);
if (isNil(server)) { if (isNil(server)) {
return res.status(404).send(); return res.status(404).send();
} }
const plex = new Plex(server); let healthyPromise: Promise<boolean>;
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([ const status = await Promise.race([
(async () => { healthyPromise,
return await plex.checkServerStatus(); new Promise<false>((resolve) => {
})(),
new Promise<-1>((resolve) => {
setTimeout(() => { setTimeout(() => {
resolve(-1); resolve(false);
}, 60000); }, 60000);
}), }),
]); ]);
return res.send({ return res.send({
healthy: s === 1, healthy: status,
}); });
} catch (err) { } catch (err) {
logger.error(err); logger.error(err);
@@ -90,13 +108,15 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
); );
fastify.post( fastify.post(
'/plex-servers/foreignstatus', '/media-sources/foreignstatus',
{ {
schema: { schema: {
body: z.object({ body: z.object({
name: z.string().optional(), name: z.string().optional(),
accessToken: z.string(), accessToken: z.string(),
uri: z.string(), uri: z.string(),
type: z.union([z.literal('plex'), z.literal('jellyfin')]),
username: z.string().optional(),
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -108,36 +128,46 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
}, },
}, },
async (req, res) => { async (req, res) => {
try { let healthyPromise: Promise<boolean>;
const plex = new Plex({ switch (req.body.type) {
...req.body, case 'plex': {
name: req.body.name ?? 'unknown', const plex = new PlexApiClient({
}); ...req.body,
name: req.body.name ?? 'unknown',
});
const s: boolean = await Promise.race([ healthyPromise = plex.checkServerStatus();
(async () => { break;
const res = await plex.checkServerStatus(); }
return res === 1; case 'jellyfin': {
})(), const jellyfin = new JellyfinApiClient({
new Promise<false>((resolve) => { url: req.body.uri,
setTimeout(() => { name: req.body.name ?? 'unknown',
resolve(false); apiKey: req.body.accessToken,
}, 60000); });
}),
]);
return res.send({ healthyPromise = jellyfin.ping();
healthy: s, break;
}); }
} catch (err) {
logger.error('%O', err);
return res.status(500).send();
} }
const healthy = await Promise.race([
healthyPromise,
new Promise<false>((resolve) => {
setTimeout(() => {
resolve(false);
}, 60000);
}),
]);
return res.send({
healthy,
});
}, },
); );
fastify.delete( fastify.delete(
'/plex-servers/:id', '/media-sources/:id',
{ {
schema: { schema: {
params: BasicIdParamSchema, params: BasicIdParamSchema,
@@ -149,15 +179,14 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
}, },
async (req, res) => { async (req, res) => {
try { try {
const { deletedServer } = await req.serverCtx.plexServerDB.deleteServer( const { deletedServer } =
req.params.id, await req.serverCtx.mediaSourceDB.deleteMediaSource(req.params.id);
);
// Are these useful? What do they even do? // Are these useful? What do they even do?
req.serverCtx.eventService.push({ req.serverCtx.eventService.push({
type: 'settings-update', type: 'settings-update',
message: `Plex server ${deletedServer.name} removed.`, message: `Media source ${deletedServer.name} removed.`,
module: 'plex-server', module: 'media-source',
detail: { detail: {
serverId: req.params.id, serverId: req.params.id,
serverName: deletedServer.name, serverName: deletedServer.name,
@@ -180,8 +209,8 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
logger.error('Error %O', err); logger.error('Error %O', err);
req.serverCtx.eventService.push({ req.serverCtx.eventService.push({
type: 'settings-update', type: 'settings-update',
message: 'Error deleting plex server.', message: 'Error deleting media-source.',
module: 'plex-server', module: 'media-source',
detail: { detail: {
action: 'delete', action: 'delete',
serverId: req.params.id, serverId: req.params.id,
@@ -196,11 +225,11 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
); );
fastify.put( fastify.put(
'/plex-servers/:id', '/media-sources/:id',
{ {
schema: { schema: {
params: BasicIdParamSchema, params: BasicIdParamSchema,
body: UpdatePlexServerRequestSchema, body: UpdateMediaSourceRequestSchema,
response: { response: {
200: z.void(), 200: z.void(),
500: z.void(), 500: z.void(),
@@ -209,7 +238,9 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
}, },
async (req, res) => { async (req, res) => {
try { try {
const report = await req.serverCtx.plexServerDB.updateServer(req.body); const report = await req.serverCtx.mediaSourceDB.updateMediaSource(
req.body,
);
let modifiedPrograms = 0; let modifiedPrograms = 0;
let destroyedPrograms = 0; let destroyedPrograms = 0;
report.forEach((r) => { report.forEach((r) => {
@@ -220,8 +251,8 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
}); });
req.serverCtx.eventService.push({ req.serverCtx.eventService.push({
type: 'settings-update', type: 'settings-update',
message: `Plex server ${req.body.name} updated. ${modifiedPrograms} programs modified, ${destroyedPrograms} programs deleted`, message: `Media source ${req.body.name} updated. ${modifiedPrograms} programs modified, ${destroyedPrograms} programs deleted`,
module: 'plex-server', module: 'media-source',
detail: { detail: {
serverName: req.body.name, serverName: req.body.name,
action: 'update', action: 'update',
@@ -231,11 +262,11 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
return res.status(200).send(); return res.status(200).send();
} catch (err) { } catch (err) {
logger.error('Could not update plex server. ', err); logger.error(err, 'Could not update plex server. ');
req.serverCtx.eventService.push({ req.serverCtx.eventService.push({
type: 'settings-update', type: 'settings-update',
message: 'Error updating plex server.', message: 'Error updating media source.',
module: 'plex-server', module: 'media-source',
detail: { detail: {
action: 'update', action: 'update',
serverName: firstDefined(req, 'body', 'name'), serverName: firstDefined(req, 'body', 'name'),
@@ -249,10 +280,10 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
); );
fastify.post( fastify.post(
'/plex-servers', '/media-sources',
{ {
schema: { schema: {
body: InsertPlexServerRequestSchema, body: InsertMediaSourceRequestSchema,
response: { response: {
201: z.object({ 201: z.object({
id: z.string(), id: z.string(),
@@ -264,13 +295,13 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
}, },
async (req, res) => { async (req, res) => {
try { try {
const newServerId = await req.serverCtx.plexServerDB.addServer( const newServerId = await req.serverCtx.mediaSourceDB.addMediaSource(
req.body, req.body,
); );
req.serverCtx.eventService.push({ req.serverCtx.eventService.push({
type: 'settings-update', type: 'settings-update',
message: `Plex server ${req.body.name} added.`, message: `Media source "${req.body.name}" added.`,
module: 'plex-server', module: 'media-source',
detail: { detail: {
serverId: newServerId, serverId: newServerId,
serverName: req.body.name, serverName: req.body.name,
@@ -280,19 +311,19 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
}); });
return res.status(201).send({ id: newServerId }); return res.status(201).send({ id: newServerId });
} catch (err) { } catch (err) {
logger.error('Could not add plex server.', err); logger.error(err, 'Could not add media source');
req.serverCtx.eventService.push({ req.serverCtx.eventService.push({
type: 'settings-update', type: 'settings-update',
message: 'Error adding plex server.', message: 'Error adding media source.',
module: 'plex-server', module: 'plex-server',
detail: { detail: {
action: 'add', action: 'add',
serverName: firstDefined(req, 'body', 'name'), serverName: req.body.name,
error: isError(err) ? firstDefined(err, 'message') : 'unknown', error: isError(err) ? firstDefined(err, 'message') : 'unknown',
}, },
level: 'error', 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) => { async (req, res) => {
try { try {
const server = await req.entityManager const server = await req.entityManager
.repo(PlexServerSettings) .repo(MediaSource)
.findOne({ name: req.query.serverName }); .findOne({ name: req.query.serverName });
if (isNil(server)) { if (isNil(server)) {
return res.status(404).send({ message: 'Plex server not found.' }); 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([ const s = await Promise.race([
plex.checkServerStatus().then((res) => res === 1), plex.checkServerStatus(),
wait(15000).then(() => false), wait(15000).then(() => false),
]); ]);

View File

@@ -7,7 +7,7 @@ import {
ProgramSourceType, ProgramSourceType,
programSourceTypeFromString, programSourceTypeFromString,
} from '../dao/custom_types/ProgramSourceType'; } from '../dao/custom_types/ProgramSourceType';
import { PlexApiFactory } from '../external/PlexApiFactory'; import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory';
import { RouterPluginAsyncCallback } from '../types/serverType'; import { RouterPluginAsyncCallback } from '../types/serverType';
const externalIdSchema = z const externalIdSchema = z
@@ -70,6 +70,11 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
switch (req.query.id.externalSourceType) { switch (req.query.id.externalSourceType) {
case ProgramSourceType.PLEX: { case ProgramSourceType.PLEX: {
result = await handlePlexItem(req.query); 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) { async function handlePlexItem(query: ExternalMetadataQuery) {
const plexApi = await PlexApiFactory().getOrSet(query.id.externalSourceId); const plexApi = await MediaSourceApiFactory().getOrSet(
query.id.externalSourceId,
);
if (isNil(plexApi)) { if (isNil(plexApi)) {
return null; return null;
@@ -130,4 +137,26 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
return null; 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;
}
}; };

View File

@@ -17,16 +17,23 @@ import z from 'zod';
import { import {
ProgramSourceType, ProgramSourceType,
programSourceTypeFromString, programSourceTypeFromString,
programSourceTypeToMediaSource,
} from '../dao/custom_types/ProgramSourceType.js'; } from '../dao/custom_types/ProgramSourceType.js';
import { getEm } from '../dao/dataSource.js'; import { getEm } from '../dao/dataSource.js';
import { Program, ProgramType } from '../dao/entities/Program.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 { TruthyQueryParam } from '../types/schemas.js';
import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js';
import { ProgramGrouping } from '../dao/entities/ProgramGrouping.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 { 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({ const LookupExternalProgrammingSchema = z.object({
externalId: z externalId: z
@@ -94,30 +101,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.status(404).send(); return res.status(404).send();
} }
const handlePlexItem = async ( const handleResult = async (mediaSource: MediaSource, result: string) => {
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(),
});
if (req.query.proxy) { if (req.query.proxy) {
try { try {
const proxyRes = await axios.request<stream.Readable>({ const proxyRes = await axios.request<stream.Readable>({
@@ -141,8 +125,9 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
} catch (e) { } catch (e) {
if (isAxiosError(e) && e.response?.status === 404) { if (isAxiosError(e) && e.response?.status === 404) {
logger.error( logger.error(
'Error retrieving thumb from Plex at url: %s. Status: 404', 'Error retrieving thumb from %s at url: %s. Status: 404',
result.replaceAll(server.accessToken, 'REDACTED_TOKEN'), mediaSource.type,
result.replaceAll(mediaSource.accessToken, 'REDACTED_TOKEN'),
); );
return res.status(404).send(); return res.status(404).send();
} }
@@ -154,37 +139,118 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
}; };
if (!isNil(program)) { 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) { switch (program.sourceType) {
case ProgramSourceType.PLEX: { case ProgramSourceType.PLEX: {
let keyToUse = program.externalKey; return handleResult(
if (program.type === ProgramType.Track && !isNil(program.album)) { mediaSource,
ifDefined( PlexApiClient.getThumbUrl({
find( uri: mediaSource.uri,
program.album.$.externalRefs, itemKey: keyToUse,
(ref) => accessToken: mediaSource.accessToken,
ref.sourceType === ProgramExternalIdType.PLEX && height: req.query.height,
ref.externalSourceId === program.externalSourceId, width: req.query.width,
), upscale: req.query.upscale.toString(),
(ref) => { }),
keyToUse = ref.externalKey; );
},
);
}
return handlePlexItem(keyToUse, program.externalSourceId);
} }
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: default:
return res.status(405).send(); return res.status(405).send();
} }
} else { } else {
// We can assume that we have a grouping here... // We can assume that we have a grouping here...
// We only support Plex now // We only support Plex now
const source = find(grouping!.externalRefs, { const source = find(
sourceType: ProgramExternalIdType.PLEX, grouping!.externalRefs,
}); (ref) =>
ref.sourceType === ProgramExternalIdType.PLEX ||
ref.sourceType === ProgramExternalIdType.JELLYFIN,
);
if (isNil(source)) { if (isNil(source)) {
return res.status(500).send(); 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) => { async (req, res) => {
const em = getEm(); 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)) { if (isNil(program)) {
return res.status(404).send(); return res.status(404).send();
} }
const plexServers = await req.serverCtx.plexServerDB.getAll(); const mediaSources = await req.serverCtx.mediaSourceDB.getAll();
switch (program.sourceType) { const externalId = program.externalIds.$.find(
case ProgramSourceType.PLEX: { (eid) =>
if (isNil(program.externalKey)) { eid.sourceType === ProgramExternalIdType.JELLYFIN ||
return res.status(500).send(); eid.sourceType === ProgramExternalIdType.PLEX,
} );
const server = find(plexServers, { name: program.externalSourceId }); if (!externalId) {
if (isNil(server) || isNil(server.clientIdentifier)) { 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(); return res.status(404).send();
} }
@@ -234,6 +318,14 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.send({ url }); 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(); return res.redirect(302, url).send();
} }
} }

View File

@@ -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<ContentProgram['externalSourceType']>,
Record<string, ContentProgram[]>
>;
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<keyof any, unknown>,
KeyType = T extends Record<infer K, unknown> ? 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<ProgramGroupingType, GroupingIdAndPlexInfo[]>
> = {
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<string, Set<string>>,
)
.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<ProgramGrouping, 'externalRefs', 'uuid' | 'type'>
>(),
);
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<ProgramGrouping, 'externalRefs', 'uuid' | 'type'>
>,
);
// 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<ProgramGrouping>(),
{
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<ProgramGroupingType, GroupingIdAndPlexInfo[]> =
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<V>(): Record<ProgramGroupingType, V[]> {
return {
[ProgramGroupingType.MusicAlbum]: [],
[ProgramGroupingType.MusicArtist]: [],
[ProgramGroupingType.TvShow]: [],
[ProgramGroupingType.TvShowSeason]: [],
};
}
function mergeGroupings<V>(
l: Record<ProgramGroupingType, V[]>,
r: Record<ProgramGroupingType, V[]>,
): Record<ProgramGroupingType, V[]> {
return reduce(
r,
(prev, curr, key) => ({
...prev,
[key]: [...prev[key as ProgramGroupingType], ...curr],
}),
l,
);
}

View File

@@ -13,24 +13,31 @@ import {
flatten, flatten,
forEach, forEach,
isEmpty, isEmpty,
isUndefined,
map, map,
partition, partition,
} from 'lodash-es'; } from 'lodash-es';
import { PlexQueryResult } from '../external/plex.js'; import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js';
import { PlexApiFactory } from '../external/PlexApiFactory.js';
import { Maybe } from '../types/util.js'; import { Maybe } from '../types/util.js';
import { asyncPool } from '../util/asyncPool.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 { LoggerFactory } from '../util/logging/LoggerFactory.js';
import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js'; import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js';
import { getEm } from './dataSource.js'; import { getEm } from './dataSource.js';
import { ProgramType } from './entities/Program.js'; import { ProgramType } from './entities/Program.js';
import { import {
ProgramGrouping, ProgramGrouping,
programGroupingTypeForJellyfinType,
programGroupingTypeForString, programGroupingTypeForString,
} from './entities/ProgramGrouping.js'; } from './entities/ProgramGrouping.js';
import { ProgramGroupingExternalId } from './entities/ProgramGroupingExternalId.js'; import { ProgramGroupingExternalId } from './entities/ProgramGroupingExternalId.js';
import { ProgramDB } from './programDB.js'; import { ProgramDB } from './programDB.js';
import { QueryResult } from '../external/BaseApiClient.js';
import { JellyfinItem, isJellyfinType } from '@tunarr/types/jellyfin';
export class ProgramGroupingCalculator { export class ProgramGroupingCalculator {
#logger = LoggerFactory.child({ className: ProgramGroupingCalculator.name }); #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) { if (!plexApi) {
return; return;
@@ -245,6 +252,220 @@ export class ProgramGroupingCalculator {
await em.flush(); 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<ProgramGrouping>;
if (maybeGrandparentAndRef) {
newOrUpdatedGrandparent = maybeGrandparentAndRef[0];
}
const newOrUpdatedParents: ProgramGrouping[] = [];
const parentGroupingsByRef: Record<string, string> = {};
// 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( private handlePlexGrouping(
existing: Loaded< existing: Loaded<
ProgramGrouping, ProgramGrouping,
@@ -252,7 +473,7 @@ export class ProgramGroupingCalculator {
'uuid' | 'type', 'uuid' | 'type',
never never
> | null, > | null,
queryResult: PlexQueryResult<PlexMedia>, queryResult: QueryResult<PlexMedia>,
plexServerName: string, plexServerName: string,
): Maybe<[ProgramGrouping, ProgramGroupingExternalId]> { ): Maybe<[ProgramGrouping, ProgramGroupingExternalId]> {
if (queryResult.type === 'error') { if (queryResult.type === 'error') {
@@ -338,4 +559,94 @@ export class ProgramGroupingCalculator {
return [entity, ref] as const; return [entity, ref] as const;
} }
private handleJellyfinGrouping(
existing: Loaded<
ProgramGrouping,
'externalRefs',
'uuid' | 'type',
never
> | null,
queryResult: QueryResult<Maybe<JellyfinItem>>,
jellyfinServerName: string,
): Maybe<[ProgramGrouping, ProgramGroupingExternalId]> {
if (queryResult.type === 'error') {
this.#logger.error(
'Error requesting item from Jellyfin: %O %s',
queryResult.code,
queryResult.message ?? '<no 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<ProgramGrouping, 'title' | 'summary' | 'icon'> = {
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;
}
} }

View File

@@ -1,5 +1,7 @@
import { ExternalIdType } from '@tunarr/types/schemas'; import { ExternalIdType } from '@tunarr/types/schemas';
import { enumKeys } from '../../util/enumUtil.js'; import { enumKeys } from '../../util/enumUtil.js';
import { ProgramSourceType } from './ProgramSourceType.js';
import { MediaSourceType } from '../entities/MediaSource.js';
export enum ProgramExternalIdType { export enum ProgramExternalIdType {
PLEX = 'plex', PLEX = 'plex',
@@ -7,6 +9,7 @@ export enum ProgramExternalIdType {
TMDB = 'tmdb', TMDB = 'tmdb',
IMDB = 'imdb', IMDB = 'imdb',
TVDB = 'tvdb', TVDB = 'tvdb',
JELLYFIN = 'jellyfin',
} }
export function programExternalIdTypeFromExternalIdType( export function programExternalIdTypeFromExternalIdType(
@@ -15,6 +18,30 @@ export function programExternalIdTypeFromExternalIdType(
return programExternalIdTypeFromString(str)!; 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( export function programExternalIdTypeFromString(
str: string, str: string,
): ProgramExternalIdType | undefined { ): ProgramExternalIdType | undefined {
@@ -26,3 +53,16 @@ export function programExternalIdTypeFromString(
} }
return; 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;
}
}

View File

@@ -1,7 +1,9 @@
import { enumKeys } from '../../util/enumUtil.js'; import { enumKeys } from '../../util/enumUtil.js';
import { MediaSourceType } from '../entities/MediaSource.js';
export enum ProgramSourceType { export enum ProgramSourceType {
PLEX = 'plex', PLEX = 'plex',
JELLYFIN = 'jellyfin',
} }
export function programSourceTypeFromString( export function programSourceTypeFromString(
@@ -15,3 +17,21 @@ export function programSourceTypeFromString(
} }
return; 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;
}
}

View File

@@ -3,6 +3,7 @@
// active streaming session // active streaming session
import { z } from 'zod'; import { z } from 'zod';
import { MediaSourceType } from '../entities/MediaSource.js';
const baseStreamLineupItemSchema = z.object({ const baseStreamLineupItemSchema = z.object({
originalTimestamp: z.number().nonnegative().optional(), originalTimestamp: z.number().nonnegative().optional(),
@@ -63,6 +64,7 @@ const ProgramTypeEnum = z.enum(['movie', 'episode', 'track']);
const BaseContentBackedStreamLineupItemSchema = const BaseContentBackedStreamLineupItemSchema =
baseStreamLineupItemSchema.extend({ baseStreamLineupItemSchema.extend({
// ID in the program DB table
programId: z.string().uuid(), programId: z.string().uuid(),
// These are taken from the Program DB entity // These are taken from the Program DB entity
plexFilePath: z.string().optional(), plexFilePath: z.string().optional(),
@@ -70,6 +72,7 @@ const BaseContentBackedStreamLineupItemSchema =
filePath: z.string().optional(), filePath: z.string().optional(),
externalKey: z.string(), externalKey: z.string(),
programType: ProgramTypeEnum, programType: ProgramTypeEnum,
externalSource: z.nativeEnum(MediaSourceType),
}); });
const CommercialStreamLineupItemSchema = const CommercialStreamLineupItemSchema =

View File

@@ -153,7 +153,7 @@ const defaultProgramJoins: ProgramJoins = {
type ProgramFields = readonly `program.${keyof RawProgram}`[]; type ProgramFields = readonly `program.${keyof RawProgram}`[];
const AllProgramFields: ProgramFields = [ export const AllProgramFields: ProgramFields = [
'program.albumName', 'program.albumName',
'program.albumUuid', 'program.albumUuid',
'program.artistName', 'program.artistName',

View File

@@ -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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -22,6 +22,7 @@ import { CustomShow } from './CustomShow.js';
import { FillerShow } from './FillerShow.js'; import { FillerShow } from './FillerShow.js';
import { ProgramExternalId } from './ProgramExternalId.js'; import { ProgramExternalId } from './ProgramExternalId.js';
import { ProgramGrouping } from './ProgramGrouping.js'; import { ProgramGrouping } from './ProgramGrouping.js';
import { JellyfinItemKind } from '@tunarr/types/jellyfin';
/** /**
* Program represents a 'playable' entity. A movie, episode, or music track * Program represents a 'playable' entity. A movie, episode, or music track
@@ -259,3 +260,17 @@ export function programTypeFromString(str: string): ProgramType | undefined {
} }
return; 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;
}

View File

@@ -9,6 +9,7 @@ import { isNonEmptyString } from '../../util/index.js';
import { ProgramExternalIdType } from '../custom_types/ProgramExternalIdType.js'; import { ProgramExternalIdType } from '../custom_types/ProgramExternalIdType.js';
import { BaseEntity } from './BaseEntity.js'; import { BaseEntity } from './BaseEntity.js';
import { Program } from './Program.js'; import { Program } from './Program.js';
import { createExternalId } from '@tunarr/shared';
/** /**
* References to external sources for a {@link Program} * References to external sources for a {@link Program}
@@ -30,7 +31,7 @@ import { Program } from './Program.js';
name: 'unique_program_multi_external_id', name: 'unique_program_multi_external_id',
properties: ['program', 'sourceType', 'externalSourceId'], properties: ['program', 'sourceType', 'externalSourceId'],
expression: 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 { export class ProgramExternalId extends BaseEntity {
@Enum(() => ProgramExternalIdType) @Enum(() => ProgramExternalIdType)
@@ -88,9 +89,11 @@ export class ProgramExternalId extends BaseEntity {
} }
toExternalIdString(): string { toExternalIdString(): string {
return `${this.sourceType.toString()}|${this.externalSourceId ?? ''}|${ return createExternalId(
this.externalKey this.sourceType,
}`; this.externalSourceId ?? '',
this.externalKey,
);
} }
toKnexInsertData() { toKnexInsertData() {

View File

@@ -11,6 +11,7 @@ import { ProgramGroupingExternalId } from './ProgramGroupingExternalId.js';
import { Maybe } from '../../types/util.js'; import { Maybe } from '../../types/util.js';
import { find } from 'lodash-es'; import { find } from 'lodash-es';
import { enumKeys } from '../../util/enumUtil.js'; import { enumKeys } from '../../util/enumUtil.js';
import { JellyfinItemKind } from '@tunarr/types/jellyfin';
/** /**
* A ProgramGrouping represents some logical collection of Programs. * A ProgramGrouping represents some logical collection of Programs.
@@ -89,3 +90,20 @@ export function programGroupingTypeForString(
} }
return; return;
} }
export function programGroupingTypeForJellyfinType(
t: JellyfinItemKind,
): Maybe<ProgramGroupingType> {
switch (t) {
case 'Season':
return ProgramGroupingType.TvShowSeason;
case 'Series':
return ProgramGroupingType.TvShow;
case 'MusicArtist':
return ProgramGroupingType.MusicArtist;
case 'MusicAlbum':
return ProgramGroupingType.MusicAlbum;
default:
return;
}
}

View File

@@ -31,7 +31,7 @@ import {
sortBy, sortBy,
} from 'lodash-es'; } from 'lodash-es';
import path from 'path'; import path from 'path';
import { PlexApiFactory } from '../../external/PlexApiFactory.js'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js';
import { globalOptions } from '../../globals.js'; import { globalOptions } from '../../globals.js';
import { serverContext } from '../../serverContext.js'; import { serverContext } from '../../serverContext.js';
import { GlobalScheduler } from '../../services/scheduler.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 { LoggerFactory } from '../../util/logging/LoggerFactory.js';
import { EntityManager, withDb } from '../dataSource.js'; import { EntityManager, withDb } from '../dataSource.js';
import { CachedImage } from '../entities/CachedImage.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 { Settings, SettingsDB, defaultXmlTvSettings } from '../settings.js';
import { import {
LegacyChannelMigrator, LegacyChannelMigrator,
@@ -320,9 +320,9 @@ export class LegacyDbMigrator {
// will take care of that -- we may want to do it here if we want // will take care of that -- we may want to do it here if we want
// to remove the fixer eventually, though. // to remove the fixer eventually, though.
for (const entity of entities) { for (const entity of entities) {
const plexApi = PlexApiFactory().get(entity); const plexApi = MediaSourceApiFactory().get(entity);
const status = await plexApi.checkServerStatus(); const healthy = await plexApi.checkServerStatus();
if (status === 1) { if (healthy) {
this.logger.debug( this.logger.debug(
'Plex server name: %s url: %s healthy', 'Plex server name: %s url: %s healthy',
entity.name, entity.name,

View File

@@ -12,13 +12,13 @@ import {
PlexTvShow, PlexTvShow,
} from '@tunarr/types/plex'; } from '@tunarr/types/plex';
import { first, groupBy, isNil, isNull, isUndefined, keys } from 'lodash-es'; import { first, groupBy, isNil, isNull, isUndefined, keys } from 'lodash-es';
import { Plex } from '../../external/plex'; import { PlexApiClient } from '../../external/plex/PlexApiClient';
import { PlexApiFactory } from '../../external/PlexApiFactory'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory';
import { isNonEmptyString, wait } from '../../util'; import { isNonEmptyString, wait } from '../../util';
import { LoggerFactory } from '../../util/logging/LoggerFactory'; import { LoggerFactory } from '../../util/logging/LoggerFactory';
import { ProgramSourceType } from '../custom_types/ProgramSourceType'; import { ProgramSourceType } from '../custom_types/ProgramSourceType';
import { getEm } from '../dataSource'; import { getEm } from '../dataSource';
import { PlexServerSettings } from '../entities/PlexServerSettings'; import { MediaSource } from '../entities/MediaSource';
import { Program, ProgramType } from '../entities/Program'; import { Program, ProgramType } from '../entities/Program';
import { import {
ProgramGrouping, ProgramGrouping,
@@ -84,7 +84,7 @@ export class LegacyMetadataBackfiller {
programs: Program[], programs: Program[],
) { ) {
const em = getEm(); const em = getEm();
const server = await em.findOne(PlexServerSettings, { name: serverName }); const server = await em.findOne(MediaSource, { name: serverName });
if (isNil(server)) { if (isNil(server)) {
this.logger.warn( this.logger.warn(
'Could not find plex server details for server %s', 'Could not find plex server details for server %s',
@@ -163,14 +163,14 @@ export class LegacyMetadataBackfiller {
} }
// Otherwise, we need to go and find details... // 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 // This where the types have to diverge, because the Plex
// API types differ. // API types differ.
if (type === ProgramType.Episode) { if (type === ProgramType.Episode) {
// Lookup the episode in Plex // Lookup the episode in Plex
const plexResult = await plex.doGet<PlexEpisodeView>( const plexResult = await plex.doGetPath<PlexEpisodeView>(
'/library/metadata/' + externalKey, '/library/metadata/' + externalKey,
); );
@@ -197,10 +197,10 @@ export class LegacyMetadataBackfiller {
// These will be used on subsequent iterations to identify matches // These will be used on subsequent iterations to identify matches
// without hitting Plex. // without hitting Plex.
if (!isUndefined(show)) { if (!isUndefined(show)) {
const seasons = await plex.doGet<PlexSeasonView>(show.key); const seasons = await plex.doGetPath<PlexSeasonView>(show.key);
if (!isUndefined(seasons?.Metadata)) { if (!isUndefined(seasons?.Metadata)) {
for (const season of seasons.Metadata) { for (const season of seasons.Metadata) {
const seasonEpisodes = await plex.doGet<PlexEpisodeView>( const seasonEpisodes = await plex.doGetPath<PlexEpisodeView>(
season.key, season.key,
); );
if (!isUndefined(seasonEpisodes?.Metadata)) { if (!isUndefined(seasonEpisodes?.Metadata)) {
@@ -302,7 +302,7 @@ export class LegacyMetadataBackfiller {
} }
} else { } else {
// Lookup the episode in Plex // Lookup the episode in Plex
const plexResult = await plex.doGet<PlexMusicTrackView>( const plexResult = await plex.doGetPath<PlexMusicTrackView>(
'/library/metadata/' + externalKey, '/library/metadata/' + externalKey,
); );
@@ -329,10 +329,10 @@ export class LegacyMetadataBackfiller {
// These will be used on subsequent iterations to identify matches // These will be used on subsequent iterations to identify matches
// without hitting Plex. // without hitting Plex.
if (!isUndefined(artist)) { if (!isUndefined(artist)) {
const albums = await plex.doGet<PlexMusicAlbumView>(artist.key); const albums = await plex.doGetPath<PlexMusicAlbumView>(artist.key);
if (!isUndefined(albums?.Metadata)) { if (!isUndefined(albums?.Metadata)) {
for (const album of albums.Metadata) { for (const album of albums.Metadata) {
const albumTracks = await plex.doGet<PlexMusicTrackView>( const albumTracks = await plex.doGetPath<PlexMusicTrackView>(
album.key, album.key,
); );
if (!isUndefined(albumTracks?.Metadata)) { if (!isUndefined(albumTracks?.Metadata)) {
@@ -443,7 +443,7 @@ export class LegacyMetadataBackfiller {
Metadata: InferredMetadataType[]; Metadata: InferredMetadataType[];
}, },
>( >(
plex: Plex, plex: PlexApiClient,
ratingKey: string, ratingKey: string,
cb: (item: InferredMetadataType) => ProgramGrouping | undefined, cb: (item: InferredMetadataType) => ProgramGrouping | undefined,
) { ) {
@@ -485,8 +485,11 @@ export class LegacyMetadataBackfiller {
InferredPlexType extends { Metadata: InferredMetadataType[] } = { InferredPlexType extends { Metadata: InferredMetadataType[] } = {
Metadata: InferredMetadataType[]; Metadata: InferredMetadataType[];
}, },
>(plex: Plex, ratingKey: string): Promise<InferredMetadataType | undefined> { >(
const plexResult = await plex.doGet<InferredPlexType>( plex: PlexApiClient,
ratingKey: string,
): Promise<InferredMetadataType | undefined> {
const plexResult = await plex.doGetPath<InferredPlexType>(
'/library/metadata/' + ratingKey, '/library/metadata/' + ratingKey,
); );

View File

@@ -1,13 +1,20 @@
import { import {
InsertPlexServerRequest, InsertMediaSourceRequest,
UpdatePlexServerRequest, UpdateMediaSourceRequest,
} from '@tunarr/types/api'; } from '@tunarr/types/api';
import ld, { isNil, isUndefined, keys, map, mapValues } from 'lodash-es'; import ld, { isNil, isUndefined, keys, map, mapValues } from 'lodash-es';
import { groupByUniq } from '../util/index.js'; import { groupByUniq } from '../util/index.js';
import { ChannelDB } from './channelDb.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 { 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'; import { Program } from './entities/Program.js';
//hmnn this is more of a "PlexServerService"... //hmnn this is more of a "PlexServerService"...
@@ -22,7 +29,8 @@ type Report = {
destroyedPrograms: number; destroyedPrograms: number;
modifiedPrograms: number; modifiedPrograms: number;
}; };
export class PlexServerDB {
export class MediaSourceDB {
#channelDb: ChannelDB; #channelDb: ChannelDB;
constructor(channelDb: ChannelDB) { constructor(channelDb: ChannelDB) {
@@ -31,25 +39,33 @@ export class PlexServerDB {
async getAll() { async getAll() {
const em = getEm(); const em = getEm();
return em.repo(PlexServerSettingsEntity).findAll(); return em.repo(MediaSource).findAll();
} }
async getById(id: string) { 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() return getEm()
.repo(PlexServerSettingsEntity) .repo(MediaSource)
.findOne({ .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 deletedServer = await getEm().transactional(async (em) => {
const ref = em.getReference(PlexServerSettingsEntity, id); const ref = em.getReference(MediaSource, id);
const existing = await em.findOneOrFail(PlexServerSettingsEntity, ref, { const existing = await em.findOneOrFail(MediaSource, ref, {
populate: ['uuid', 'name'], populate: ['uuid', 'name'],
}); });
em.remove(ref); em.remove(ref);
@@ -60,39 +76,51 @@ export class PlexServerDB {
if (!removePrograms) { if (!removePrograms) {
reports = []; reports = [];
} else { } else {
reports = await this.fixupProgramReferences(deletedServer.name); reports = await this.fixupProgramReferences(
deletedServer.name,
programSourceTypeFromMediaSource(deletedServer.type),
);
} }
return { deletedServer, reports }; return { deletedServer, reports };
} }
async updateServer(server: UpdatePlexServerRequest) { async updateMediaSource(server: UpdateMediaSourceRequest) {
const em = getEm(); const em = getEm();
const repo = em.repo(PlexServerSettingsEntity); const repo = em.repo(MediaSource);
const id = server.id; const id = server.id;
if (isNil(id)) { if (isNil(id)) {
throw Error('Missing server id from request'); throw Error('Missing server id from request');
} }
const s = await repo.findOne(id); const s = await repo.findOne({ uuid: id });
if (isNil(s)) { if (isNil(s)) {
throw Error("Server doesn't exist."); 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, { em.assign(s, {
name: server.name, name: server.name,
uri: server.uri, uri: server.uri,
accessToken: server.accessToken, accessToken: server.accessToken,
sendGuideUpdates: server.sendGuideUpdates ?? false, sendGuideUpdates,
sendChannelUpdates: server.sendChannelUpdates ?? false, sendChannelUpdates,
updatedAt: new Date(), updatedAt: new Date(),
}); });
this.normalizeServer(s); 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 repo.upsert(s);
await em.flush(); await em.flush();
@@ -100,45 +128,77 @@ export class PlexServerDB {
return report; return report;
} }
async addServer(server: InsertPlexServerRequest): Promise<string> { async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
const em = getEm(); const em = getEm();
const repo = em.repo(PlexServerSettingsEntity); const repo = em.repo(MediaSource);
const name = isUndefined(server.name) ? 'plex' : server.name; const name = isUndefined(server.name) ? 'plex' : server.name;
// let i = 2; const sendGuideUpdates =
// const prefix = name; server.type === 'plex' ? server.sendGuideUpdates ?? false : false;
// let resultName = name; const sendChannelUpdates =
// while (this.doesNameExist(resultName)) { server.type === 'plex' ? server.sendChannelUpdates ?? false : false;
// resultName = `${prefix}${i}`;
// i += 1;
// }
// name = resultName;
const sendGuideUpdates = server.sendGuideUpdates ?? false;
const sendChannelUpdates = server.sendChannelUpdates ?? false;
const index = await repo.count(); const index = await repo.count();
const newServer = em.create(PlexServerSettingsEntity, { const newServer = em.create(MediaSource, {
...server, ...server,
name, name,
sendGuideUpdates, sendGuideUpdates,
sendChannelUpdates, sendChannelUpdates,
index, index,
type: mediaSourceTypeFromApi(server.type),
}); });
this.normalizeServer(newServer); 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( private async fixupProgramReferences(
serverName: string, 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 em = getEm();
const allPrograms = await em const allPrograms = await em
.repo(Program) .repo(Program)
.find( .find(
{ sourceType: ProgramSourceType.PLEX, externalSourceId: serverName }, { sourceType: serverType, externalSourceId: serverName },
{ populate: ['fillerShows', 'channels', 'customShows'] }, { populate: ['fillerShows', 'channels', 'customShows'] },
); );
@@ -234,7 +294,7 @@ export class PlexServerDB {
return [...channelReports, ...fillerReports, ...customShowReports]; return [...channelReports, ...fillerReports, ...customShowReports];
} }
private fixupProgram(program: Program, newServer: PlexServerSettingsEntity) { private fixupProgram(program: Program, newServer: MediaSource) {
let modified = false; let modified = false;
const fixIcon = (icon: string | undefined) => { const fixIcon = (icon: string | undefined) => {
if ( if (
@@ -261,7 +321,7 @@ export class PlexServerDB {
return modified; return modified;
} }
private normalizeServer(server: PlexServerSettingsEntity) { private normalizeServer(server: MediaSource) {
while (server.uri.endsWith('/')) { while (server.uri.endsWith('/')) {
server.uri = server.uri.slice(0, -1); server.uri = server.uri.slice(0, -1);
} }

View File

@@ -1,4 +1,5 @@
import { ref } from '@mikro-orm/core'; import { ref } from '@mikro-orm/core';
import { createExternalId } from '@tunarr/shared';
import { import {
ChannelProgram, ChannelProgram,
ContentProgram, ContentProgram,
@@ -6,13 +7,15 @@ import {
isContentProgram, isContentProgram,
isCustomProgram, isCustomProgram,
} from '@tunarr/types'; } from '@tunarr/types';
import { PlexEpisode, PlexMedia, PlexMusicTrack } from '@tunarr/types/plex'; import { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import ld, { import ld, {
compact, compact,
filter, filter,
find,
forEach, forEach,
isNil, isNil,
isUndefined,
map, map,
partition, partition,
reduce, reduce,
@@ -21,8 +24,8 @@ import ld, {
import { performance } from 'perf_hooks'; import { performance } from 'perf_hooks';
import { GlobalScheduler } from '../services/scheduler.js'; import { GlobalScheduler } from '../services/scheduler.js';
import { ReconcileProgramDurationsTask } from '../tasks/ReconcileProgramDurationsTask.js'; import { ReconcileProgramDurationsTask } from '../tasks/ReconcileProgramDurationsTask.js';
import { SavePlexProgramExternalIdsTask } from '../tasks/SavePlexProgramExternalIdsTask.js'; import { SavePlexProgramExternalIdsTask } from '../tasks/plex/SavePlexProgramExternalIdsTask.js';
import { PlexTaskQueue } from '../tasks/TaskQueue.js'; import { JellyfinTaskQueue, PlexTaskQueue } from '../tasks/TaskQueue.js';
import { SavePlexProgramGroupingsTask } from '../tasks/plex/SavePlexProgramGroupingsTask.js'; import { SavePlexProgramGroupingsTask } from '../tasks/plex/SavePlexProgramGroupingsTask.js';
import { ProgramMinterFactory } from '../util/ProgramMinter.js'; import { ProgramMinterFactory } from '../util/ProgramMinter.js';
import { groupByUniqFunc, isNonEmptyString } from '../util/index.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 { time, timeNamedAsync } from '../util/perf.js';
import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js'; import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js';
import { getEm } from './dataSource.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 { ProgramExternalId } from './entities/ProgramExternalId.js';
import { upsertProgramExternalIds_deprecated } from './programExternalIdHelpers.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( export async function upsertContentPrograms(
programs: ChannelProgram[], programs: ChannelProgram[],
@@ -58,7 +70,102 @@ export async function upsertContentPrograms(
) )
.value(); .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 const programsToPersist = ld
.chain(contentPrograms) .chain(contentPrograms)
.map((p) => { .map((p) => {
@@ -72,31 +179,6 @@ export async function upsertContentPrograms(
}) })
.value(); .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<string, PlexMedia>,
),
};
},
{} as Record<string, PlexMedia>,
)
.value();
const programInfoByUniqueId = groupByUniqFunc( const programInfoByUniqueId = groupByUniqFunc(
programsToPersist, programsToPersist,
({ program }) => program.uniqueId(), ({ 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 const programExternalIds = ld
.chain(upsertedPrograms) .chain(upsertedPrograms)
.flatMap((program) => { .flatMap((program) => {
@@ -139,19 +218,113 @@ export async function upsertContentPrograms(
}) })
.value(); .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) .chain(programExternalIds)
.map((externalId) => { .map((externalId) => {
const plexMedia = const media =
plexRatingExternalIdToMedia[externalId.toExternalIdString()]; sourceExternalIdToOriginalProgram[externalId.toExternalIdString()];
if ( if (
plexMedia && media &&
(plexMedia.type === 'track' || plexMedia.type === 'episode') media.sourceType === 'plex' &&
(media.program.type === 'track' || media.program.type === 'episode')
) { ) {
return { return {
externalId, externalId,
plexMedia, plexMedia: media.program,
}; };
} }
@@ -180,8 +353,9 @@ export async function upsertContentPrograms(
) )
.value(); .value();
// TODO Need to implement this for Jellyfin
setImmediate(() => { setImmediate(() => {
forEach(externalIdsByGrandparentId, (externalIds, grandparentId) => { forEach(plexExternalIdsByGrandparentId, (externalIds, grandparentId) => {
const parentIds = map( const parentIds = map(
externalIds, externalIds,
(eid) => eid.plexMedia.parentRatingKey, (eid) => eid.plexMedia.parentRatingKey,
@@ -203,81 +377,162 @@ export async function upsertContentPrograms(
).catch((e) => console.error(e)); ).catch((e) => console.error(e));
}); });
}); });
}
const [requiredExternalIds, backgroundExternalIds] = partition( function scheduleJellyfinProgramGroupingTasks(
programExternalIds, programExternalIds: ProgramExternalId[],
(p) => sourceExternalIdToOriginalProgram: Record<
p.sourceType === ProgramExternalIdType.PLEX || string,
p.sourceType === ProgramExternalIdType.PLEX_GUID, 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 if (
// TODO: We could optimize further here by only saving IDs necessary for streaming media &&
await timeNamedAsync('upsert external ids', logger, () => media.sourceType === 'jellyfin' &&
upsertProgramExternalIds_deprecated(requiredExternalIds), (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(() => { setImmediate(() => {
upsertProgramExternalIds_deprecated(backgroundExternalIds).catch((e) => { forEach(externalIdsByGrandparentId, (externalIds, grandparentId) => {
logger.error( const parentIds = compact(
e, map(externalIds, ({ item }) =>
'Error saving non-essential external IDs. A fixer will run for these', 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(); PlexTaskQueue.pause();
const [, pQueueTime] = time(() => { const [, pQueueTime] = time(() => {
forEach(upsertedPrograms, (program) => { forEach(
try { filter(upsertedPrograms, (p) => p.sourceType === ProgramSourceType.PLEX),
const task = new SavePlexProgramExternalIdsTask(program.uuid); (program) => {
task.logLevel = 'trace'; try {
PlexTaskQueue.add(task).catch((e) => { const task = new SavePlexProgramExternalIdsTask(program.uuid);
logger.error(e, 'Error saving external IDs for program %s', program); task.logLevel = 'trace';
}); PlexTaskQueue.add(task).catch((e) => {
} catch (e) { logger.error(
logger.error( e,
e, 'Error saving external IDs for program %s',
'Failed to schedule external IDs task for persisted program: %O', program,
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); 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 scheduleJellyfinExternalIdsTask(upsertedPrograms: Program[]) {
// function. Useful to matching non-persisted API programs with persisted programs const logger = LoggerFactory.root;
export function contentProgramUniqueId(p: ContentProgram) {
// ID should always be defined in the persistent case
if (p.persisted) {
return p.id!;
}
// These should always be defined for the non-persisted case JellyfinTaskQueue.pause();
return `${p.externalSourceType}|${p.externalSourceName}|${p.originalProgram?.key}`; 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, // Takes a listing of programs and makes a mapping of a unique identifier,

216
server/src/external/BaseApiClient.ts vendored Normal file
View File

@@ -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<T> = {
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<T> = QuerySuccessResult<T> | QueryErrorResult;
export function isQueryError(x: QueryResult<unknown>): x is QueryErrorResult {
return x.type === 'error';
}
export function isQuerySuccess<T>(
x: QueryResult<T>,
): x is QuerySuccessResult<T> {
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<T extends z.ZodTypeAny, Out = z.infer<T>>(
path: string,
schema: T,
extraConfig: Partial<AxiosRequestConfig> = {},
): Promise<QueryResult<Out>> {
const getter = async () => {
const req: AxiosRequestConfig = {
...extraConfig,
method: 'get',
url: path,
};
const response = await this.doRequest<unknown>(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<QueryErrorResult> {
return;
}
protected makeErrorResult(
code: QueryErrorCode,
message?: string,
): QueryErrorResult {
return {
type: 'error',
code,
message,
};
}
protected makeSuccessResult<T>(data: T): QuerySuccessResult<T> {
return {
type: 'success',
data,
};
}
doGet<T>(req: Omit<AxiosRequestConfig, 'method'>) {
return this.doRequest<T>({ method: 'get', ...req });
}
doPost<T>(req: Omit<AxiosRequestConfig, 'method'>) {
return this.doRequest<T>({ method: 'post', ...req });
}
doPut<T>(req: Omit<AxiosRequestConfig, 'method'>) {
return this.doRequest<T>({ method: 'put', ...req });
}
doHead(req: Omit<AxiosRequestConfig, 'method'>) {
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<T>(req: AxiosRequestConfig): Promise<Try<T>> {
try {
const response = await this.axiosInstance.request<T>(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');
}
}
}
}

View File

@@ -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<string, boolean> = 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<X extends MediaSourceType, ApiClient = FindChild<X, TypeToClient>>(
typ: X,
opts: RemoteMediaSourceOptions,
factory: (opts: RemoteMediaSourceOptions) => ApiClient,
): ApiClient {
const key = `${typ}|${opts.url}|${opts.apiKey}`;
let client = this.#cache.get<ApiClient>(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<PlexApiClient>(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<X, TypeToClient>,
>(
type: X,
name: string,
factory: (opts: RemoteMediaSourceOptions) => ApiClient,
): Promise<Maybe<ApiClient>> {
const key = `${type}|${name}`;
let client = this.#cache.get<ApiClient>(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<PlexApiClient>(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;
};

View File

@@ -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<string, boolean> = 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<Plex>(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<Plex>(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;
};

View File

@@ -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<RemoteMediaSourceOptions, 'type'>
> {
constructor(options: Omit<RemoteMediaSourceOptions, 'type'>) {
super({
...options,
extraHeaders: {
...options.extraHeaders,
Accept: 'application/json',
'X-Emby-Token': options.apiKey,
},
});
}
static async login(
server: Omit<RemoteMediaSourceOptions, 'apiKey' | 'type'>,
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<QueryResult<Maybe<JellyfinItem>>> {
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<string>, // Not required if we are using an access token
libraryId: Nilable<string>,
itemTypes: Nilable<JellyfinItemKind[]> = null,
extraFields: JellyfinItemFields[] = [],
pageParams: Nilable<{ offset: number; limit: number }> = null,
extraParams: object = {},
): Promise<QueryResult<JellyfinLibraryItemsResponse>> {
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<QueryErrorResult> {
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);
}
}

View File

@@ -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<PlexServerSettings>,
'accessToken' | 'uri' | 'name' | 'clientIdentifier'
>,
'clientIdentifier'
> & {
enableRequestCache?: boolean;
};
type PlexQuerySuccessResult<T> = {
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<T> =
| PlexQuerySuccessResult<T>
| PlexQueryErrorResult;
export function isPlexQueryError(
x: PlexQueryResult<unknown>,
): x is PlexQueryErrorResult {
return x.type === 'error';
}
export function isPlexQuerySuccess<T>(
x: PlexQueryResult<T>,
): x is PlexQuerySuccessResult<T> {
return x.type === 'success';
}
function makeErrorResult(
code: PlexQueryErrorCode,
message?: string,
): PlexQueryErrorResult {
return {
type: 'error',
code,
message,
};
}
function makeSuccessResult<T>(data: T): PlexQuerySuccessResult<T> {
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<T>(
serverName: string,
path: string,
getter: () => Promise<T>,
): Promise<T> {
const key = this.getCacheKey(serverName, path);
const existing = this.#cache.get<T>(key);
if (isDefined(existing)) {
return existing;
}
const value = await getter();
this.#cache.set(key, value);
return value;
}
async getOrSetPlexResult<T>(
serverName: string,
path: string,
getter: () => Promise<PlexQueryResult<T>>,
opts?: { setOnError: boolean },
): Promise<PlexQueryResult<T>> {
const key = this.getCacheKey(serverName, path);
const existing = this.#cache.get<PlexQueryResult<T>>(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<T>(req: AxiosRequestConfig): Promise<Try<T>> {
try {
const response = await this.axiosInstance.request<T>(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<T>(
path: string,
optionalHeaders: RawAxiosRequestHeaders = {},
skipCache: boolean = false,
): Promise<PlexQueryResult<PlexMediaContainer<T>>> {
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<PlexMediaContainerResponse<T>>(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<T>(
path: string,
optionalHeaders: RawAxiosRequestHeaders = {},
skipCache: boolean = false,
): Promise<Maybe<PlexMediaContainer<T>>> {
const result = await this.doGetResult<PlexMediaContainer<T>>(
path,
optionalHeaders,
skipCache,
);
if (isPlexQuerySuccess(result)) {
return result.data;
} else {
return;
}
}
async doTypeCheckedGet<T extends z.ZodTypeAny, Out = z.infer<T>>(
path: string,
schema: T,
extraConfig: Partial<AxiosRequestConfig> = {},
): Promise<PlexQueryResult<Out>> {
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<unknown>(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<PlexQueryResult<PlexMedia>> {
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<PlexDvrsResponse>('/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<string, number | string> = {
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<Maybe<PlexTvDevicesResponse>> {
const response = await this.doRequest<string>({
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[] };
};

View File

@@ -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<MediaSource>,
'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<T>(
path: string,
optionalHeaders: RawAxiosRequestHeaders = {},
skipCache: boolean = false,
): Promise<QueryResult<PlexMediaContainer<T>>> {
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<PlexMediaContainerResponse<T>>(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<T>(
path: string,
optionalHeaders: RawAxiosRequestHeaders = {},
skipCache: boolean = false,
): Promise<Maybe<PlexMediaContainer<T>>> {
const result = await this.doGetResult<PlexMediaContainer<T>>(
path,
optionalHeaders,
skipCache,
);
if (isQuerySuccess(result)) {
return result.data;
} else {
return;
}
}
async getItemMetadata(key: string): Promise<QueryResult<PlexMedia>> {
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<PlexDvrsResponse>('/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<string, number | string> = {
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<Maybe<PlexTvDevicesResponse>> {
const response = await this.doRequest<string>({
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<QueryErrorResult> {
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[] };
};

View File

@@ -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<T>(
serverName: string,
path: string,
getter: () => Promise<T>,
): Promise<T> {
const key = this.getCacheKey(serverName, path);
const existing = this.#cache.get<T>(key);
if (isDefined(existing)) {
return existing;
}
const value = await getter();
this.#cache.set(key, value);
return value;
}
async getOrSetPlexResult<T>(
serverName: string,
path: string,
getter: () => Promise<QueryResult<T>>,
opts?: { setOnError: boolean },
): Promise<QueryResult<T>> {
const key = this.getCacheKey(serverName, path);
const existing = this.#cache.get<QueryResult<T>>(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}`;
}
}

View File

@@ -13,7 +13,7 @@ import {
import path from 'path'; import path from 'path';
import { DeepReadonly, DeepRequired } from 'ts-essentials'; import { DeepReadonly, DeepRequired } from 'ts-essentials';
import { serverOptions } from '../globals.js'; 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 { StreamContextChannel } from '../stream/types.js';
import { Maybe } from '../types/util.js'; import { Maybe } from '../types/util.js';
import { TypedEventEmitter } from '../types/eventEmitter.js'; import { TypedEventEmitter } from '../types/eventEmitter.js';
@@ -365,6 +365,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter<
startTime: number, startTime: number,
duration: Maybe<string>, duration: Maybe<string>,
enableIcon: Maybe<Watermark>, enableIcon: Maybe<Watermark>,
extraInnputHeaders: Record<string, string> = {},
) { ) {
return this.spawn( return this.spawn(
streamUrl, streamUrl,
@@ -373,6 +374,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter<
duration, duration,
true, true,
enableIcon, enableIcon,
extraInnputHeaders,
); );
} }
@@ -430,6 +432,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter<
duration: Maybe<string>, duration: Maybe<string>,
limitRead: boolean, limitRead: boolean,
watermark: Maybe<Watermark>, watermark: Maybe<Watermark>,
extraInnputHeaders: Record<string, string> = {},
) { ) {
const ffmpegArgs: string[] = [ const ffmpegArgs: string[] = [
'-hide_banner', '-hide_banner',
@@ -461,6 +464,9 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter<
let videoFile = -1; let videoFile = -1;
let overlayFile = -1; let overlayFile = -1;
if (isNonEmptyString(streamUrl)) { if (isNonEmptyString(streamUrl)) {
for (const [key, value] of Object.entries(extraInnputHeaders)) {
ffmpegArgs.push('-headers', `'${key}: ${value}'`);
}
ffmpegArgs.push(`-i`, streamUrl); ffmpegArgs.push(`-i`, streamUrl);
videoFile = inputFiles++; videoFile = inputFiles++;
audioFile = videoFile; audioFile = videoFile;
@@ -880,7 +886,8 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter<
const argsWithTokenRedacted = ffmpegArgs const argsWithTokenRedacted = ffmpegArgs
.join(' ') .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.logger.debug(`Starting ffmpeg with args: "%s"`, argsWithTokenRedacted);
this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20240719145409 extends Migration {
async up(): Promise<void> {
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`);',
);
}
}

View File

@@ -0,0 +1,89 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20240805185042 extends Migration {
async up(): Promise<void> {
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;');
}
}

View File

@@ -4,8 +4,8 @@ import path from 'path';
import { XmlTvWriter } from './XmlTvWriter.js'; import { XmlTvWriter } from './XmlTvWriter.js';
import { ChannelDB } from './dao/channelDb.js'; import { ChannelDB } from './dao/channelDb.js';
import { CustomShowDB } from './dao/customShowDb.js'; import { CustomShowDB } from './dao/customShowDb.js';
import { FillerDB } from './dao/fillerDb.js'; import { FillerDB } from './dao/fillerDB.js';
import { PlexServerDB } from './dao/plexServerDb.js'; import { MediaSourceDB } from './dao/mediaSourceDB.js';
import { ProgramDB } from './dao/programDB.js'; import { ProgramDB } from './dao/programDB.js';
import { SettingsDB, getSettings } from './dao/settings.js'; import { SettingsDB, getSettings } from './dao/settings.js';
import { serverOptions } from './globals.js'; import { serverOptions } from './globals.js';
@@ -33,7 +33,7 @@ export class ServerContext {
public hdhrService: HdhrService, public hdhrService: HdhrService,
public customShowDB: CustomShowDB, public customShowDB: CustomShowDB,
public channelCache: ChannelCache, public channelCache: ChannelCache,
public plexServerDB: PlexServerDB, public mediaSourceDB: MediaSourceDB,
public settings: SettingsDB, public settings: SettingsDB,
public programDB: ProgramDB, public programDB: ProgramDB,
) {} ) {}
@@ -77,7 +77,7 @@ export const serverContext: () => ServerContext = once(() => {
new HdhrService(settings), new HdhrService(settings),
customShowDB, customShowDB,
channelCache, channelCache,
new PlexServerDB(channelDB), new MediaSourceDB(channelDB),
settings, settings,
new ProgramDB(), new ProgramDB(),
); );

View File

@@ -10,7 +10,7 @@ import {
import { flatten, isNil, uniqBy } from 'lodash-es'; import { flatten, isNil, uniqBy } from 'lodash-es';
import map from 'lodash-es/map'; import map from 'lodash-es/map';
import { ProgramDB } from '../dao/programDB'; import { ProgramDB } from '../dao/programDB';
import { Plex } from '../external/plex'; import { PlexApiClient } from '../external/plex/PlexApiClient';
import { typedProperty } from '../types/path'; import { typedProperty } from '../types/path';
import { flatMapAsyncSeq, wait } from '../util/index.js'; import { flatMapAsyncSeq, wait } from '../util/index.js';
import { Logger, LoggerFactory } from '../util/logging/LoggerFactory'; import { Logger, LoggerFactory } from '../util/logging/LoggerFactory';
@@ -24,10 +24,10 @@ export type EnrichedPlexTerminalMedia = PlexTerminalMedia & {
export class PlexItemEnumerator { export class PlexItemEnumerator {
#logger: Logger = LoggerFactory.child({ className: PlexItemEnumerator.name }); #logger: Logger = LoggerFactory.child({ className: PlexItemEnumerator.name });
#timer = new Timer(this.#logger); #timer = new Timer(this.#logger);
#plex: Plex; #plex: PlexApiClient;
#programDB: ProgramDB; #programDB: ProgramDB;
constructor(plex: Plex, programDB: ProgramDB) { constructor(plex: PlexApiClient, programDB: ProgramDB) {
this.#plex = plex; this.#plex = plex;
this.#programDB = programDB; this.#programDB = programDB;
} }
@@ -55,7 +55,7 @@ export class PlexItemEnumerator {
} else if (isPlexDirectory(item)) { } else if (isPlexDirectory(item)) {
return []; return [];
} else { } else {
const plexResult = await this.#plex.doGet<PlexChildMediaViewType>( const plexResult = await this.#plex.doGetPath<PlexChildMediaViewType>(
item.key, item.key,
); );

View File

@@ -7,9 +7,9 @@ import { isNil, map } from 'lodash-es';
import { ChannelDB } from '../../dao/channelDb.js'; import { ChannelDB } from '../../dao/channelDb.js';
import { EntityManager } from '../../dao/dataSource.js'; import { EntityManager } from '../../dao/dataSource.js';
import { Channel } from '../../dao/entities/Channel.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 { ProgramDB } from '../../dao/programDB.js';
import { Plex } from '../../external/plex.js'; import { PlexApiClient } from '../../external/plex/PlexApiClient.js';
import { import {
EnrichedPlexTerminalMedia, EnrichedPlexTerminalMedia,
PlexItemEnumerator, PlexItemEnumerator,
@@ -26,7 +26,7 @@ export class PlexContentSourceUpdater extends ContentSourceUpdater<DynamicConten
className: PlexContentSourceUpdater.name, className: PlexContentSourceUpdater.name,
}); });
#timer = new Timer(this.#logger); #timer = new Timer(this.#logger);
#plex: Plex; #plex: PlexApiClient;
#channelDB: ChannelDB; #channelDB: ChannelDB;
constructor( constructor(
@@ -38,14 +38,14 @@ export class PlexContentSourceUpdater extends ContentSourceUpdater<DynamicConten
} }
protected async prepare(em: EntityManager) { protected async prepare(em: EntityManager) {
const server = await em.repo(PlexServerSettings).findOneOrFail({ const server = await em.repo(MediaSource).findOneOrFail({
$or: [ $or: [
{ name: this.config.plexServerId }, { name: this.config.plexServerId },
{ clientIdentifier: this.config.plexServerId }, { clientIdentifier: this.config.plexServerId },
], ],
}); });
this.#plex = new Plex(server); this.#plex = new PlexApiClient(server);
} }
protected async run() { protected async run() {
@@ -53,7 +53,7 @@ export class PlexContentSourceUpdater extends ContentSourceUpdater<DynamicConten
// TODO page through the results // TODO page through the results
const plexResult = await this.#timer.timeAsync('plex search', () => const plexResult = await this.#timer.timeAsync('plex search', () =>
this.#plex.doGet<PlexLibraryListing>( this.#plex.doGetPath<PlexLibraryListing>(
`/library/sections/${this.config.plexLibraryKey}/all?${filter.join( `/library/sections/${this.config.plexLibraryKey}/all?${filter.join(
'&', '&',
)}`, )}`,
@@ -100,7 +100,7 @@ const plexMediaToContentProgram = (
return { return {
id: media.id ?? uniqueId, id: media.id ?? uniqueId,
persisted: !isNil(media.id), persisted: !isNil(media.id),
originalProgram: media, originalProgram: { sourceType: 'plex', program: media },
duration: media.duration, duration: media.duration,
externalSourceName: serverName, externalSourceName: serverName,
externalSourceType: 'plex', externalSourceType: 'plex',

View File

@@ -22,16 +22,18 @@ import { FfmpegSettings, Watermark } from '@tunarr/types';
import { isError, isString, isUndefined } from 'lodash-es'; import { isError, isString, isUndefined } from 'lodash-es';
import { Writable } from 'stream'; import { Writable } from 'stream';
import { isContentBackedLineupIteam } from '../dao/derived_types/StreamLineup.js'; import { isContentBackedLineupIteam } from '../dao/derived_types/StreamLineup.js';
import { MediaSourceType } from '../dao/entities/MediaSource.js';
import { FfmpegEvents } from '../ffmpeg/ffmpeg.js'; import { FfmpegEvents } from '../ffmpeg/ffmpeg.js';
import { TypedEventEmitter } from '../types/eventEmitter.js'; import { TypedEventEmitter } from '../types/eventEmitter.js';
import { Maybe } from '../types/util.js'; import { Maybe } from '../types/util.js';
import { isNonEmptyString } from '../util/index.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 { OfflinePlayer } from './OfflinePlayer.js';
import { Player, PlayerContext } from './Player.js'; import { Player, PlayerContext } from './Player.js';
import { JellyfinPlayer } from './jellyfin/JellyfinPlayer.js';
import { PlexPlayer } from './plex/PlexPlayer.js'; import { PlexPlayer } from './plex/PlexPlayer.js';
import { StreamContextChannel } from './types.js'; import { StreamContextChannel } from './types.js';
import { LoggerFactory } from '../util/logging/LoggerFactory.js';
import { serverOptions } from '../globals.js';
export class ProgramPlayer extends Player { export class ProgramPlayer extends Player {
private logger = LoggerFactory.child({ caller: import.meta }); private logger = LoggerFactory.child({ caller: import.meta });
@@ -53,17 +55,22 @@ export class ProgramPlayer extends Player {
this.delegate = new OfflinePlayer(true, context); this.delegate = new OfflinePlayer(true, context);
} else if (program.type === 'loading') { } else if (program.type === 'loading') {
this.logger.debug('About to play loading stream'); this.logger.debug('About to play loading stream');
/* loading */
context.isLoading = true; context.isLoading = true;
this.delegate = new OfflinePlayer(false, context); this.delegate = new OfflinePlayer(false, context);
} else if (program.type === 'offline') { } else if (program.type === 'offline') {
this.logger.debug('About to play offline stream'); this.logger.debug('About to play offline stream');
/* offline */
this.delegate = new OfflinePlayer(false, context); this.delegate = new OfflinePlayer(false, context);
} else if (isContentBackedLineupIteam(program) && program) { } else if (isContentBackedLineupIteam(program)) {
this.logger.debug('About to play plex stream'); switch (program.externalSource) {
/* plex */ case MediaSourceType.Plex:
this.delegate = new PlexPlayer(context); 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( this.context.watermark = this.getWatermark(
context.ffmpegSettings, context.ffmpegSettings,
@@ -137,8 +144,8 @@ export class ProgramPlayer extends Player {
); );
} }
this.logger.error( this.logger.error(
'Error when attempting to play video. Fallback to error stream: ' + actualError,
actualError.stack, 'Error when attempting to play video. Fallback to error stream',
); );
//Retry once with an error stream: //Retry once with an error stream:
this.context.lineupItem = { this.context.lineupItem = {
@@ -182,7 +189,7 @@ export class ProgramPlayer extends Player {
} else if (isNonEmptyString(channel.icon?.path)) { } else if (isNonEmptyString(channel.icon?.path)) {
icon = channel.icon.path; icon = channel.icon.path;
} else { } else {
icon = `http://localhost:${serverOptions().port}/images/tunarr.png`; icon = makeLocalUrl('/images/tunarr.png');
} }
console.log(watermark); console.log(watermark);

View File

@@ -1,14 +1,6 @@
import { Loaded } from '@mikro-orm/core'; import { Loaded } from '@mikro-orm/core';
import constants from '@tunarr/shared/constants'; import constants from '@tunarr/shared/constants';
import { import { first, isEmpty, isNil, isNull, isUndefined, pick } from 'lodash-es';
find,
first,
isEmpty,
isNil,
isNull,
isUndefined,
pick,
} from 'lodash-es';
import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js'; import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js';
import { getEm } from '../dao/dataSource.js'; import { getEm } from '../dao/dataSource.js';
import { import {
@@ -34,8 +26,10 @@ import { binarySearchRange } from '../util/binarySearch.js';
import { isNonEmptyString, zipWithIndex } from '../util/index.js'; import { isNonEmptyString, zipWithIndex } from '../util/index.js';
import { LoggerFactory } from '../util/logging/LoggerFactory.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js';
import { STREAM_CHANNEL_CONTEXT_KEYS, StreamContextChannel } from './types.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 { ChannelDB } from '../dao/channelDb.js';
import { MediaSourceType } from '../dao/entities/MediaSource.js';
import { ProgramExternalId } from '../dao/entities/ProgramExternalId.js';
const SLACK = constants.SLACK; const SLACK = constants.SLACK;
@@ -164,7 +158,12 @@ export class StreamProgramCalculator {
populate: ['externalIds'], populate: ['externalIds'],
populateWhere: { populateWhere: {
externalIds: { externalIds: {
sourceType: ProgramExternalIdType.PLEX, sourceType: {
$in: [
ProgramExternalIdType.PLEX,
ProgramExternalIdType.JELLYFIN,
],
},
}, },
}, },
}, },
@@ -178,21 +177,26 @@ export class StreamProgramCalculator {
if (!isNil(backingItem)) { if (!isNil(backingItem)) {
// Will play this item on the first found server... unsure if that is // Will play this item on the first found server... unsure if that is
// what we want // what we want
const plexInfo = find( const externalInfo = backingItem.externalIds.find(
backingItem.externalIds, (eid) =>
(eid) => eid.sourceType === ProgramExternalIdType.PLEX, eid.sourceType === ProgramExternalIdType.PLEX ||
eid.sourceType === ProgramExternalIdType.JELLYFIN,
); );
if ( if (
!isUndefined(plexInfo) && !isUndefined(externalInfo) &&
isNonEmptyString(plexInfo.externalSourceId) isNonEmptyString(externalInfo.externalSourceId)
) { ) {
program = { program = {
type: 'program', type: 'program',
plexFilePath: plexInfo.externalFilePath, externalSource:
externalKey: plexInfo.externalKey, externalInfo.sourceType === ProgramExternalIdType.JELLYFIN
filePath: plexInfo.directFilePath, ? MediaSourceType.Jellyfin
externalSourceId: plexInfo.externalSourceId, : MediaSourceType.Plex,
plexFilePath: externalInfo.externalFilePath,
externalKey: externalInfo.externalKey,
filePath: externalInfo.directFilePath,
externalSourceId: externalInfo.externalSourceId,
duration: backingItem.duration, duration: backingItem.duration,
programId: backingItem.uuid, programId: backingItem.uuid,
title: backingItem.title, title: backingItem.title,
@@ -302,24 +306,38 @@ export class StreamProgramCalculator {
} }
} }
return { const externalInfos = await getEm().find(ProgramExternalId, {
// just add the video, starting at 0, playing the entire duration program: { uuid: filler.uuid },
type: 'commercial', sourceType: {
title: filler.title, $in: [ProgramExternalIdType.PLEX, ProgramExternalIdType.JELLYFIN],
filePath: filler.filePath!, },
externalKey: filler.externalKey, });
start: fillerstart,
streamDuration: Math.max( if (!isEmpty(externalInfos)) {
1, const externalInfo = first(externalInfos)!;
Math.min(filler.duration - fillerstart, remaining), return {
), // just add the video, starting at 0, playing the entire duration
duration: filler.duration, type: 'commercial',
programId: filler.uuid, title: filler.title,
beginningOffset: beginningOffset, filePath: externalInfo.directFilePath,
externalSourceId: filler.externalSourceId, externalKey: externalInfo.externalKey,
plexFilePath: filler.plexFilePath!, externalSource:
programType: filler.type as ProgramType, 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 // pick the offline screen
remaining = Math.min(remaining, 10 * 60 * 1000); remaining = Math.min(remaining, 10 * 60 * 1000);

View File

@@ -15,7 +15,6 @@ import { fileExists } from '../util/fsUtil';
import { deepCopy } from '../util/index.js'; import { deepCopy } from '../util/index.js';
import { LoggerFactory } from '../util/logging/LoggerFactory'; import { LoggerFactory } from '../util/logging/LoggerFactory';
import { import {
ProgramAndTimeElapsed,
StreamProgramCalculator, StreamProgramCalculator,
generateChannelContext, generateChannelContext,
} from './StreamProgramCalculator'; } from './StreamProgramCalculator';
@@ -107,80 +106,75 @@ export class VideoStream {
} }
let lineupItem: Maybe<StreamLineupItem>; let lineupItem: Maybe<StreamLineupItem>;
let currentProgram: ProgramAndTimeElapsed | undefined;
let channelContext: Loaded<Channel> = channel; let channelContext: Loaded<Channel> = channel;
const redirectChannels: string[] = []; const redirectChannels: string[] = [];
const upperBounds: number[] = []; const upperBounds: number[] = [];
if (isUndefined(lineupItem)) { let currentProgram = await this.calculator.getCurrentProgramAndTimeElapsed(
const lineup = await serverCtx.channelDB.loadLineup(channel.uuid); startTimestamp,
currentProgram = await this.calculator.getCurrentProgramAndTimeElapsed( channel,
startTimestamp, lineup,
channel, );
lineup,
while (
!isUndefined(currentProgram) &&
currentProgram.program.type === 'redirect'
) {
redirectChannels.push(channelContext.uuid);
upperBounds.push(
currentProgram.program.duration - currentProgram.timeElapsed,
); );
while ( if (redirectChannels.includes(currentProgram.program.channel)) {
!isUndefined(currentProgram) && await serverCtx.channelCache.recordPlayback(
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(
channelContext.uuid, channelContext.uuid,
startTimestamp, startTimestamp,
{
type: 'error',
title: 'Error',
error:
'Recursive channel redirect found: ' +
redirectChannels.join(', '),
duration: 60000,
start: 0,
},
); );
}
if (!isUndefined(lineupItem)) { const nextChannelId = currentProgram.program.channel;
lineupItem = deepCopy(lineupItem); const newChannelAndLineup =
break; await serverCtx.channelDB.loadChannelAndLineup(nextChannelId);
} else {
currentProgram = if (isNil(newChannelAndLineup)) {
await this.calculator.getCurrentProgramAndTimeElapsed( const msg = "Invalid redirect to a channel that doesn't exist";
startTimestamp, this.logger.error(msg);
channelContext, currentProgram = {
newChannelAndLineup.lineup, 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--) { for (let i = redirectChannels.length - 1; i >= 0; i--) {
const thisUpperBound = nth(upperBounds, i); const thisUpperBound = nth(upperBounds, i);
if (!isNil(thisUpperBound)) { if (!isNil(thisUpperBound)) {
console.log('adjusting upper bound....');
const nextBound = thisUpperBound + beginningOffset; const nextBound = thisUpperBound + beginningOffset;
const prevBound = isNil(lineupItem.streamDuration) const prevBound = isNil(lineupItem.streamDuration)
? upperBound ? upperBound
@@ -310,10 +303,10 @@ export class VideoStream {
}; };
const playerContext: PlayerContext = { const playerContext: PlayerContext = {
lineupItem: lineupItem, lineupItem,
ffmpegSettings: ffmpegSettings, ffmpegSettings,
channel: combinedChannel, channel: combinedChannel,
m3u8: m3u8, m3u8,
audioOnly: audioOnly, audioOnly: audioOnly,
// A little hacky... // A little hacky...
entityManager: ( entityManager: (
@@ -322,7 +315,7 @@ export class VideoStream {
settings: serverCtx.settings, settings: serverCtx.settings,
}; };
const player: ProgramPlayer = new ProgramPlayer(playerContext); const player = new ProgramPlayer(playerContext);
let stopped = false; let stopped = false;
const stop = () => { const stop = () => {
@@ -343,11 +336,10 @@ export class VideoStream {
}); });
ffmpegEmitter?.on('end', () => { ffmpegEmitter?.on('end', () => {
this.logger.trace('playObj.end');
stop(); stop();
}); });
} catch (err) { } catch (err) {
this.logger.error('Error when attempting to play video: %O', err); this.logger.error(err, 'Error when attempting to play video');
stop(); stop();
return { return {
type: 'error', type: 'error',

View File

@@ -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<FFMPEG> = 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<TypedEventEmitter<FfmpegEvents> | 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<FfmpegEvents>;
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;
}
}

View File

@@ -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<Nullable<PlexStream>> {
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<Nullable<StreamDetails>> {
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;
}
}

View File

@@ -3,10 +3,13 @@ import EventEmitter from 'events';
import { isNil, isNull, isUndefined } from 'lodash-es'; import { isNil, isNull, isUndefined } from 'lodash-es';
import { Writable } from 'stream'; import { Writable } from 'stream';
import { isContentBackedLineupIteam } from '../../dao/derived_types/StreamLineup.js'; 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 { FFMPEG, FfmpegEvents } from '../../ffmpeg/ffmpeg.js';
import { GlobalScheduler } from '../../services/scheduler.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 { TypedEventEmitter } from '../../types/eventEmitter.js';
import { Maybe, Nullable } from '../../types/util.js'; import { Maybe, Nullable } from '../../types/util.js';
import { ifDefined } from '../../util/index.js'; import { ifDefined } from '../../util/index.js';
@@ -61,9 +64,12 @@ export class PlexPlayer extends Player {
} }
const ffmpegSettings = this.context.ffmpegSettings; 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 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)) { if (isNil(server)) {
throw Error( throw Error(
`Unable to find server "${lineupItem.externalSourceId}" specified by program.`, `Unable to find server "${lineupItem.externalSourceId}" specified by program.`,

View File

@@ -17,22 +17,20 @@ import {
replace, replace,
trimEnd, trimEnd,
} from 'lodash-es'; } from 'lodash-es';
import { PlexServerSettings } from '../../dao/entities/PlexServerSettings'; import { MediaSource } from '../../dao/entities/MediaSource';
import { import { PlexApiClient } from '../../external/plex/PlexApiClient';
Plex, import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory';
isPlexQueryError,
isPlexQuerySuccess,
} from '../../external/plex';
import { PlexApiFactory } from '../../external/PlexApiFactory';
import { Nullable } from '../../types/util'; import { Nullable } from '../../types/util';
import { Logger, LoggerFactory } from '../../util/logging/LoggerFactory'; 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 { attempt, isNonEmptyString } from '../../util';
import { ContentBackedStreamLineupItem } from '../../dao/derived_types/StreamLineup.js'; import { ContentBackedStreamLineupItem } from '../../dao/derived_types/StreamLineup.js';
import { SettingsDB } from '../../dao/settings.js'; import { SettingsDB } from '../../dao/settings.js';
import { makeLocalUrl } from '../../util/serverUtil.js'; import { makeLocalUrl } from '../../util/serverUtil.js';
import { ProgramDB } from '../../dao/programDB'; import { ProgramDB } from '../../dao/programDB';
import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType'; 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 // The minimum fields we need to get stream details about an item
type PlexItemStreamDetailsQuery = Pick< type PlexItemStreamDetailsQuery = Pick<
@@ -48,10 +46,10 @@ type PlexItemStreamDetailsQuery = Pick<
*/ */
export class PlexStreamDetails { export class PlexStreamDetails {
private logger: Logger; private logger: Logger;
private plex: Plex; private plex: PlexApiClient;
constructor( constructor(
private server: PlexServerSettings, private server: MediaSource,
private settings: SettingsDB, private settings: SettingsDB,
private programDB: ProgramDB, private programDB: ProgramDB,
) { ) {
@@ -61,7 +59,7 @@ export class PlexStreamDetails {
caller: import.meta, caller: import.meta,
}); });
this.plex = PlexApiFactory().get(this.server); this.plex = MediaSourceApiFactory().get(this.server);
} }
async getStream(item: PlexItemStreamDetailsQuery) { async getStream(item: PlexItemStreamDetailsQuery) {
@@ -81,7 +79,7 @@ export class PlexStreamDetails {
item.externalKey, item.externalKey,
); );
if (isPlexQueryError(itemMetadataResult)) { if (isQueryError(itemMetadataResult)) {
if (itemMetadataResult.code === 'not_found') { if (itemMetadataResult.code === 'not_found') {
this.logger.debug( this.logger.debug(
'Could not find item %s in Plex. Rating key may have changed. Attempting to update.', '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) { if (byGuidResult.data.MediaContainer.size > 0) {
this.logger.debug( this.logger.debug(
'Found %d matching items in library. Using the first', '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 // We have to check that we can hit this URL or the stream will not work
if (isNonEmptyString(placeholderThumbPath)) { if (isNonEmptyString(placeholderThumbPath)) {
const result = await attempt(() => const result = await attempt(() =>
this.plex.doHead(placeholderThumbPath), this.plex.doHead({ url: placeholderThumbPath }),
); );
if (!isError(result)) { if (!isError(result)) {
streamDetails.placeholderImage = streamDetails.placeholderImage =

View File

@@ -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<PlexStreamSettings>;
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<PlexMediaContainer<TranscodeDecision>>;
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<PlexItemMetadata>;
constructor(
clientId: string,
server: PlexServerSettings,
settings: DeepReadonly<PlexStreamSettings>,
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<PlexStream> {
// let stream: PlexStream = { directPlay: false };
let directPlay: boolean = false;
let streamUrl: string;
let separateVideoStream: Maybe<string>;
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<StreamDetails> = {};
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<string>)
? '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<StreamDetails>; // 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<TranscodeDecision>(
`/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<string, string | number>;
} {
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<PlexItemMetadata>(
`/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<string>) {
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<string>;
audioCodecs?: ReadonlyArray<string>;
subtitleCodecs?: ReadonlyArray<string>;
}) {
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<string, string | boolean | number>;
}) {
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)})`;
}

View File

@@ -17,3 +17,35 @@ export type StreamContextChannel = Pick<
Channel & { offlinePicture?: string; offlineSoundtrack?: string }, Channel & { offlinePicture?: string; offlineSoundtrack?: string },
TupleToUnion<typeof STREAM_CHANNEL_CONTEXT_KEYS> TupleToUnion<typeof STREAM_CHANNEL_CONTEXT_KEYS>
>; >;
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;
};

View File

@@ -81,7 +81,7 @@ export class ReconcileProgramDurationsTask extends Task {
!isUndefined(dbItemDuration) && !isUndefined(dbItemDuration) &&
dbItemDuration !== item.durationMs dbItemDuration !== item.durationMs
) { ) {
console.debug('Found duration mismatch: %s', item.id); this.logger.debug('Found duration mismatch: %s', item.id);
changed = true; changed = true;
return { return {
...item, ...item,

View File

@@ -53,3 +53,9 @@ export const PlexTaskQueue = new TaskQueue('PlexTaskQueue', {
intervalCap: 5, intervalCap: 5,
interval: 2000, interval: 2000,
}); });
export const JellyfinTaskQueue = new TaskQueue('JellyfinTaskQueue', {
concurrency: 2,
intervalCap: 5,
interval: 2000,
});

View File

@@ -3,9 +3,9 @@ import { PlexDvr } from '@tunarr/types/plex';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { ChannelDB } from '../dao/channelDb.js'; import { ChannelDB } from '../dao/channelDb.js';
import { withDb } from '../dao/dataSource.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 { 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 { globalOptions } from '../globals.js';
import { ServerContext } from '../serverContext.js'; import { ServerContext } from '../serverContext.js';
import { TVGuideService } from '../services/tvGuideService.js'; import { TVGuideService } from '../services/tvGuideService.js';
@@ -88,11 +88,11 @@ export class UpdateXmlTvTask extends Task<void> {
const channels = await this.#channelDB.getAllChannels(); const channels = await this.#channelDB.getAllChannels();
const allPlexServers = await withDb((em) => { const allPlexServers = await withDb((em) => {
return em.find(PlexServerSettings, {}); return em.find(MediaSource, {});
}); });
await mapAsyncSeq(allPlexServers, async (plexServer) => { await mapAsyncSeq(allPlexServers, async (plexServer) => {
const plex = new Plex(plexServer); const plex = new PlexApiClient(plexServer);
let dvrs: PlexDvr[] = []; let dvrs: PlexDvr[] = [];
if (!plexServer.sendGuideUpdates && !plexServer.sendChannelUpdates) { if (!plexServer.sendGuideUpdates && !plexServer.sendChannelUpdates) {

View File

@@ -10,11 +10,11 @@ import {
import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType'; import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType';
import { ProgramSourceType } from '../../dao/custom_types/ProgramSourceType.js'; import { ProgramSourceType } from '../../dao/custom_types/ProgramSourceType.js';
import { getEm } from '../../dao/dataSource'; 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 { Program } from '../../dao/entities/Program';
import { ProgramExternalId } from '../../dao/entities/ProgramExternalId.js'; import { ProgramExternalId } from '../../dao/entities/ProgramExternalId.js';
import { Plex, isPlexQueryError } from '../../external/plex.js'; import { PlexApiClient } from '../../external/plex/PlexApiClient.js';
import { PlexApiFactory } from '../../external/PlexApiFactory'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory';
import { Maybe } from '../../types/util.js'; import { Maybe } from '../../types/util.js';
import { asyncPool } from '../../util/asyncPool.js'; import { asyncPool } from '../../util/asyncPool.js';
import { attempt, attemptSync, groupByUniq, wait } from '../../util/index.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 Fixer from './fixer';
import { PlexTerminalMedia } from '@tunarr/types/plex'; import { PlexTerminalMedia } from '@tunarr/types/plex';
import { upsertProgramExternalIds_deprecated } from '../../dao/programExternalIdHelpers'; import { upsertProgramExternalIds_deprecated } from '../../dao/programExternalIdHelpers';
import { isQueryError } from '../../external/BaseApiClient.js';
export class BackfillProgramExternalIds extends Fixer { export class BackfillProgramExternalIds extends Fixer {
#logger = LoggerFactory.child({ caller: import.meta }); #logger = LoggerFactory.child({ caller: import.meta });
@@ -53,7 +54,7 @@ export class BackfillProgramExternalIds extends Fixer {
cursor.totalCount, cursor.totalCount,
); );
const plexConnections: Record<string, Plex> = {}; const plexConnections: Record<string, PlexApiClient> = {};
while (cursor.length > 0) { while (cursor.length > 0) {
await wait(50); await wait(50);
// process // process
@@ -66,12 +67,12 @@ export class BackfillProgramExternalIds extends Fixer {
keys(plexConnections), keys(plexConnections),
); );
const serverSettings = await em.find(PlexServerSettings, { const serverSettings = await em.find(MediaSource, {
name: { $in: missingServers }, name: { $in: missingServers },
}); });
forEach(serverSettings, (server) => { forEach(serverSettings, (server) => {
plexConnections[server.name] = PlexApiFactory().get(server); plexConnections[server.name] = MediaSourceApiFactory().get(server);
}); });
for await (const result of asyncPool( for await (const result of asyncPool(
@@ -123,7 +124,7 @@ export class BackfillProgramExternalIds extends Fixer {
} }
} }
private async handleProgram(program: Program, plex: Maybe<Plex>) { private async handleProgram(program: Program, plex: Maybe<PlexApiClient>) {
if (isUndefined(plex)) { if (isUndefined(plex)) {
throw new Error( throw new Error(
'No Plex server connection found for server ' + 'No Plex server connection found for server ' +
@@ -133,7 +134,7 @@ export class BackfillProgramExternalIds extends Fixer {
const metadataResult = await plex.getItemMetadata(program.externalKey); const metadataResult = await plex.getItemMetadata(program.externalKey);
if (isPlexQueryError(metadataResult)) { if (isQueryError(metadataResult)) {
throw new Error( throw new Error(
`Could not retrieve metadata for program ID ${program.uuid}, rating key = ${program.externalKey}`, `Could not retrieve metadata for program ID ${program.uuid}, rating key = ${program.externalKey}`,
); );

View File

@@ -1,17 +1,20 @@
import { find, isNil } from 'lodash-es'; import { find, isNil } from 'lodash-es';
import { EntityManager } from '../../dao/dataSource.js'; import { EntityManager } from '../../dao/dataSource.js';
import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; import {
import { Plex } from '../../external/plex.js'; MediaSource,
MediaSourceType,
} from '../../dao/entities/MediaSource.js';
import { PlexApiClient } from '../../external/plex/PlexApiClient.js';
import Fixer from './fixer.js'; import Fixer from './fixer.js';
export class AddPlexServerIdsFixer extends Fixer { export class AddPlexServerIdsFixer extends Fixer {
async runInternal(em: EntityManager): Promise<void> { async runInternal(em: EntityManager): Promise<void> {
const plexServers = await em const plexServers = await em
.repo(PlexServerSettings) .repo(MediaSource)
.find({ clientIdentifier: null }); .find({ clientIdentifier: null, type: MediaSourceType.Plex });
for (const server of plexServers) { for (const server of plexServers) {
const api = new Plex(server); const api = new PlexApiClient(server);
const devices = await api.getDevices(); const devices = await api.getDevices();
if (!isNil(devices) && devices.MediaContainer.Device) { if (!isNil(devices) && devices.MediaContainer.Device) {
const matchingServer = find( const matchingServer = find(

View File

@@ -16,14 +16,14 @@ import ld, {
} from 'lodash-es'; } from 'lodash-es';
import { ProgramSourceType } from '../../dao/custom_types/ProgramSourceType'; import { ProgramSourceType } from '../../dao/custom_types/ProgramSourceType';
import { getEm } from '../../dao/dataSource'; 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 { Program, ProgramType } from '../../dao/entities/Program';
import { import {
ProgramGrouping, ProgramGrouping,
ProgramGroupingType, ProgramGroupingType,
} from '../../dao/entities/ProgramGrouping'; } from '../../dao/entities/ProgramGrouping';
import { ProgramGroupingExternalId } from '../../dao/entities/ProgramGroupingExternalId'; import { ProgramGroupingExternalId } from '../../dao/entities/ProgramGroupingExternalId';
import { PlexApiFactory } from '../../external/PlexApiFactory'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory';
import { LoggerFactory } from '../../util/logging/LoggerFactory'; import { LoggerFactory } from '../../util/logging/LoggerFactory';
import Fixer from './fixer'; import Fixer from './fixer';
import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType'; import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType';
@@ -35,7 +35,7 @@ export class BackfillProgramGroupings extends Fixer {
}); });
protected async runInternal(em: EntityManager): Promise<void> { protected async runInternal(em: EntityManager): Promise<void> {
const plexServers = await em.findAll(PlexServerSettings); const plexServers = await em.findAll(MediaSource);
// Update shows first, then seasons, so we can relate them // Update shows first, then seasons, so we can relate them
const serversAndShows = await em const serversAndShows = await em
@@ -77,8 +77,8 @@ export class BackfillProgramGroupings extends Fixer {
continue; continue;
} }
const plex = PlexApiFactory().get(server); const plex = MediaSourceApiFactory().get(server);
const plexResult = await plex.doGet<PlexLibraryShows>( const plexResult = await plex.doGetPath<PlexLibraryShows>(
'/library/metadata/' + grandparentExternalKey, '/library/metadata/' + grandparentExternalKey,
); );
@@ -147,8 +147,8 @@ export class BackfillProgramGroupings extends Fixer {
continue; continue;
} }
const plex = PlexApiFactory().get(server); const plex = MediaSourceApiFactory().get(server);
const plexResult = await plex.doGet<PlexSeasonView>( const plexResult = await plex.doGetPath<PlexSeasonView>(
'/library/metadata/' + parentExternalKey, '/library/metadata/' + parentExternalKey,
); );
@@ -245,8 +245,8 @@ export class BackfillProgramGroupings extends Fixer {
continue; continue;
} }
const plex = PlexApiFactory().get(server); const plex = MediaSourceApiFactory().get(server);
const plexResult = await plex.doGet<PlexSeasonView>( const plexResult = await plex.doGetPath<PlexSeasonView>(
'/library/metadata/' + ref.externalKey, '/library/metadata/' + ref.externalKey,
); );

View File

@@ -2,9 +2,9 @@ import { EntityManager } from '@mikro-orm/better-sqlite';
import { Cursor } from '@mikro-orm/core'; import { Cursor } from '@mikro-orm/core';
import { PlexEpisodeView, PlexSeasonView } from '@tunarr/types/plex'; import { PlexEpisodeView, PlexSeasonView } from '@tunarr/types/plex';
import { first, forEach, groupBy, mapValues, pickBy } from 'lodash-es'; 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 { 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 { Maybe } from '../../types/util.js';
import { groupByUniqAndMap, wait } from '../../util/index.js'; import { groupByUniqAndMap, wait } from '../../util/index.js';
import Fixer from './fixer.js'; import Fixer from './fixer.js';
@@ -14,7 +14,7 @@ export class MissingSeasonNumbersFixer extends Fixer {
private logger = LoggerFactory.child({ caller: import.meta }); private logger = LoggerFactory.child({ caller: import.meta });
async runInternal(em: EntityManager): Promise<void> { async runInternal(em: EntityManager): Promise<void> {
const allPlexServers = await em.findAll(PlexServerSettings); const allPlexServers = await em.findAll(MediaSource);
if (allPlexServers.length === 0) { if (allPlexServers.length === 0) {
return; return;
@@ -23,7 +23,7 @@ export class MissingSeasonNumbersFixer extends Fixer {
const plexByName = groupByUniqAndMap( const plexByName = groupByUniqAndMap(
allPlexServers, allPlexServers,
'name', 'name',
(server) => new Plex(server), (server) => new PlexApiClient(server),
); );
let cursor: Maybe<Cursor<Program>> = undefined; let cursor: Maybe<Cursor<Program>> = undefined;
@@ -122,9 +122,12 @@ export class MissingSeasonNumbersFixer extends Fixer {
} while (cursor.hasNextPage); } while (cursor.hasNextPage);
} }
private async findSeasonNumberUsingEpisode(episodeId: string, plex: Plex) { private async findSeasonNumberUsingEpisode(
episodeId: string,
plex: PlexApiClient,
) {
try { try {
const episode = await plex.doGet<PlexEpisodeView>( const episode = await plex.doGetPath<PlexEpisodeView>(
`/library/metadata/${episodeId}`, `/library/metadata/${episodeId}`,
); );
return episode?.parentIndex; 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 // We get the parent because we're dealing with an episode and we want the
// season index. // season index.
try { try {
const season = await plex.doGet<PlexSeasonView>( const season = await plex.doGetPath<PlexSeasonView>(
`/library/metadata/${seasonId}`, `/library/metadata/${seasonId}`,
); );
return first(season?.Metadata ?? [])?.index; return first(season?.Metadata ?? [])?.index;

View File

@@ -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<unknown> {
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<ProgramExternalId> = undefined;
let api: Maybe<JellyfinApiClient>;
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;
}
}

View File

@@ -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<TaskId, unknown>;
constructor(
private request: SaveJellyfinProgramGroupingsRequest,
private programDB: ProgramDB = new ProgramDB(),
) {
super();
}
protected async runInternal(): Promise<unknown> {
await new ProgramGroupingCalculator(
this.programDB,
).createHierarchyForManyFromJellyfin(
this.request.programType,
this.request.jellyfinServerName,
this.request.programAndJellyfinIds,
this.request.parentKeys,
this.request.grandparentKey,
);
return;
}
}

View File

@@ -1,17 +1,18 @@
import { ref } from '@mikro-orm/core'; import { ref } from '@mikro-orm/core';
import { PlexTerminalMedia } from '@tunarr/types/plex'; import { PlexTerminalMedia } from '@tunarr/types/plex';
import { compact, isEmpty, isError, isUndefined, map } from 'lodash-es'; import { compact, isEmpty, isError, isUndefined, map } from 'lodash-es';
import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js'; import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType.js';
import { getEm } from '../dao/dataSource.js'; import { getEm } from '../../dao/dataSource.js';
import { Program } from '../dao/entities/Program.js'; import { Program } from '../../dao/entities/Program.js';
import { ProgramExternalId } from '../dao/entities/ProgramExternalId.js'; import { ProgramExternalId } from '../../dao/entities/ProgramExternalId.js';
import { upsertProgramExternalIds_deprecated } from '../dao/programExternalIdHelpers.js'; import { upsertProgramExternalIds_deprecated } from '../../dao/programExternalIdHelpers.js';
import { Plex, isPlexQueryError } from '../external/plex.js'; import { PlexApiClient } from '../../external/plex/PlexApiClient.js';
import { PlexApiFactory } from '../external/PlexApiFactory.js'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js';
import { Maybe } from '../types/util.js'; import { Maybe } from '../../types/util.js';
import { parsePlexExternalGuid } from '../util/externalIds.js'; import { parsePlexExternalGuid } from '../../util/externalIds.js';
import { isDefined, isNonEmptyString } from '../util/index.js'; import { isDefined, isNonEmptyString } from '../../util/index.js';
import { Task } from './Task.js'; import { Task } from '../Task.js';
import { isQueryError } from '../../external/BaseApiClient.js';
export class SavePlexProgramExternalIdsTask extends Task { export class SavePlexProgramExternalIdsTask extends Task {
ID = SavePlexProgramExternalIdsTask.name; ID = SavePlexProgramExternalIdsTask.name;
@@ -36,13 +37,13 @@ export class SavePlexProgramExternalIdsTask extends Task {
} }
let chosenId: Maybe<ProgramExternalId> = undefined; let chosenId: Maybe<ProgramExternalId> = undefined;
let api: Maybe<Plex>; let api: Maybe<PlexApiClient>;
for (const id of plexIds) { for (const id of plexIds) {
if (!isNonEmptyString(id.externalSourceId)) { if (!isNonEmptyString(id.externalSourceId)) {
continue; continue;
} }
api = await PlexApiFactory().getOrSet(id.externalSourceId); api = await MediaSourceApiFactory().getOrSet(id.externalSourceId);
if (isDefined(api)) { if (isDefined(api)) {
chosenId = id; chosenId = id;
@@ -56,7 +57,7 @@ export class SavePlexProgramExternalIdsTask extends Task {
const metadataResult = await api.getItemMetadata(chosenId.externalKey); const metadataResult = await api.getItemMetadata(chosenId.externalKey);
if (isPlexQueryError(metadataResult)) { if (isQueryError(metadataResult)) {
this.logger.error( this.logger.error(
'Error querying Plex for item %s', 'Error querying Plex for item %s',
chosenId.externalKey, chosenId.externalKey,

View File

@@ -7,7 +7,7 @@ import { ProgramType } from '../../dao/entities/Program.js';
type SavePlexProgramGroupingsRequest = { type SavePlexProgramGroupingsRequest = {
programType: ProgramType; programType: ProgramType;
plexServerName: string; plexServerName: string;
programAndPlexIds: { programId: string; plexId: string, parentKey }[]; programAndPlexIds: { programId: string; plexId: string; parentKey }[];
parentKeys: string[]; parentKeys: string[];
grandparentKey: string; grandparentKey: string;
}; };
@@ -23,8 +23,9 @@ export class SavePlexProgramGroupingsTask extends Task {
} }
protected async runInternal(): Promise<unknown> { protected async runInternal(): Promise<unknown> {
const calculator = new ProgramGroupingCalculator(this.programDB); await new ProgramGroupingCalculator(
await calculator.createHierarchyForManyFromPlex( this.programDB,
).createHierarchyForManyFromPlex(
this.request.programType, this.request.programType,
this.request.plexServerName, this.request.plexServerName,
this.request.programAndPlexIds, this.request.programAndPlexIds,

View File

@@ -1,11 +1,11 @@
import { RecurrenceRule } from 'node-schedule'; import { RecurrenceRule } from 'node-schedule';
import { PlexServerSettings } from '../dao/entities/PlexServerSettings'; import { MediaSource } from '../../dao/entities/MediaSource';
import { PlexApiFactory } from '../external/PlexApiFactory'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory';
import { run } from '../util'; import { run } from '../../util';
import { ScheduledTask } from './ScheduledTask'; import { ScheduledTask } from '../ScheduledTask';
import { Task } from './Task'; import { Task } from '../Task';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlobalScheduler } from '../services/scheduler'; import { GlobalScheduler } from '../../services/scheduler';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
type UpdatePlexPlayStatusScheduleRequest = { type UpdatePlexPlayStatusScheduleRequest = {
@@ -33,7 +33,7 @@ export class UpdatePlexPlayStatusScheduledTask extends ScheduledTask {
private playState: PlayState = 'playing'; private playState: PlayState = 'playing';
constructor( constructor(
private plexServer: PlexServerSettings, private plexServer: MediaSource,
private request: UpdatePlexPlayStatusScheduleRequest, private request: UpdatePlexPlayStatusScheduleRequest,
public sessionId: string = v4(), public sessionId: string = v4(),
) { ) {
@@ -96,14 +96,14 @@ class UpdatePlexPlayStatusTask extends Task {
} }
constructor( constructor(
private plexServer: PlexServerSettings, private plexServer: MediaSource,
private request: UpdatePlexPlayStatusInvocation, private request: UpdatePlexPlayStatusInvocation,
) { ) {
super(); super();
} }
protected async runInternal(): Promise<boolean> { protected async runInternal(): Promise<boolean> {
const plex = PlexApiFactory().get(this.plexServer); const plex = MediaSourceApiFactory().get(this.plexServer);
const deviceName = `tunarr-channel-${this.request.channelNumber}`; const deviceName = `tunarr-channel-${this.request.channelNumber}`;
const params = { const params = {
@@ -119,7 +119,7 @@ class UpdatePlexPlayStatusTask extends Task {
}; };
try { try {
await plex.doPost('/:/timeline', params); await plex.doPost({ url: '/:/timeline', params });
} catch (error) { } catch (error) {
this.logger.warn( this.logger.warn(
error, error,

View File

@@ -4,9 +4,14 @@ import {
FastifyPluginAsync, FastifyPluginAsync,
FastifyPluginCallback, FastifyPluginCallback,
RawServerDefault, RawServerDefault,
RouteGenericInterface,
} from 'fastify'; } from 'fastify';
import { ZodTypeProvider } from 'fastify-type-provider-zod'; 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 { IncomingMessage, ServerResponse } from 'http';
import { z } from 'zod';
export type ServerType = FastifyInstance< export type ServerType = FastifyInstance<
RawServerDefault, RawServerDefault,
@@ -27,3 +32,27 @@ export type RouterPluginAsyncCallback = FastifyPluginAsync<
RawServerDefault, RawServerDefault,
ZodTypeProvider ZodTypeProvider
>; >;
export type ZodFastifySchema = {
body?: z.AnyZodObject;
querystring?: z.AnyZodObject;
params?: z.AnyZodObject;
headers?: z.AnyZodObject;
response?: z.AnyZodObject;
};
export type ZodFastifyRequest<Schema extends FastifySchema = ZodFastifySchema> =
FastifyRequest<
RouteGenericInterface,
RawServerDefault,
IncomingMessage,
Readonly<Schema>,
ZodTypeProvider,
unknown,
FastifyBaseLogger,
ResolveFastifyRequestType<
ZodTypeProvider,
Readonly<Schema>,
RouteGenericInterface
>
>;

View File

@@ -4,6 +4,8 @@ export type Maybe<T> = T | undefined;
export type Nullable<T> = T | null; export type Nullable<T> = T | null;
export type Nilable<T> = Maybe<T> | Nullable<T>;
export type TupleToUnion<T extends ReadonlyArray<unknown>> = T[number]; export type TupleToUnion<T extends ReadonlyArray<unknown>> = T[number];
export type Intersection<X, Y> = { export type Intersection<X, Y> = {

View File

@@ -1,17 +1,28 @@
import { EntityManager, ref } from '@mikro-orm/better-sqlite'; import {
EntityManager,
RequiredEntityData,
ref,
} from '@mikro-orm/better-sqlite';
import { import {
PlexEpisode, PlexEpisode,
PlexMovie, PlexMovie,
PlexMusicTrack, PlexMusicTrack,
PlexTerminalMedia, PlexTerminalMedia,
} from '@tunarr/types/plex'; } 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 { ProgramSourceType } from '../dao/custom_types/ProgramSourceType.js';
import { Program, ProgramType } from '../dao/entities/Program.js'; import { Program, ProgramType } from '../dao/entities/Program.js';
import { ProgramExternalId } from '../dao/entities/ProgramExternalId.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 { LoggerFactory } from './logging/LoggerFactory.js';
import { parsePlexExternalGuid } from './externalIds.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 * Generates Program DB entities for Plex media
@@ -24,18 +35,46 @@ class PlexProgramMinter {
this.#em = em; this.#em = em;
} }
mint(serverName: string, plexItem: PlexTerminalMedia) { mint(serverName: string, program: ContentProgramOriginalProgram) {
switch (plexItem.type) { switch (program.sourceType) {
case 'movie': case 'plex':
return this.mintMovieProgram(serverName, plexItem); switch (program.program.type) {
case 'episode': case 'movie':
return this.mintEpisodeProgram(serverName, plexItem); return this.mintMovieProgramForPlex(serverName, program.program);
case 'track': case 'episode':
return this.mintTrackProgram(serverName, plexItem); 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 ?? []); const file = first(first(plexMovie.Media)?.Part ?? []);
return this.#em.create( return this.#em.create(
Program, 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<Program>,
{ persist: false },
);
}
private mintEpisodeProgramForPlex(
serverName: string, serverName: string,
plexEpisode: PlexEpisode, plexEpisode: PlexEpisode,
): Program { ): Program {
@@ -92,7 +158,39 @@ class PlexProgramMinter {
return program; 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 ?? []); const file = first(first(plexTrack.Media)?.Part ?? []);
return this.#em.create( return this.#em.create(
Program, 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( 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, serverName: string,
program: Program, program: Program,
media: PlexTerminalMedia, media: PlexTerminalMedia,
@@ -167,6 +310,60 @@ class PlexProgramMinter {
return [ratingId, guidId, ...externalGuids]; 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 { export class ProgramMinterFactory {

48
server/src/util/axios.ts Normal file
View File

@@ -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;
},
);
}

View File

@@ -16,6 +16,7 @@ import _, {
map, map,
once, once,
range, range,
reject,
zipWith, zipWith,
} from 'lodash-es'; } from 'lodash-es';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
@@ -464,3 +465,7 @@ export function nullToUndefined<T>(x: T | null | undefined): T | undefined {
} }
return x; return x;
} }
export function removeErrors<T>(coll: Try<T>[] | null | undefined): T[] {
return reject(coll, isError) satisfies T[] as T[];
}

View File

@@ -1,25 +1,35 @@
import { ExternalId, SingleExternalId, MultiExternalId } from '@tunarr/types'; import { ExternalId, SingleExternalId, MultiExternalId } from '@tunarr/types';
import { PlexMedia } from '@tunarr/types/plex'; 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 { scheduleRandomSlots } from './services/randomSlotsService.js';
export { scheduleTimeSlots } from './services/timeSlotService.js'; export { scheduleTimeSlots } from './services/timeSlotService.js';
export { mod as dayjsMod } from './util/dayjsExtensions.js'; export { mod as dayjsMod } from './util/dayjsExtensions.js';
// TODO replace first arg with shared type // TODO replace first arg with shared type
export function createExternalId( export function createExternalId(
sourceType: ExternalIdType, sourceType: ExternalIdType, //StrictExclude<ExternalIdType, SingleExternalIdType>,
sourceId: string, sourceId: string,
itemId: string, itemId: string,
): `${string}|${string}|${string}` { ): `${string}|${string}|${string}` {
return `${sourceType}|${sourceId}|${itemId}`; return `${sourceType}|${sourceId}|${itemId}`;
} }
export function createGlobalExternalIdString(
sourceType: SingleExternalIdType,
id: string,
): `${string}|${string}` {
return `${sourceType}|${id}`;
}
export function createExternalIdFromMulti(multi: MultiExternalId) { export function createExternalIdFromMulti(multi: MultiExternalId) {
return createExternalId(multi.source, multi.sourceId, multi.id); return createExternalId(multi.source, multi.sourceId, multi.id);
} }
export function createExternalIdFromGlobal(global: SingleExternalId) { 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 // We could type this better if we reuse the other ExternalId

View File

@@ -4,6 +4,7 @@ import { PlexMedia } from '@tunarr/types/plex';
import isFunction from 'lodash-es/isFunction.js'; import isFunction from 'lodash-es/isFunction.js';
import { MarkRequired } from 'ts-essentials'; import { MarkRequired } from 'ts-essentials';
import type { PerTypeCallback } from '../types/index.js'; import type { PerTypeCallback } from '../types/index.js';
import { isNull } from 'lodash-es';
export { mod as dayjsMod } from './dayjsExtensions.js'; export { mod as dayjsMod } from './dayjsExtensions.js';
export * as seq from './seq.js'; export * as seq from './seq.js';
@@ -111,3 +112,10 @@ export function forPlexMedia<T>(choices: PerTypeCallback<PlexMedia, T>) {
return null; return null;
}; };
} }
export function nullToUndefined<T>(x: T | null | undefined): T | undefined {
if (isNull(x)) {
return undefined;
}
return x;
}

View File

@@ -42,6 +42,10 @@
"types": "./build/plex/index.d.ts", "types": "./build/plex/index.d.ts",
"default": "./build/plex/index.js" "default": "./build/plex/index.js"
}, },
"./jellyfin": {
"types": "./build/jellyfin/index.d.ts",
"default": "./build/jellyfin/index.js"
},
"./api": { "./api": {
"types": "./build/api/index.d.ts", "types": "./build/api/index.d.ts",
"default": "./build/api/index.js" "default": "./build/api/index.js"

View File

@@ -1,11 +1,19 @@
import z from 'zod'; import z from 'zod';
import { import {
JellyfinServerSettingsSchema,
MediaSourceSettingsSchema,
PlexServerSettingsSchema, PlexServerSettingsSchema,
PlexStreamSettingsSchema, PlexStreamSettingsSchema,
} from './schemas/settingsSchemas.js'; } from './schemas/settingsSchemas.js';
export type PlexServerSettings = z.infer<typeof PlexServerSettingsSchema>; export type PlexServerSettings = z.infer<typeof PlexServerSettingsSchema>;
export type JellyfinServerSettings = z.infer<
typeof JellyfinServerSettingsSchema
>;
export type MediaSourceSettings = z.infer<typeof MediaSourceSettingsSchema>;
export type PlexStreamSettings = z.infer<typeof PlexStreamSettingsSchema>; export type PlexStreamSettings = z.infer<typeof PlexStreamSettingsSchema>;
export const defaultPlexStreamSettings = PlexStreamSettingsSchema.parse({}); export const defaultPlexStreamSettings = PlexStreamSettingsSchema.parse({});

View File

@@ -6,6 +6,7 @@ import {
} from '../schemas/programmingSchema.js'; } from '../schemas/programmingSchema.js';
import { import {
BackupSettingsSchema, BackupSettingsSchema,
JellyfinServerSettingsSchema,
PlexServerSettingsSchema, PlexServerSettingsSchema,
} from '../schemas/settingsSchemas.js'; } from '../schemas/settingsSchemas.js';
import { import {
@@ -118,19 +119,36 @@ export const RandomSlotProgramLineupSchema = z.object({
schedule: RandomSlotScheduleSchema, schedule: RandomSlotScheduleSchema,
}); });
export const UpdateChannelProgrammingRequestSchema = z.discriminatedUnion( export const UpdateChannelProgrammingRequestSchema: z.ZodDiscriminatedUnion<
'type', 'type',
[ [
ManualProgramLineupSchema, typeof ManualProgramLineupSchema,
TimeBasedProgramLineupSchema, typeof TimeBasedProgramLineupSchema,
RandomSlotProgramLineupSchema, typeof RandomSlotProgramLineupSchema,
], ]
); > = z.discriminatedUnion('type', [
ManualProgramLineupSchema,
TimeBasedProgramLineupSchema,
RandomSlotProgramLineupSchema,
]);
export type UpdateChannelProgrammingRequest = z.infer< export type UpdateChannelProgrammingRequest = z.infer<
typeof UpdateChannelProgrammingRequestSchema 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({ export const UpdatePlexServerRequestSchema = PlexServerSettingsSchema.partial({
sendChannelUpdates: true, sendChannelUpdates: true,
sendGuideUpdates: true, sendGuideUpdates: true,
@@ -142,17 +160,18 @@ export type UpdatePlexServerRequest = z.infer<
typeof UpdatePlexServerRequestSchema typeof UpdatePlexServerRequestSchema
>; >;
export const InsertPlexServerRequestSchema = PlexServerSettingsSchema.partial({ export const InsertMediaSourceRequestSchema = z.discriminatedUnion('type', [
sendChannelUpdates: true, PlexServerSettingsSchema.partial({
sendGuideUpdates: true, sendChannelUpdates: true,
index: true, sendGuideUpdates: true,
clientIdentifier: true, index: true,
}).omit({ clientIdentifier: true,
id: true, }).omit({ id: true }),
}); JellyfinServerSettingsSchema.omit({ id: true }),
]);
export type InsertPlexServerRequest = z.infer< export type InsertMediaSourceRequest = z.infer<
typeof InsertPlexServerRequestSchema typeof InsertMediaSourceRequestSchema
>; >;
export const VersionApiResponseSchema = z.object({ export const VersionApiResponseSchema = z.object({
@@ -201,3 +220,9 @@ export type UpdateSystemSettingsRequest = z.infer<
>; >;
export const UpdateBackupSettingsRequestSchema = BackupSettingsSchema; export const UpdateBackupSettingsRequestSchema = BackupSettingsSchema;
export const JellyfinLoginRequest = z.object({
url: z.string().url(),
username: z.string().min(1),
password: z.string().min(1),
});

View File

@@ -5,7 +5,7 @@ export * from './FfmpegSettings.js';
export * from './FillerList.js'; export * from './FillerList.js';
export * from './GuideApi.js'; export * from './GuideApi.js';
export * from './HdhrSettings.js'; export * from './HdhrSettings.js';
export * from './PlexSettings.js'; export * from './MediaSourceSettings.js';
export * from './Program.js'; export * from './Program.js';
export * from './Tasks.js'; export * from './Tasks.js';
export * from './Theme.js'; export * from './Theme.js';

996
types/src/jellyfin/index.ts Normal file
View File

@@ -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<typeof JellyfinItemFields>;
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<typeof JellyfinUserResponse>;
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<typeof JellyfinLibrary>;
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<typeof JellyfinItemKind>;
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<typeof NameGuidPair>;
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<typeof MediaStream>;
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<typeof JellyfinItem>;
// 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);
}

View File

@@ -1,4 +1,5 @@
import z from 'zod'; import z from 'zod';
import { FindChild } from '../util.js';
export * from './dvr.js'; export * from './dvr.js';
@@ -757,21 +758,12 @@ export type PlexMetadataType<
T extends { Metadata: M[] } = { Metadata: M[] }, T extends { Metadata: M[] } = { Metadata: M[] },
> = T['Metadata'][0]; > = T['Metadata'][0];
type FindChild0<Target, Arr extends unknown[] = []> = Arr extends [
[infer Head, infer Child],
...infer Tail,
]
? Head extends Target
? Child
: FindChild0<Target, Tail>
: never;
export type PlexChildMediaType<Target extends PlexMedia> = export type PlexChildMediaType<Target extends PlexMedia> =
Target extends PlexTerminalMedia Target extends PlexTerminalMedia
? Target ? Target
: FindChild0<Target, PlexMediaToChildType>; : FindChild<Target, PlexMediaToChildType>;
export type PlexChildMediaApiType<Target extends PlexMedia> = FindChild0< export type PlexChildMediaApiType<Target extends PlexMedia> = FindChild<
Target, Target,
PlexMediaApiChildType PlexMediaApiChildType
>; >;

View File

@@ -9,6 +9,7 @@ import {
PlexMusicTrackSchema, PlexMusicTrackSchema,
} from '../plex/index.js'; } from '../plex/index.js';
import { ChannelIconSchema, ExternalIdSchema } from './utilSchemas.js'; import { ChannelIconSchema, ExternalIdSchema } from './utilSchemas.js';
import { JellyfinItem } from '../jellyfin/index.js';
export const ProgramTypeSchema = z.union([ export const ProgramTypeSchema = z.union([
z.literal('movie'), z.literal('movie'),
@@ -19,7 +20,10 @@ export const ProgramTypeSchema = z.union([
z.literal('flex'), 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({ export const ProgramSchema = z.object({
artistName: z.string().optional(), artistName: z.string().optional(),
@@ -80,17 +84,30 @@ export const RedirectProgramSchema = BaseProgramSchema.extend({
channelName: z.string(), 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({ export const CondensedContentProgramSchema = BaseProgramSchema.extend({
type: z.literal('content'), type: z.literal('content'),
id: z.string().optional(), // Populated if persisted id: z.string().optional(), // Populated if persisted
// Only populated on client requests to the server // Only populated on client requests to the server
originalProgram: z originalProgram: OriginalProgramSchema.optional(),
.discriminatedUnion('type', [
PlexEpisodeSchema,
PlexMovieSchema,
PlexMusicTrackSchema,
])
.optional(),
}); });
export const ContentProgramTypeSchema = z.union([ export const ContentProgramTypeSchema = z.union([

View File

@@ -1,6 +1,6 @@
import z from 'zod'; import z from 'zod';
import { ResolutionSchema } from './miscSchemas.js'; import { ResolutionSchema } from './miscSchemas.js';
import { TupleToUnion } from '../util.js'; import { Tag, TupleToUnion } from '../util.js';
import { ScheduleSchema } from './utilSchemas.js'; import { ScheduleSchema } from './utilSchemas.js';
export const XmlTvSettingsSchema = z.object({ export const XmlTvSettingsSchema = z.object({
@@ -88,19 +88,42 @@ export const FfmpegSettingsSchema = z.object({
disableChannelPrelude: z.boolean().default(false), disableChannelPrelude: z.boolean().default(false),
}); });
export const PlexServerSettingsSchema = z.object({ const mediaSourceId = z.custom<MediaSourceId>((val) => {
id: z.string(), return typeof val === 'string';
});
export type MediaSourceId = Tag<string, 'mediaSourceId'>;
const BaseMediaSourceSettingsSchema = z.object({
id: mediaSourceId,
name: z.string(), name: z.string(),
uri: z.string(), uri: z.string(),
accessToken: z.string(), accessToken: z.string(),
});
export const PlexServerSettingsSchema = BaseMediaSourceSettingsSchema.extend({
type: z.literal('plex'),
sendGuideUpdates: z.boolean(), sendGuideUpdates: z.boolean(),
sendChannelUpdates: z.boolean(), sendChannelUpdates: z.boolean(),
index: z.number(), index: z.number(),
clientIdentifier: z.string().optional(), 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({ 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), enableDebugLogging: z.boolean().default(false),
directStreamBitrate: z.number().default(20000), directStreamBitrate: z.number().default(20000),
transcodeBitrate: z.number().default(2000), transcodeBitrate: z.number().default(2000),

View File

@@ -9,6 +9,7 @@ export const ExternalIdType = [
'imdb', 'imdb',
'tmdb', 'tmdb',
'tvdb', 'tvdb',
'jellyfin',
] as const; ] as const;
export type ExternalIdType = TupleToUnion<typeof ExternalIdType>; export type ExternalIdType = TupleToUnion<typeof ExternalIdType>;
@@ -26,7 +27,7 @@ export const SingleExternalIdSourceSchema = constructZodLiteralUnionType(
SingleExternalIdType.map((typ) => z.literal(typ)), SingleExternalIdType.map((typ) => z.literal(typ)),
); );
export const MultiExternalIdType = ['plex'] as const; export const MultiExternalIdType = ['plex', 'jellyfin'] as const;
export type MultiExternalIdType = TupleToUnion<typeof MultiExternalIdType>; export type MultiExternalIdType = TupleToUnion<typeof MultiExternalIdType>;
function inConstArr<Arr extends readonly string[], S extends string>( function inConstArr<Arr extends readonly string[], S extends string>(
@@ -64,7 +65,10 @@ export const SingleExternalIdSchema = z.object({
}); });
// When we have more sources, this will be a union // 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 // Represents components of an ID that can be
// used to address an object (program or grouping) in // used to address an object (program or grouping) in

View File

@@ -14,3 +14,16 @@ export const tag = <
): UTag => x as unknown as UTag; ): UTag => x as unknown as UTag;
export type TupleToUnion<T extends ReadonlyArray<unknown>> = T[number]; export type TupleToUnion<T extends ReadonlyArray<unknown>> = 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<Target, Arr extends unknown[] = []> = Arr extends [
[infer Head, infer Child],
...infer Tail,
]
? Head extends Target
? Child
: FindChild<Target, Tail>
: never;

View File

@@ -6,6 +6,7 @@ export default defineConfig({
'schemas/index': 'src/schemas/index.ts', 'schemas/index': 'src/schemas/index.ts',
'plex/index': 'src/plex/index.ts', 'plex/index': 'src/plex/index.ts',
'api/index': 'src/api/index.ts', 'api/index': 'src/api/index.ts',
'jellyfin/index': 'src/jellyfin/index.ts',
}, },
format: 'esm', format: 'esm',
dts: true, dts: true,

View File

@@ -73,6 +73,7 @@
"ts-essentials": "^9.4.1", "ts-essentials": "^9.4.1",
"typescript": "5.4.3", "typescript": "5.4.3",
"vite": "^5.4.1", "vite": "^5.4.1",
"vite-plugin-svgr": "^4.2.0",
"vitest": "^2.0.5" "vitest": "^2.0.5"
} }
} }

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="110.25" x2="496.14" y1="213.3" y2="436.09"><stop offset="0" stop-color="#aa5cc3"/><stop offset="1" stop-color="#00a4dc"/></linearGradient><g fill="url(#a)"><path d="m256 201.6c-20.4 0-86.2 119.3-76.2 139.4s142.5 19.9 152.4 0-55.7-139.4-76.2-139.4z"/><path d="m256 23.3c-61.6 0-259.8 359.4-229.6 420.1s429.3 60 459.2 0-168-420.1-229.6-420.1zm150.5 367.5c-19.6 39.3-281.1 39.8-300.9 0s110.1-275.3 150.4-275.3 170.1 235.9 150.5 275.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 607 B

View File

@@ -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<HTMLDivElement>;
};
export const GridContainerTabPanel = forwardRef(
(props: TabPanelProps, ref: ForwardedRef<HTMLDivElement>) => {
const { children, value, index, ...other } = props;
const viewType = useStore((state) => state.theme.programmingSelectorView);
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
key={value}
{...other}
>
{value === index && children && (
<Grid
container
component={'div'}
spacing={2}
sx={{
display: viewType === 'grid' ? 'grid' : 'flex',
gridTemplateColumns:
viewType === 'grid'
? 'repeat(auto-fill, minmax(160px, 1fr))'
: 'none',
justifyContent: viewType === 'grid' ? 'space-around' : 'normal',
mt: 2,
}}
ref={ref}
>
{children}
</Grid>
)}
</div>
);
},
);

View File

@@ -1,60 +1,62 @@
import {
useCurrentMediaSource,
useKnownMedia,
} from '@/store/programmingSelector/selectors.ts';
import { Box, Collapse, List } from '@mui/material'; 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 { usePrevious } from '@uidotdev/usehooks';
import _ from 'lodash-es'; import { chain, first } from 'lodash-es';
import React, { import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
useCallback, import { useBoolean, useIntersectionObserver } from 'usehooks-ts';
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useIntersectionObserver } from 'usehooks-ts';
import { import {
extractLastIndexes, extractLastIndexes,
findFirstItemInNextRowIndex, findFirstItemInNextRowIndex,
getEstimatedModalHeight, getEstimatedModalHeight,
getImagesPerRow, getImagesPerRow,
} from '../helpers/inlineModalUtil'; } from '../helpers/inlineModalUtil';
import { toggle } from '../helpers/util.ts';
import useStore from '../store'; import useStore from '../store';
import { PlexGridItem } from './channel_config/PlexGridItem'; import { GridInlineModalProps } from './channel_config/MediaItemGrid.tsx';
type InlineModalProps = { interface InlineModalProps<ItemType, ItemKind extends string>
itemGuid: string; extends GridInlineModalProps<ItemType> {
modalIndex: number; getItemType: (item: ItemType) => ItemKind; // Tmp change //PlexMedia['type'] | 'all';
open?: boolean; getChildItemType: (item: ItemType) => ItemKind;
rowSize: number; sourceType: MediaSourceSettings['type'];
type: PlexMedia['type'] | 'all'; extractItemId: (item: ItemType) => string;
}; }
export function InlineModal(props: InlineModalProps) { export function InlineModal<ItemType, ItemKind extends string>(
const { itemGuid, modalIndex, open, rowSize, type } = props; props: InlineModalProps<ItemType, ItemKind>,
) {
const {
modalItemGuid: itemGuid,
modalIndex,
open,
rowSize,
getItemType,
getChildItemType,
extractItemId,
renderChildren,
} = props;
const previousItemGuid = usePrevious(itemGuid); const previousItemGuid = usePrevious(itemGuid);
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
const [itemWidth, setItemWidth] = useState(0); const [itemWidth, setItemWidth] = useState(0);
const [isOpen, setIsOpen] = useState(false); const {
value: isOpen,
setTrue: setOpen,
setFalse: setClosed,
} = useBoolean(false);
const ref = useRef<HTMLUListElement>(null); const ref = useRef<HTMLUListElement>(null);
const gridItemRef = useRef<HTMLDivElement>(null); const gridItemRef = useRef<HTMLDivElement>(null);
const inlineModalRef = useRef<HTMLDivElement>(null); const inlineModalRef = useRef<HTMLDivElement>(null);
const darkMode = useStore((state) => state.theme.darkMode); const darkMode = useStore((state) => state.theme.darkMode);
const [childLimit, setChildLimit] = useState(9); const [childLimit, setChildLimit] = useState(9);
const [imagesPerRow, setImagesPerRow] = useState(0); const [imagesPerRow, setImagesPerRow] = useState(0);
const modalChildren: PlexMedia[] = useStore((s) => { const currentMediaSource = useCurrentMediaSource(props.sourceType);
const known = s.contentHierarchyByServer[s.currentServer!.name]; const knownMedia = useKnownMedia();
if (known) { const modalChildren = knownMedia
const children = known[itemGuid]; .getChildren(currentMediaSource!.id, itemGuid ?? '')
if (children) { .map((media) => media.item) as ItemType[];
return _.chain(children)
.map((id) => s.knownMediaByServer[s.currentServer!.name][id])
.compact()
.filter(isPlexMedia)
.value();
}
}
return [];
});
const modalHeight = useMemo( const modalHeight = useMemo(
() => () =>
@@ -63,15 +65,11 @@ export function InlineModal(props: InlineModalProps) {
containerWidth, containerWidth,
itemWidth, itemWidth,
modalChildren.length, 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(() => { useEffect(() => {
if (ref.current && previousItemGuid !== itemGuid) { if (ref.current && previousItemGuid !== itemGuid) {
const containerWidth = ref?.current?.getBoundingClientRect().width || 0; const containerWidth = ref?.current?.getBoundingClientRect().width || 0;
@@ -82,11 +80,17 @@ export function InlineModal(props: InlineModalProps) {
setItemWidth(itemWidth); setItemWidth(itemWidth);
setContainerWidth(containerWidth); setContainerWidth(containerWidth);
setImagesPerRow(imagesPerRow); setImagesPerRow(imagesPerRow);
setChildModalInfo({ childItemGuid: '', childModalIndex: -1 });
} }
}, [ref, gridItemRef, previousItemGuid, itemGuid]); }, [ref, gridItemRef, previousItemGuid, itemGuid]);
const [childItemGuid, setChildItemGuid] = useState<string | null>(null); const [{ childModalIndex, childItemGuid }, setChildModalInfo] = useState<{
const [childModalIndex, setChildModalIndex] = useState(-1); childItemGuid: string | null;
childModalIndex: number;
}>({
childItemGuid: null,
childModalIndex: -1,
});
const firstItemInNextRowIndex = useMemo( const firstItemInNextRowIndex = useMemo(
() => () =>
@@ -98,10 +102,25 @@ export function InlineModal(props: InlineModalProps) {
[childModalIndex, modalChildren?.length, rowSize], [childModalIndex, modalChildren?.length, rowSize],
); );
const handleMoveModal = useCallback((index: number, item: PlexMedia) => { const handleMoveModal = useCallback(
setChildItemGuid((prev) => (prev === item.guid ? null : item.guid)); (index: number, item: ItemType) => {
setChildModalIndex((prev) => (prev === index ? -1 : index)); 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 // TODO: Complete this by updating the limit below, not doing this
// right now because already working with a huge changeset. // right now because already working with a huge changeset.
@@ -125,10 +144,52 @@ export function InlineModal(props: InlineModalProps) {
).includes(childModalIndex) ).includes(childModalIndex)
: false; : 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 ( return (
<Box <Box
ref={inlineModalRef} ref={inlineModalRef}
component="div" component="div"
className={
`inline-modal-${itemGuid} ` +
(open ? 'inline-modal-open ' : ' ') +
(isOpen ? 'animation-done' : '')
}
sx={{ sx={{
display: isOpen ? 'grid' : 'none', display: isOpen ? 'grid' : 'none',
gridColumn: isOpen ? '1 / -1' : undefined, gridColumn: isOpen ? '1 / -1' : undefined,
@@ -136,7 +197,7 @@ export function InlineModal(props: InlineModalProps) {
> >
<Collapse <Collapse
in={open} in={open}
timeout={100} timeout={150}
easing={{ easing={{
enter: 'easeInSine', enter: 'easeInSine',
exit: 'easeOutSine', exit: 'easeOutSine',
@@ -144,8 +205,8 @@ export function InlineModal(props: InlineModalProps) {
mountOnEnter mountOnEnter
unmountOnExit unmountOnExit
sx={{ width: '100%', display: 'grid', gridColumn: '1 / -1' }} sx={{ width: '100%', display: 'grid', gridColumn: '1 / -1' }}
onEnter={toggleModal} onEnter={show}
onExited={toggleModal} onExited={hide}
> >
<List <List
component="ul" component="ul"
@@ -166,39 +227,24 @@ export function InlineModal(props: InlineModalProps) {
}} }}
ref={ref} ref={ref}
> >
{_.chain(modalChildren) {isOpen && (
.filter(isPlexMedia) <>
.take(childLimit) {chain(modalChildren)
.map((child: PlexMedia, idx: number) => ( .take(childLimit)
<React.Fragment key={child.guid}> .map((item, idx) => renderChild(idx, item))
{isPlexParentItem(child) && ( .value()}
<InlineModal <InlineModal
itemGuid={childItemGuid ?? ''} {...props}
modalIndex={childModalIndex} getItemType={getChildItemType}
open={idx === firstItemInNextRowIndex} modalItemGuid={childItemGuid ?? ''}
rowSize={rowSize} modalIndex={childModalIndex}
type={child.type} open={isFinalChildModalOpen}
/> />
)} {childLimit < modalChildren.length && (
<PlexGridItem <li style={{ height: 40 }} ref={intersectionRef}></li>
item={child} )}
index={idx} </>
modalIndex={modalIndex ?? childModalIndex} )}
ref={gridItemRef}
moveModal={handleMoveModal}
/>
</React.Fragment>
))
.value()}
{/* This Modal is for last row items because they can't be inserted using the above inline modal */}
<InlineModal
itemGuid={childItemGuid ?? ''}
modalIndex={childModalIndex}
rowSize={rowSize}
open={isFinalChildModalOpen}
type={'season'}
/>
<li style={{ height: 40 }} ref={intersectionRef}></li>
</List> </List>
</Collapse> </Collapse>
</Box> </Box>

View File

@@ -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 { Close as CloseIcon, OpenInNew } from '@mui/icons-material';
import { import {
Box, Box,
@@ -9,15 +12,20 @@ import {
IconButton, IconButton,
Skeleton, Skeleton,
Stack, Stack,
SvgIcon,
Typography, Typography,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from '@mui/material'; } from '@mui/material';
import { createExternalId } from '@tunarr/shared'; import { createExternalId } from '@tunarr/shared';
import { forProgramType } from '@tunarr/shared/util'; 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 dayjs, { Dayjs } from 'dayjs';
import { isUndefined } from 'lodash-es'; import { capitalize, compact, find, isUndefined } from 'lodash-es';
import { import {
ReactEventHandler, ReactEventHandler,
useCallback, useCallback,
@@ -94,9 +102,10 @@ export default function ProgramDetailsDialog({
forProgramType({ forProgramType({
content: (program) => ( content: (program) => (
<Chip <Chip
key="duration"
color="primary" color="primary"
label={prettyItemDuration(program.duration)} label={prettyItemDuration(program.duration)}
sx={{ mt: 1 }} sx={{ mt: 1, mr: 1 }}
/> />
), ),
}), }),
@@ -107,12 +116,83 @@ export default function ProgramDetailsDialog({
(program: ChannelProgram) => { (program: ChannelProgram) => {
const ratingString = rating(program); const ratingString = rating(program);
return ratingString ? ( return ratingString ? (
<Chip color="primary" label={ratingString} sx={{ mx: 1, mt: 1 }} /> <Chip
key="rating"
color="primary"
label={ratingString}
sx={{ mr: 1, mt: 1 }}
/>
) : null; ) : null;
}, },
[rating], [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<JSX.Element> = undefined;
switch (id.source) {
case 'jellyfin':
icon = <JellyfinIcon />;
break;
case 'plex':
icon = <PlexIcon />;
break;
default:
break;
}
if (icon) {
return (
<Chip
key="source"
color="primary"
icon={<SvgIcon>{icon}</SvgIcon>}
label={capitalize(id.source)}
sx={{ mr: 1, mt: 1 }}
/>
);
}
}
return null;
}, []);
const timeChip = () => {
if (start && stop) {
return (
<Chip
key="time"
label={`${dayjs(start).format('h:mm')} - ${dayjs(stop).format(
'h:mma',
)}`}
sx={{ mt: 1, mr: 1 }}
color="primary"
/>
);
}
return null;
};
const chips = (program: ChannelProgram) => {
return compact([
durationChip(program),
ratingChip(program),
timeChip(),
sourceChip(program),
]);
};
const thumbnailImage = useMemo( const thumbnailImage = useMemo(
() => () =>
forProgramType({ forProgramType({
@@ -131,16 +211,35 @@ export default function ProgramDetailsDialog({
} }
let key = p.uniqueId; let key = p.uniqueId;
if ( if (p.subtype === 'track' && p.originalProgram) {
p.subtype === 'track' && switch (p.originalProgram.sourceType) {
p.originalProgram?.type === 'track' && case 'plex': {
isNonEmptyString(p.originalProgram.parentRatingKey) if (
) { p.originalProgram.program.type === 'track' &&
key = createExternalId( isNonEmptyString(p.originalProgram.program.parentRatingKey)
'plex', ) {
p.externalSourceName!, key = createExternalId(
p.originalProgram.parentRatingKey, 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`; return `${settings.backendUri}/api/metadata/external?id=${key}&mode=proxy&asset=thumb`;
@@ -181,8 +280,33 @@ export default function ProgramDetailsDialog({
const isEpisode = const isEpisode =
program && program.type === 'content' && program.subtype === 'episode'; program && program.type === 'content' && program.subtype === 'episode';
const imageWidth = smallViewport ? (isEpisode ? '100%' : '55%') : 240; 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 ( return (
program && ( program && (
@@ -206,17 +330,7 @@ export default function ProgramDetailsDialog({
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2}> <Stack spacing={2}>
<Box> <Box>{chips(program)}</Box>
{durationChip(program)}
{ratingChip(program)}
<Chip
label={`${programStart.format('h:mm')} - ${programEnd.format(
'h:mma',
)}`}
sx={{ mt: 1 }}
color="primary"
/>
</Box>
<Stack <Stack
direction="row" direction="row"
spacing={smallViewport ? 0 : 2} spacing={smallViewport ? 0 : 2}
@@ -241,7 +355,13 @@ export default function ProgramDetailsDialog({
<Skeleton <Skeleton
variant="rectangular" variant="rectangular"
width={smallViewport ? '100%' : imageWidth} width={smallViewport ? '100%' : imageWidth}
height={500} height={
program.type === 'content' && program.subtype === 'movie'
? 500
: smallViewport
? undefined
: 140
}
animation={thumbLoadState === 'loading' ? 'pulse' : false} animation={thumbLoadState === 'loading' ? 'pulse' : false}
></Skeleton> ></Skeleton>
)} )}
@@ -267,7 +387,7 @@ export default function ProgramDetailsDialog({
width={imageWidth} width={imageWidth}
/> />
)} )}
{externalUrl && ( {externalUrl && isNonEmptyString(externalSourceName) && (
<Button <Button
component="a" component="a"
target="_blank" target="_blank"
@@ -276,7 +396,7 @@ export default function ProgramDetailsDialog({
endIcon={<OpenInNew />} endIcon={<OpenInNew />}
variant="contained" variant="contained"
> >
View in Plex View in {externalSourceName}
</Button> </Button>
)} )}
</Box> </Box>

View File

@@ -1,51 +1,22 @@
import { Unstable_Grid2 as Grid } from '@mui/material'; interface TabPanelProps {
import { ForwardedRef, forwardRef } from 'react';
import useStore from '../store';
type TabPanelProps = {
children?: React.ReactNode; children?: React.ReactNode;
index: number; index: number;
value: number; value: number;
ref?: React.RefObject<HTMLDivElement>; }
};
const CustomTabPanel = forwardRef( export function TabPanel(props: TabPanelProps) {
(props: TabPanelProps, ref: ForwardedRef<HTMLDivElement>) => { const { children, value, index, ...other } = props;
const { children, value, index, ...other } = props;
const viewType = useStore((state) => state.theme.programmingSelectorView); return (
<div
return ( role="tabpanel"
<div hidden={value !== index}
role="tabpanel" id={`simple-tabpanel-${index}`}
hidden={value !== index} aria-labelledby={`simple-tab-${index}`}
id={`simple-tabpanel-${index}`} {...other}
aria-labelledby={`simple-tab-${index}`} >
key={value} {/* {value === index && <Box sx={{ p: 3 }}>{children}</Box>} */}
{...other} {value === index && children}
> </div>
{value === index && children && ( );
<Grid }
container
component={'div'}
spacing={2}
sx={{
display: viewType === 'grid' ? 'grid' : 'flex',
gridTemplateColumns:
viewType === 'grid'
? 'repeat(auto-fill, minmax(160px, 1fr))'
: 'none',
justifyContent: viewType === 'grid' ? 'space-around' : 'normal',
mt: 2,
}}
ref={ref}
>
{children}
</Grid>
)}
</div>
);
},
);
export default CustomTabPanel;

View File

@@ -13,6 +13,8 @@ import useStore from '../../store/index.ts';
import { clearSelectedMedia } from '../../store/programmingSelector/actions.ts'; import { clearSelectedMedia } from '../../store/programmingSelector/actions.ts';
import { CustomShowSelectedMedia } from '../../store/programmingSelector/store.ts'; import { CustomShowSelectedMedia } from '../../store/programmingSelector/store.ts';
import { AddedCustomShowProgram, AddedMedia } from '../../types/index.ts'; import { AddedCustomShowProgram, AddedMedia } from '../../types/index.ts';
import { useKnownMedia } from '@/store/programmingSelector/selectors.ts';
import { enumerateJellyfinItem } from '@/hooks/jellyfin/jellyfinHookUtil.ts';
type Props = { type Props = {
onAdd: (items: AddedMedia[]) => void; onAdd: (items: AddedMedia[]) => void;
@@ -29,7 +31,7 @@ export default function AddSelectedMediaButton({
...rest ...rest
}: Props) { }: Props) {
const apiClient = useTunarrApi(); const apiClient = useTunarrApi();
const knownMedia = useStore((s) => s.knownMediaByServer); const knownMedia = useKnownMedia();
const selectedMedia = useStore((s) => s.selectedMedia); const selectedMedia = useStore((s) => s.selectedMedia);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -41,14 +43,45 @@ export default function AddSelectedMediaButton({
selectedMedia, selectedMedia,
forSelectedMediaType<Promise<AddedMedia[]>>({ forSelectedMediaType<Promise<AddedMedia[]>>({
plex: async (selected) => { 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( const items = await enumeratePlexItem(
apiClient, apiClient,
selected.server, selected.serverId,
selected.serverName,
media, media,
)(); )();
return map(items, (item) => ({ media: item, type: 'plex' })); 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': ( 'custom-show': (
selected: CustomShowSelectedMedia, selected: CustomShowSelectedMedia,
): Promise<AddedCustomShowProgram[]> => { ): Promise<AddedCustomShowProgram[]> => {

View File

@@ -340,7 +340,7 @@ export default function ChannelProgrammingList({
const [focusedProgramDetails, setFocusedProgramDetails] = useState< const [focusedProgramDetails, setFocusedProgramDetails] = useState<
ChannelProgram | undefined ChannelProgram | undefined
>(); >();
const [startStop, setStartStop] = useState<GuideTime>({}); const [, setStartStop] = useState<GuideTime>({});
const [editProgram, setEditProgram] = useState< const [editProgram, setEditProgram] = useState<
((UIFlexProgram | UIRedirectProgram) & { index: number }) | undefined ((UIFlexProgram | UIRedirectProgram) & { index: number }) | undefined
>(); >();
@@ -474,8 +474,6 @@ export default function ChannelProgrammingList({
open={!isUndefined(focusedProgramDetails)} open={!isUndefined(focusedProgramDetails)}
onClose={() => setFocusedProgramDetails(undefined)} onClose={() => setFocusedProgramDetails(undefined)}
program={focusedProgramDetails} program={focusedProgramDetails}
start={startStop.start}
stop={startStop.stop}
/> />
<AddFlexModal <AddFlexModal
open={!isUndefined(editProgram) && editProgram.type === 'flex'} open={!isUndefined(editProgram) && editProgram.type === 'flex'}

View File

@@ -0,0 +1,168 @@
import { isEqual, isNil } from 'lodash-es';
import pluralize from 'pluralize';
import {
ForwardedRef,
forwardRef,
memo,
useCallback,
useMemo,
useState,
} from 'react';
import {
forJellyfinItem,
isNonEmptyString,
prettyItemDuration,
toggle,
} from '../../helpers/util.ts';
import { useJellyfinLibraryItems } from '@/hooks/jellyfin/useJellyfinApi.ts';
import { addJellyfinSelectedMedia } from '@/store/programmingSelector/actions.ts';
import { useCurrentMediaSource } from '@/store/programmingSelector/selectors.ts';
import { SelectedMedia } from '@/store/programmingSelector/store.ts';
import { JellyfinItem, JellyfinItemKind } from '@tunarr/types/jellyfin';
import { MediaGridItem } from './MediaGridItem.tsx';
import { GridItemProps } from './MediaItemGrid.tsx';
export interface JellyfinGridItemProps extends GridItemProps<JellyfinItem> {}
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<JellyfinItemKind>({
Season: 'Episode',
Series: 'Season',
});
const subtitle = forJellyfinItem({
Movie: (item) => (
<span>{prettyItemDuration((item.RunTimeTicks ?? 0) / 10_000)}</span>
),
default: (item) => {
const childCount = extractChildCount(item);
if (isNil(childCount)) {
return null;
}
return (
<span>{`${childCount} ${pluralize(
childItemType(item) ?? 'item',
childCount,
)}`}</span>
);
},
});
export const JellyfinGridItem = memo(
forwardRef(
(props: JellyfinGridItemProps, ref: ForwardedRef<HTMLDivElement>) => {
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 && (
<MediaGridItem
{...props}
key={props.item.Id}
itemSource="jellyfin"
ref={ref}
metadata={metadata}
onClick={handleItemClick}
onSelect={(item) => addJellyfinSelectedMedia(currentServer, item)}
/>
)
);
},
),
(prev, next) => {
// if (!isEqual(prev, next)) {
// console.log(prev, next);
// }
return isEqual(prev, next);
},
);

View File

@@ -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<HTMLButtonElement>) => {
e.stopPropagation();
if (selectedMediaIds.includes(item.Id)) {
removePlexSelectedMedia(selectedServer.id, [item.Id]);
} else {
addJellyfinSelectedMedia(selectedServer, item);
}
},
[item, selectedServer, selectedMediaIds],
);
const renderChildren = () => {
return isPending ? (
<Skeleton />
) : (
<List sx={{ pl: 4 }}>
{children?.Items.map((child, idx, arr) => (
<JellyfinListItem
key={child.Id}
item={child}
index={idx}
length={arr.length}
/>
))}
</List>
);
};
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 (
<React.Fragment key={item.Id}>
<ListItemButton onClick={handleClick} dense sx={{ width: '100%' }}>
{hasChildren && (
<ListItemIcon>{open ? <ExpandLess /> : <ExpandMore />}</ListItemIcon>
)}
<ListItemText primary={item.Name} secondary={getSecondaryText()} />
<Button onClick={(e) => handleItem(e)} variant="contained">
{hasChildren
? `Add ${item.Type}`
: selectedMediaIds.includes(item.Id)
? 'Remove'
: `Add ${item.Type}`}
</Button>
</ListItemButton>
<Collapse in={open} timeout="auto" unmountOnExit sx={{ width: '100%' }}>
{renderChildren()}
</Collapse>
<Divider variant="fullWidth" />
</React.Fragment>
);
}

View File

@@ -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<JellyfinItemKind>({
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<HTMLElement>,
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<MediaSourceId>(''),
selectedLibrary?.library.Id ?? '',
itemTypes,
true,
20,
);
const totalItems = useMemo(() => {
return first(jellyfinItemsQuery.data?.pages)?.TotalRecordCount ?? 0;
}, [jellyfinItemsQuery.data]);
const renderGridItem = (
gridItemProps: GridItemProps<JellyfinItem>,
modalProps: GridInlineModalProps<JellyfinItem>,
) => {
const isLast = gridItemProps.index === totalItems - 1;
const renderModal =
isParentItem(gridItemProps.item) &&
((gridItemProps.index + 1) % modalProps.rowSize === 0 || isLast);
return (
<React.Fragment key={gridItemProps.item.Id}>
<JellyfinGridItem {...gridItemProps} />
{renderModal && (
<InlineModal
{...modalProps}
extractItemId={typedProperty('Id')}
sourceType="jellyfin"
getItemType={typedProperty('Type')}
getChildItemType={childJellyfinType}
/>
)}
</React.Fragment>
);
};
return (
<>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
sx={{
display: 'flex',
pt: 1,
columnGap: 1,
alignItems: 'flex-end',
justifyContent: 'flex-end',
flexGrow: 1,
}}
>
<ToggleButtonGroup value={viewType} onChange={handleFormat} exclusive>
<ToggleButton value="list">
<ViewList />
</ToggleButton>
<ToggleButton value="grid">
<GridView />
</ToggleButton>
</ToggleButtonGroup>
</Stack>
<Tabs
value={tabValue}
onChange={(_, value: number) => setTabValue(value)}
aria-label="Jellyfin media selector tabs"
variant="scrollable"
allowScrollButtonsMobile
>
<Tab
value={TabValues.Library}
label="Library"
// {...a11yProps(0)}
/>
{/* {!isUndefined(collectionsData) &&
sumBy(collectionsData.pages, (page) => page.size) > 0 && (
<Tab
value={TabValues.Collections}
label="Collections"
{...a11yProps(1)}
/>
)}
{!isUndefined(playlistData) &&
sumBy(playlistData.pages, 'size') > 0 && (
<Tab
value={TabValues.Playlists}
label="Playlists"
{...a11yProps(1)}
/>
)} */}
</Tabs>
</Box>
<MediaItemGrid
getPageDataSize={(page) => ({
total: page.TotalRecordCount,
size: page.Items.length,
})}
extractItems={(page) => page.Items}
getItemKey={useCallback((item: JellyfinItem) => item.Id, [])}
renderGridItem={renderGridItem}
renderListItem={(item, index) => (
<JellyfinListItem item={item} index={index} />
)}
// renderFinalRow={renderFinalRowInlineModal}
infiniteQuery={jellyfinItemsQuery}
/>
</>
);
}

View File

@@ -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<T> = {
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<T> = {
item: T;
itemSource: MediaSourceSettings['type'];
// extractors: GridItemMetadataExtractors<T>;
metadata: GridItemMetadata;
style?: React.CSSProperties;
index: number;
isModalOpen: boolean;
onClick: (item: T) => void;
onSelect: (item: T) => void;
};
const MediaGridItemInner = <T,>(
props: Props<T>,
ref: ForwardedRef<HTMLDivElement>,
) => {
// 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<HTMLDivElement | HTMLButtonElement>) => {
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 (
<Fade
in={isInViewport && !isUndefined(item) && hasThumbnail === imageLoaded}
timeout={750}
ref={imageContainerRef}
>
<div className="testtesteststestes">
<ImageListItem
component={Grid}
key={itemId}
sx={{
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
paddingLeft: '8px !important',
paddingRight: '8px',
paddingTop: '8px',
height: 'auto',
backgroundColor: (theme) =>
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 ? (
<Box
sx={{
position: 'relative',
minHeight: isMusicItem ? 100 : isEpisodeItem ? 84 : 225, // 84 accomodates episode img height
maxHeight: '100%',
}}
>
<img
src={thumbnailUrl}
style={{
borderRadius: '5%',
height: 'auto',
width: '100%',
visibility: imageLoaded ? 'visible' : 'hidden',
zIndex: 2,
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageLoaded(true)}
/>
<Box
component="div"
sx={{
background: skeletonBgColor,
borderRadius: '5%',
position: 'absolute',
top: 0,
left: 0,
aspectRatio: isMusicItem
? '1/1'
: isEpisodeItem
? '1.77/1'
: '2/3',
width: '100%',
height: 'auto',
zIndex: 1,
opacity: imageLoaded ? 0 : 1,
visibility: imageLoaded ? 'hidden' : 'visible',
minHeight: isMusicItem ? 100 : isEpisodeItem ? 84 : 225,
}}
></Box>
</Box>
) : (
<Skeleton
animation={false}
variant="rounded"
sx={{ borderRadius: '5%' }}
height={isMusicItem ? 144 : isEpisodeItem ? 84 : 250}
/>
))}
<ImageListItemBar
title={title}
subtitle={subtitle}
position="below"
actionIcon={
<IconButton
aria-label={`star ${title}`}
onClick={(event: MouseEvent<HTMLButtonElement>) =>
handleItem(event)
}
>
{isSelected ? <CheckCircle /> : <RadioButtonUnchecked />}
</IconButton>
}
actionPosition="right"
/>
</ImageListItem>
</div>
</Fade>
);
};
// );
export const MediaGridItem = forwardRef(MediaGridItemInner) as <T>(
props: Props<T> & { ref?: ForwardedRef<HTMLDivElement> },
) => ReturnType<typeof MediaGridItemInner>;

View File

@@ -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<ItemType> {
item: ItemType;
index: number;
isModalOpen: boolean;
moveModal: (index: number, item: ItemType) => void;
ref: ForwardedRef<HTMLDivElement>;
}
export interface GridInlineModalProps<ItemType> {
open: boolean;
modalItemGuid: Nullable<string>;
modalIndex: number;
rowSize: number;
renderChildren: (
gridItemProps: GridItemProps<ItemType>,
modalProps: GridInlineModalProps<ItemType>,
) => JSX.Element;
}
type Props<PageDataType, ItemType> = {
getPageDataSize: (page: PageDataType) => { total?: number; size: number };
extractItems: (page: PageDataType) => ItemType[];
renderGridItem: (
gridItemProps: GridItemProps<ItemType>,
modalProps: GridInlineModalProps<ItemType>,
) => JSX.Element;
renderListItem: (item: ItemType, index: number) => JSX.Element;
getItemKey: (item: ItemType) => string;
infiniteQuery: UseInfiniteQueryResult<InfiniteData<PageDataType>>;
};
type Size = {
width?: number;
height?: number;
};
type ModalState = {
modalIndex: number;
modalGuid: Nullable<string>;
};
// magic number for top bar padding; TODO: calc it off ref
const TopBarPadddingPx = 64;
export function MediaItemGrid<PageDataType, ItemType>({
getPageDataSize,
renderGridItem,
renderListItem,
getItemKey,
extractItems,
infiniteQuery: {
data,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
isLoading,
},
}: Props<PageDataType, ItemType>) {
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<ModalState>({
modalGuid: null,
modalIndex: -1,
});
const gridContainerRef = useRef<HTMLDivElement>(null);
const selectedModalItemRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null);
const previousModalIndex = usePrevious(modalIndex);
const [{ width }, setSize] = useState<Size>({
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 (
<>
<Box ref={gridContainerRef} sx={{ width: '100%' }}>
<Grid
container
component="div"
spacing={2}
sx={{
display: viewType === 'grid' ? 'grid' : 'flex',
gridTemplateColumns:
viewType === 'grid'
? 'repeat(auto-fill, minmax(160px, 1fr))'
: 'none',
justifyContent: viewType === 'grid' ? 'space-around' : 'normal',
mt: 2,
}}
ref={ref}
>
{renderItems()}
</Grid>
</Box>
{!isLoading && <div style={{ height: 96 }} ref={ref}></div>}
{isFetchingNextPage && (
<CircularProgress
color="primary"
sx={{ display: 'block', margin: '2em auto' }}
/>
)}
{data && !hasNextPage && (
<Typography fontStyle={'italic'} sx={{ textAlign: 'center' }}>
fin.
</Typography>
)}
<Divider sx={{ mt: 3, mb: 2 }} />
</>
);
}

View File

@@ -23,7 +23,7 @@ import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react';
import { usePlexTyped2 } from '../../hooks/plex/usePlex.ts'; import { usePlexTyped2 } from '../../hooks/plex/usePlex.ts';
import useStore from '../../store/index.ts'; import useStore from '../../store/index.ts';
import { import {
addKnownMediaForServer, addKnownMediaForPlexServer,
addPlexSelectedMedia, addPlexSelectedMedia,
} from '../../store/programmingSelector/actions.ts'; } from '../../store/programmingSelector/actions.ts';
import { PlexListItem } from './PlexListItem.tsx'; import { PlexListItem } from './PlexListItem.tsx';
@@ -43,20 +43,20 @@ export function PlexDirectoryListItem(props: {
PlexLibraryCollections PlexLibraryCollections
>([ >([
{ {
serverName: props.server.name, serverId: props.server.id,
path: `/library/sections/${item.key}/all`, path: `/library/sections/${item.key}/all`,
enabled: open, enabled: open,
}, },
{ {
serverName: props.server.name, serverId: props.server.id,
path: `/library/sections/${item.key}/collections`, path: `/library/sections/${item.key}/collections`,
enabled: open, enabled: open,
}, },
]); ]);
const listings = useStore((s) => s.knownMediaByServer[server.name]); const listings = useStore((s) => s.knownMediaByServer[server.id]);
const hierarchy = useStore( const hierarchy = useStore(
(s) => s.contentHierarchyByServer[server.name][item.uuid], (s) => s.contentHierarchyByServer[server.id][item.uuid],
); );
const observerTarget = useRef(null); const observerTarget = useRef(null);
@@ -86,13 +86,13 @@ export function PlexDirectoryListItem(props: {
useEffect(() => { useEffect(() => {
if (children && children.Metadata) { if (children && children.Metadata) {
addKnownMediaForServer(server.name, children.Metadata, item.uuid); addKnownMediaForPlexServer(server.id, children.Metadata, item.uuid);
} }
if (collections && collections.Metadata) { 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 = () => { const handleClick = () => {
setLimit(Math.min(hierarchy.length, 20)); setLimit(Math.min(hierarchy.length, 20));
@@ -102,17 +102,17 @@ export function PlexDirectoryListItem(props: {
const addItems = useCallback( const addItems = useCallback(
(e: MouseEvent<HTMLButtonElement>) => { (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
addPlexSelectedMedia(server.name, [item]); addPlexSelectedMedia(server, [item]);
}, },
[item, server.name], [item, server],
); );
const renderCollectionRow = (id: string) => { const renderCollectionRow = (id: string) => {
const media = listings[id]; const { type, item } = listings[id];
if (isPlexMedia(media)) { if (type === 'plex' && isPlexMedia(item)) {
return ( return (
<PlexListItem key={media.guid} item={media} length={hierarchy.length} /> <PlexListItem key={item.guid} item={item} length={hierarchy.length} />
); );
} }
}; };

View File

@@ -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 { import {
PlexChildMediaApiType, PlexChildMediaApiType,
PlexMedia, PlexMedia,
isPlexPlaylist, isPlexPlaylist,
isTerminalItem, isTerminalItem,
} from '@tunarr/types/plex'; } 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 pluralize from 'pluralize';
import React, { import {
ForwardedRef, ForwardedRef,
MouseEvent,
forwardRef, forwardRef,
memo,
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useState, useState,
} from 'react'; } from 'react';
import { useIntersectionObserver } from 'usehooks-ts';
import { import {
forPlexMedia, forPlexMedia,
isNonEmptyString, isNonEmptyString,
prettyItemDuration, prettyItemDuration,
toggle, toggle,
} from '../../helpers/util.ts'; } 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<T extends PlexMedia> { import { usePlexTyped } from '@/hooks/plex/usePlex.ts';
item: T; import {
style?: React.CSSProperties; addKnownMediaForPlexServer,
index?: number; addPlexSelectedMedia,
parent?: string; } from '@/store/programmingSelector/actions.ts';
moveModal?: (index: number, item: T) => void; import { useCurrentMediaSource } from '@/store/programmingSelector/selectors.ts';
modalIndex?: number; import { SelectedMedia } from '@/store/programmingSelector/store.ts';
onClick?: () => void; import { useSettings } from '@/store/settings/selectors.ts';
ref?: React.RefObject<HTMLDivElement>; import { createExternalId } from '@tunarr/shared';
} import { MediaGridItem } from './MediaGridItem.tsx';
import { GridItemProps } from './MediaItemGrid.tsx';
export interface PlexGridItemProps<T extends PlexMedia>
extends GridItemProps<T> {}
const genPlexChildPath = forPlexMedia({ const genPlexChildPath = forPlexMedia({
collection: (collection) => collection: (collection) =>
@@ -67,6 +49,7 @@ const extractChildCount = forPlexMedia({
show: (s) => s.childCount, show: (s) => s.childCount,
collection: (s) => parseInt(s.childCount), collection: (s) => parseInt(s.childCount),
playlist: (s) => s.leafCount, playlist: (s) => s.leafCount,
default: 0,
}); });
const childItemType = forPlexMedia({ const childItemType = forPlexMedia({
@@ -95,213 +78,131 @@ const subtitle = forPlexMedia({
}, },
}); });
export const PlexGridItem = forwardRef( export const PlexGridItem = memo(
<T extends PlexMedia>( forwardRef(
props: PlexGridItemProps<T>, <T extends PlexMedia>(
ref: ForwardedRef<HTMLDivElement>, props: PlexGridItemProps<T>,
) => { ref: ForwardedRef<HTMLDivElement>,
const settings = useSettings(); ) => {
const theme = useTheme(); const { item, index, moveModal } = props;
const skeletonBgColor = alpha( const server = useCurrentMediaSource('plex')!; // We have to have a server at this point
theme.palette.text.primary, const settings = useSettings();
theme.palette.mode === 'light' ? 0.11 : 0.13, const [modalOpen, setModalOpen] = useState(false);
); const currentServer = useCurrentMediaSource('plex');
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<PlexChildMediaApiType<T>>(
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);
const handleClick = () => { const isMusicItem = useCallback(
setOpen(toggle); (item: PlexMedia) =>
['MusicArtist', 'MusicAlbum', 'Audio'].includes(item.type),
[],
);
if (!isUndefined(index) && !isUndefined(moveModal)) { const isEpisode = useCallback(
moveModal(index, item); (item: PlexMedia) => item.type === 'episode',
} [],
}; );
useEffect(() => { const onSelect = useCallback(
if (!isUndefined(children?.Metadata)) { (item: PlexMedia) => {
addKnownMediaForServer(server.name, children.Metadata, item.guid); addPlexSelectedMedia(server, [item]);
} },
}, [item.guid, server.name, children]); [server],
);
const handleItem = useCallback( const { data: childItems } = usePlexTyped<PlexChildMediaApiType<T>>(
(e: MouseEvent<HTMLDivElement | HTMLButtonElement>) => { server.id,
e.stopPropagation(); genPlexChildPath(props.item),
!isTerminalItem(item) && modalOpen,
);
if (selectedMediaIds.includes(item.guid)) { useEffect(() => {
removePlexSelectedMedia(selectedServer!.name, [item.guid]); if (
} else { !isUndefined(childItems) &&
addPlexSelectedMedia(selectedServer!.name, [item]); !isEmpty(childItems.Metadata) &&
isNonEmptyString(currentServer?.id)
) {
addKnownMediaForPlexServer(
currentServer.id,
childItems.Metadata,
item.guid,
);
} }
}, }, [childItems, currentServer?.id, item.guid]);
[item, selectedServer, selectedMediaIds],
);
const { isIntersecting: isInViewport, ref: imageContainerRef } = const moveModalToItem = useCallback(() => {
useIntersectionObserver({ moveModal(index, item);
threshold: 0, }, [index, item, moveModal]);
rootMargin: '0px',
freezeOnceVisible: true,
});
const extractChildCount = forPlexMedia({ const handleItemClick = useCallback(() => {
season: (s) => s.leafCount, setModalOpen(toggle);
show: (s) => s.childCount, moveModalToItem();
collection: (s) => parseInt(s.childCount), }, [moveModalToItem]);
});
let childCount = isUndefined(item) ? null : extractChildCount(item); const thumbnailUrlFunc = useCallback(
if (isNaN(childCount)) { (item: PlexMedia) => {
childCount = null; 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( return `${
item.type, 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; const metadata = useMemo(
if (isPlexPlaylist(item)) { () => ({
thumbSrc = `${server.uri}${item.composite}?X-Plex-Token=${server.accessToken}`; itemId: item.guid,
} else { hasThumbnail: isNonEmptyString(
const query = new URLSearchParams({ isPlexPlaylist(item) ? item.composite : item.thumb,
mode: 'proxy', ),
asset: 'thumb', childCount: extractChildCount(item),
id: createExternalId('plex', server.name, item.ratingKey), title: item.title,
// Commenting this out for now as temporary solution for image loading issue subtitle: subtitle(item),
// thumbOptions: JSON.stringify({ width: 480, height: 720 }), thumbnailUrl: thumbnailUrlFunc(item),
}); selectedMedia: selectedMediaFunc(item),
isMusicItem: isMusicItem(item),
isEpisode: isEpisode(item),
isPlaylist: isPlexPlaylist(item),
}),
[isEpisode, isMusicItem, item, selectedMediaFunc, thumbnailUrlFunc],
);
thumbSrc = `${ return (
settings.backendUri currentServer && (
}/api/metadata/external?${query.toString()}`; <MediaGridItem
} {...props}
key={props.item.guid}
return ( itemSource="plex"
<Fade
in={isInViewport && !isUndefined(item) && hasThumb === imageLoaded}
timeout={750}
ref={imageContainerRef}
>
<div>
<ImageListItem
component={Grid}
key={item.guid}
sx={{
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
paddingLeft: '8px !important',
paddingRight: '8px',
paddingTop: '8px',
height: 'auto',
backgroundColor: (theme) =>
props.modalIndex === props.index
? darkMode
? theme.palette.grey[800]
: theme.palette.grey[400]
: 'transparent',
...style,
}}
onClick={
hasChildren
? handleClick
: (event: MouseEvent<HTMLDivElement>) => handleItem(event)
}
ref={ref} ref={ref}
> metadata={metadata}
{isInViewport && // TODO: Eventually turn this into isNearViewport so images load before they hit the viewport onClick={handleItemClick}
(hasThumb ? ( onSelect={onSelect}
<Box />
sx={{ )
position: 'relative', );
minHeight: isMusicItem ? 100 : isEpisodeItem ? 84 : 225, // 84 accomodates episode img height },
maxHeight: '100%', ),
}} isEqual,
>
<img
src={thumbSrc}
style={{
borderRadius: '5%',
height: 'auto',
width: '100%',
visibility: imageLoaded ? 'visible' : 'hidden',
zIndex: 2,
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageLoaded(true)}
/>
<Box
component="div"
sx={{
background: skeletonBgColor,
borderRadius: '5%',
position: 'absolute',
top: 0,
left: 0,
aspectRatio: isMusicItem
? '1/1'
: isEpisodeItem
? '1.77/1'
: '2/3',
width: '100%',
height: 'auto',
zIndex: 1,
opacity: imageLoaded ? 0 : 1,
visibility: imageLoaded ? 'hidden' : 'visible',
minHeight: isMusicItem ? 100 : isEpisodeItem ? 84 : 225,
}}
></Box>
</Box>
) : (
<Skeleton
animation={false}
variant="rounded"
sx={{ borderRadius: '5%' }}
height={isMusicItem ? 144 : isEpisodeItem ? 84 : 250}
/>
))}
<ImageListItemBar
title={item.title}
subtitle={subtitle(item)}
position="below"
actionIcon={
<IconButton
aria-label={`star ${item.title}`}
onClick={(event: MouseEvent<HTMLButtonElement>) =>
handleItem(event)
}
>
{selectedMediaIds.includes(item.guid) ? (
<CheckCircle />
) : (
<RadioButtonUnchecked />
)}
</IconButton>
}
actionPosition="right"
/>
</ImageListItem>
</div>
</Fade>
);
},
); );

View File

@@ -28,11 +28,12 @@ import {
import { usePlexTyped } from '../../hooks/plex/usePlex.ts'; import { usePlexTyped } from '../../hooks/plex/usePlex.ts';
import useStore from '../../store/index.ts'; import useStore from '../../store/index.ts';
import { import {
addKnownMediaForServer, addKnownMediaForPlexServer,
addPlexSelectedMedia, addPlexSelectedMedia,
removePlexSelectedMedia, removePlexSelectedMedia,
} from '../../store/programmingSelector/actions.ts'; } from '../../store/programmingSelector/actions.ts';
import { PlexSelectedMedia } from '../../store/programmingSelector/store.ts'; import { PlexSelectedMedia } from '../../store/programmingSelector/store.ts';
import { useCurrentMediaSource } from '@/store/programmingSelector/selectors.ts';
export interface PlexListItemProps<T extends PlexMedia> { export interface PlexListItemProps<T extends PlexMedia> {
item: T; item: T;
@@ -61,15 +62,15 @@ export function PlexListItem<T extends PlexMedia>(props: PlexListItemProps<T>) {
const hasChildren = !isTerminalItem(item); const hasChildren = !isTerminalItem(item);
const childPath = isPlexCollection(item) ? 'collections' : 'metadata'; const childPath = isPlexCollection(item) ? 'collections' : 'metadata';
const { isPending, data: children } = usePlexTyped<PlexChildMediaApiType<T>>( const { isPending, data: children } = usePlexTyped<PlexChildMediaApiType<T>>(
server.name, server.id,
`/library/${childPath}/${props.item.ratingKey}/children`, `/library/${childPath}/${props.item.ratingKey}/children`,
hasChildren && open, hasChildren && open,
); );
const selectedServer = useStore((s) => s.currentServer); const selectedServer = useCurrentMediaSource('plex');
const selectedMedia = useStore((s) => const selectedMedia = useStore((s) =>
filter(s.selectedMedia, (m): m is PlexSelectedMedia => m.type === 'plex'), filter(s.selectedMedia, (m): m is PlexSelectedMedia => m.type === 'plex'),
); );
const selectedMediaIds = map(selectedMedia, typedProperty('guid')); const selectedMediaIds = map(selectedMedia, typedProperty('id'));
const handleClick = () => { const handleClick = () => {
setOpen(!open); setOpen(!open);
@@ -77,18 +78,18 @@ export function PlexListItem<T extends PlexMedia>(props: PlexListItemProps<T>) {
useEffect(() => { useEffect(() => {
if (children) { 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( const handleItem = useCallback(
(e: MouseEvent<HTMLButtonElement>) => { (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
if (selectedMediaIds.includes(item.guid)) { if (selectedMediaIds.includes(item.guid)) {
removePlexSelectedMedia(selectedServer!.name, [item.guid]); removePlexSelectedMedia(selectedServer!.id, [item.guid]);
} else { } else {
addPlexSelectedMedia(selectedServer!.name, [item]); addPlexSelectedMedia(selectedServer!, [item]);
} }
}, },
[item, selectedServer, selectedMediaIds], [item, selectedServer, selectedMediaIds],

View File

@@ -1,14 +1,16 @@
import { usePlexCollectionsInfinite } from '@/hooks/plex/usePlexCollections.ts'; import { usePlexCollectionsInfinite } from '@/hooks/plex/usePlexCollections.ts';
import { usePlexPlaylistsInfinite } from '@/hooks/plex/usePlexPlaylists.ts'; import { usePlexPlaylistsInfinite } from '@/hooks/plex/usePlexPlaylists.ts';
import { usePlexSearchInfinite } from '@/hooks/plex/usePlexSearch.ts'; import { usePlexSearchInfinite } from '@/hooks/plex/usePlexSearch.ts';
import {
useCurrentMediaSource,
useCurrentSourceLibrary,
} from '@/store/programmingSelector/selectors.ts';
import FilterAlt from '@mui/icons-material/FilterAlt'; import FilterAlt from '@mui/icons-material/FilterAlt';
import GridView from '@mui/icons-material/GridView'; import GridView from '@mui/icons-material/GridView';
import ViewList from '@mui/icons-material/ViewList'; import ViewList from '@mui/icons-material/ViewList';
import { import {
Box, Box,
CircularProgress,
Collapse, Collapse,
Divider,
Grow, Grow,
LinearProgress, LinearProgress,
Stack, Stack,
@@ -16,59 +18,32 @@ import {
Tabs, Tabs,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
Typography,
} from '@mui/material'; } from '@mui/material';
import { import { PlexMedia, isPlexParentItem } from '@tunarr/types/plex';
PlexMedia, import { chain, filter, first, isNil, isUndefined, sumBy } from 'lodash-es';
PlexMovie, import React, { useCallback, useEffect, useMemo, useState } from 'react';
PlexMusicArtist, import { isNonEmptyString, toggle } from '../../helpers/util.ts';
PlexTvShow, import { usePlexLibraries } from '../../hooks/plex/usePlex.ts';
isPlexParentItem, import { useMediaSources } from '../../hooks/settingsHooks.ts';
} from '@tunarr/types/plex'; import useStore from '../../store/index.ts';
import { usePrevious } from '@uidotdev/usehooks'; import { addKnownMediaForPlexServer } from '../../store/programmingSelector/actions.ts';
import _, { import { setProgrammingSelectorViewState } from '../../store/themeEditor/actions.ts';
chain, import { ProgramSelectorViewType } from '../../types/index.ts';
compact, import { InlineModal } from '../InlineModal.tsx';
first, import { TabPanel } from '../TabPanel.tsx';
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 StandaloneToggleButton from '../base/StandaloneToggleButton.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 { PlexFilterBuilder } from './PlexFilterBuilder.tsx';
import { PlexGridItem } from './PlexGridItem'; import { PlexGridItem } from './PlexGridItem.tsx';
import { PlexListItem } from './PlexListItem';
import { PlexSortField } from './PlexSortField.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) { function a11yProps(index: number) {
return { return {
@@ -77,15 +52,6 @@ function a11yProps(index: number) {
}; };
} }
type RefMap = {
[k: string]: HTMLDivElement | null;
};
type Size = {
width?: number;
height?: number;
};
enum TabValues { enum TabValues {
Library = 0, Library = 0,
Collections = 1, Collections = 1,
@@ -93,125 +59,23 @@ enum TabValues {
} }
export default function PlexProgrammingSelector() { export default function PlexProgrammingSelector() {
const { data: plexServers } = usePlexServerSettings(); const { data: mediaSources } = useMediaSources();
const selectedServer = useStore((s) => s.currentServer); const plexServers = filter(mediaSources, { type: 'plex' });
const selectedLibrary = useStore((s) => const selectedServer = useCurrentMediaSource('plex');
s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, const selectedLibrary = useCurrentSourceLibrary('plex');
);
const viewType = useStore((state) => state.theme.programmingSelectorView); const viewType = useStore((state) => state.theme.programmingSelectorView);
const [tabValue, setTabValue] = useState(TabValues.Library); const [tabValue, setTabValue] = useState(TabValues.Library);
const [rowSize, setRowSize] = useState(9);
const [modalIndex, setModalIndex] = useState(-1);
const [modalGuid, setModalGuid] = useState<string>('');
const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 }); const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 });
const [searchVisible, setSearchVisible] = useState(false); const [searchVisible, setSearchVisible] = useState(false);
const [useAdvancedSearch, setUseAdvancedSearch] = useState(false); const [useAdvancedSearch, setUseAdvancedSearch] = useState(false);
const gridContainerRef = useRef<HTMLDivElement>(null);
const gridImageRefs = useRef<RefMap>({});
const previousModalIndex = usePrevious(modalIndex);
const [{ width }, setSize] = useState<Size>({
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) => { const handleChange = (_: React.SyntheticEvent, newValue: TabValues) => {
setTabValue(newValue); setTabValue(newValue);
}; };
const scrollToGridItem = useCallback( const { data: directoryChildren } = usePlexLibraries(
(guid: string, index: number) => { selectedServer?.id ?? tag<MediaSourceId>(''),
const selectedElement = gridImageRefs.current[guid]; selectedServer?.type === 'plex',
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 setViewType = (view: ProgramSelectorViewType) => { const setViewType = (view: ProgramSelectorViewType) => {
@@ -225,27 +89,24 @@ export default function PlexProgrammingSelector() {
setViewType(newFormats); setViewType(newFormats);
}; };
const { const plexCollectionsQuery = usePlexCollectionsInfinite(
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(
selectedServer, selectedServer,
selectedLibrary, selectedLibrary,
rowSize * 4, 24,
// selectedLibrary?.library.type === 'artist',
); );
const { isLoading: isCollectionLoading, data: collectionsData } =
plexCollectionsQuery;
const plexPlaylistsQuery = usePlexPlaylistsInfinite(
selectedServer,
selectedLibrary,
24,
);
const { isLoading: isPlaylistLoading, data: playlistData } =
plexPlaylistsQuery;
useEffect(() => { useEffect(() => {
// When switching between Libraries, if a collection doesn't exist switch back to 'Library' tab // When switching between Libraries, if a collection doesn't exist switch back to 'Library' tab
if ( if (
@@ -274,19 +135,15 @@ export default function PlexProgrammingSelector() {
({ plexSearch: plexQuery }) => plexQuery, ({ plexSearch: plexQuery }) => plexQuery,
); );
const { const plexSearchQuery = usePlexSearchInfinite(
isLoading: searchLoading,
data: searchData,
fetchNextPage: fetchNextItemsPage,
hasNextPage: hasNextItemsPage,
isFetchingNextPage: isFetchingNextItemsPage,
} = usePlexSearchInfinite(
selectedServer, selectedServer,
selectedLibrary, selectedLibrary,
searchKey, searchKey,
rowSize * 4, 24,
); );
const { isLoading: searchLoading, data: searchData } = plexSearchQuery;
useEffect(() => { useEffect(() => {
if (searchData?.pages.length === 1) { if (searchData?.pages.length === 1) {
const size = searchData.pages[0].totalSize ?? searchData.pages[0].size; const size = searchData.pages[0].totalSize ?? searchData.pages[0].size;
@@ -309,235 +166,141 @@ export default function PlexProgrammingSelector() {
.map((page) => page.Metadata) .map((page) => page.Metadata)
.flatten() .flatten()
.value(); .value();
addKnownMediaForServer(selectedServer.name, allMedia); addKnownMediaForPlexServer(selectedServer.id, allMedia);
} }
} }
}, [scrollParams, selectedServer, searchData, rowSize]); }, [scrollParams, selectedServer, searchData]);
useEffect(() => { useEffect(() => {
if ( if (isNonEmptyString(selectedServer?.id) && !isUndefined(collectionsData)) {
isNonEmptyString(selectedServer?.name) &&
!isUndefined(collectionsData)
) {
const allCollections = chain(collectionsData.pages) const allCollections = chain(collectionsData.pages)
.reject((page) => page.size === 0) .reject((page) => page.size === 0)
.map((page) => page.Metadata) .map((page) => page.Metadata)
.compact() .compact()
.flatten() .flatten()
.value(); .value();
addKnownMediaForServer(selectedServer.name, allCollections); addKnownMediaForPlexServer(selectedServer.id, allCollections);
} }
}, [selectedServer?.name, collectionsData]); }, [selectedServer?.id, collectionsData]);
const { ref } = useIntersectionObserver({ const totalItems = useMemo(() => {
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;
switch (tabValue) { switch (tabValue) {
case TabValues.Library: case TabValues.Library:
firstItemIndex = firstItemInNextLibraryRowIndex; return first(plexSearchQuery.data?.pages)?.totalSize ?? 0;
break;
case TabValues.Collections: case TabValues.Collections:
firstItemIndex = firstItemInNextCollectionRowIndex; return first(collectionsData?.pages)?.totalSize ?? 0;
break;
case TabValues.Playlists: case TabValues.Playlists:
firstItemIndex = firstItemInNextPlaylistRowIndex; return first(playlistData?.pages)?.totalSize ?? 0;
break;
} }
}, [
collectionsData?.pages,
playlistData?.pages,
plexSearchQuery.data?.pages,
tabValue,
]);
const isOpen = index === firstItemIndex; const getPlexItemKey = useCallback((item: PlexMedia) => item.guid, []);
const renderGridItem = (
gridItemProps: GridItemProps<PlexMedia>,
modalProps: GridInlineModalProps<PlexMedia>,
) => {
const isLast = gridItemProps.index === totalItems - 1;
const renderModal =
isPlexParentItem(gridItemProps.item) &&
((gridItemProps.index + 1) % modalProps.rowSize === 0 || isLast);
return ( return (
<React.Fragment key={item.guid}> <React.Fragment key={gridItemProps.item.guid}>
{isPlexParentItem(item) && <PlexGridItem {...gridItemProps} />
(item.type === 'playlist' ? (item.leafCount ?? 0) < 500 : true) && ( {renderModal && (
<InlineModal <InlineModal
itemGuid={modalGuid} {...modalProps}
modalIndex={modalIndex} extractItemId={(item) => item.guid}
rowSize={rowSize} sourceType="plex"
open={isOpen} getItemType={(item) => item.type}
type={item.type} getChildItemType={() => 'season'}
/> />
)} )}
{/* 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 */}
<PlexGridItem
item={item}
index={index}
modalIndex={modalIndex}
moveModal={handleMoveModal}
ref={(element) => (gridImageRefs.current[item.guid] = element)}
/>
</React.Fragment> </React.Fragment>
); );
}; };
const renderFinalRowInlineModal = (arr: PlexMedia[]) => { const renderPanels = () => {
// /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 (
<InlineModal
itemGuid={modalGuid}
modalIndex={modalIndex}
rowSize={rowSize}
open={open}
type={'season'}
/>
);
};
const renderListItems = () => {
const elements: JSX.Element[] = []; const elements: JSX.Element[] = [];
if ((first(searchData?.pages)?.size ?? 0) > 0) {
elements.push(
<TabPanel index={TabValues.Library} value={tabValue} key="Library">
<MediaItemGrid
getPageDataSize={(page) => ({
total: page.totalSize,
size: page.size,
})}
extractItems={(page) => page.Metadata}
getItemKey={getPlexItemKey}
renderGridItem={renderGridItem}
renderListItem={(item) => (
<PlexListItem key={item.guid} item={item} />
)}
infiniteQuery={plexSearchQuery}
/>
</TabPanel>,
);
}
if ( if (
tabValue === TabValues.Collections && // tabValue === TabValues.Collections &&
collectionsData && (first(collectionsData?.pages)?.size ?? 0) > 0
(first(collectionsData.pages)?.size ?? 0) > 0
) { ) {
elements.push( elements.push(
<CustomTabPanel <TabPanel
value={tabValue}
index={TabValues.Collections} index={TabValues.Collections}
value={tabValue}
key="Collections" key="Collections"
> >
{map( <MediaItemGrid
compact(flatMap(collectionsData.pages, (page) => page.Metadata)), getPageDataSize={(page) => ({
(item, index: number) => total: page.totalSize,
viewType === 'list' ? ( size: page.size,
<PlexListItem key={item.guid} item={item} /> })}
) : ( extractItems={(page) => page.Metadata ?? []}
renderGridItems(item, index) getItemKey={getPlexItemKey}
), renderGridItem={renderGridItem}
)} renderListItem={(item) => (
{renderFinalRowInlineModal( <PlexListItem key={item.guid} item={item} />
compact(flatMap(collectionsData.pages, (page) => page.Metadata)), )}
)} infiniteQuery={plexCollectionsQuery}
</CustomTabPanel>, />
</TabPanel>,
); );
}
if ( if (
tabValue === TabValues.Playlists && // tabValue === TabValues.Collections &&
(first(playlistData?.pages)?.size ?? 0) > 0 (first(playlistData?.pages)?.size ?? 0) > 0
) { ) {
elements.push( elements.push(
<CustomTabPanel <TabPanel
value={tabValue} index={TabValues.Playlists}
index={TabValues.Playlists} value={tabValue}
key="Playlists" key="Playlists"
> >
{map( <MediaItemGrid
compact(flatMap(playlistData?.pages, (page) => page.Metadata)), getPageDataSize={(page) => ({
(item, index: number) => total: page.totalSize,
viewType === 'list' ? ( size: page.size,
})}
extractItems={(page) => page.Metadata ?? []}
getItemKey={getPlexItemKey}
renderGridItem={renderGridItem}
renderListItem={(item) => (
<PlexListItem key={item.guid} item={item} /> <PlexListItem key={item.guid} item={item} />
) : ( )}
renderGridItems(item, index) infiniteQuery={plexPlaylistsQuery}
), />
)} </TabPanel>,
{renderFinalRowInlineModal( );
compact(flatMap(playlistData?.pages, (page) => page.Metadata)), }
)}
</CustomTabPanel>,
);
}
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(
<CustomTabPanel value={tabValue} index={0} key="Library">
{map(
items,
(item: PlexMovie | PlexTvShow | PlexMusicArtist, index: number) =>
viewType === 'list' ? (
<PlexListItem key={item.guid} item={item} />
) : (
renderGridItems(item, index)
),
)}
{items.length >= totalSearchDataSize &&
renderFinalRowInlineModal(items)}
</CustomTabPanel>,
);
} }
return elements; return elements;
@@ -640,42 +403,25 @@ export default function PlexProgrammingSelector() {
<Tab <Tab
value={TabValues.Library} value={TabValues.Library}
label="Library" label="Library"
{...a11yProps(0)} {...a11yProps(TabValues.Library)}
/> />
{!isUndefined(collectionsData) && {sumBy(collectionsData?.pages, (page) => page.size) > 0 && (
sumBy(collectionsData.pages, (page) => page.size) > 0 && ( <Tab
<Tab value={TabValues.Collections}
value={TabValues.Collections} label="Collections"
label="Collections" {...a11yProps(TabValues.Collections)}
{...a11yProps(1)} />
/> )}
)} {sumBy(playlistData?.pages, 'size') > 0 && (
{!isUndefined(playlistData) && <Tab
sumBy(playlistData.pages, 'size') > 0 && ( value={TabValues.Playlists}
<Tab label="Playlists"
value={TabValues.Playlists} {...a11yProps(TabValues.Playlists)}
label="Playlists" />
{...a11yProps(1)} )}
/>
)}
</Tabs> </Tabs>
</Box> </Box>
<Box ref={gridContainerRef} sx={{ width: '100%' }}> {renderPanels()}
{renderListItems()}
</Box>
{!searchLoading && <div style={{ height: 96 }} ref={ref}></div>}
{isFetchingNextItemsPage && (
<CircularProgress
color="primary"
sx={{ display: 'block', margin: '2em auto' }}
/>
)}
{searchData && !hasNextItemsPage && (
<Typography fontStyle={'italic'} sx={{ textAlign: 'center' }}>
fin.
</Typography>
)}
<Divider sx={{ mt: 3, mb: 2 }} />
</> </>
)} )}
</> </>

View File

@@ -8,9 +8,9 @@ import find from 'lodash-es/find';
import isUndefined from 'lodash-es/isUndefined'; import isUndefined from 'lodash-es/isUndefined';
import map from 'lodash-es/map'; import map from 'lodash-es/map';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { usePlexFilters } from '../../hooks/plex/usePlexFilters'; import { useSelectedLibraryPlexFilters } from '../../hooks/plex/usePlexFilters';
import useStore from '../../store';
import { setPlexSort } from '../../store/programmingSelector/actions'; import { setPlexSort } from '../../store/programmingSelector/actions';
import { useCurrentSourceLibrary } from '@/store/programmingSelector/selectors.ts';
type PlexSort = { type PlexSort = {
key: string; key: string;
@@ -19,10 +19,7 @@ type PlexSort = {
}; };
export function PlexSortField() { export function PlexSortField() {
const selectedServer = useStore((s) => s.currentServer); const selectedLibrary = useCurrentSourceLibrary('plex');
const selectedLibrary = useStore((s) =>
s.currentLibrary?.type === 'plex' ? s.currentLibrary : null,
);
const [sort, setSort] = useState<PlexSort>({ const [sort, setSort] = useState<PlexSort>({
key: '', key: '',
@@ -31,10 +28,7 @@ export function PlexSortField() {
}); });
const { data: plexFilterMetadata, isLoading: filterMetadataLoading } = const { data: plexFilterMetadata, isLoading: filterMetadataLoading } =
usePlexFilters( useSelectedLibraryPlexFilters();
selectedServer?.name ?? '',
selectedLibrary?.library.key ?? '',
);
const libraryFilterMetadata = find( const libraryFilterMetadata = find(
plexFilterMetadata?.Type, plexFilterMetadata?.Type,

View File

@@ -9,20 +9,60 @@ import {
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { PlexMedia, isPlexDirectory } from '@tunarr/types/plex'; 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 React, { useCallback, useEffect, useState } from 'react';
import { usePlexLibraries } from '../../hooks/plex/usePlex.ts'; 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 { useCustomShows } from '../../hooks/useCustomShows.ts';
import useStore from '../../store/index.ts'; import useStore from '../../store/index.ts';
import { import {
addKnownMediaForServer, addKnownMediaForJellyfinServer,
addKnownMediaForPlexServer,
setProgrammingListLibrary, setProgrammingListLibrary,
setProgrammingListingServer, setProgrammingListingServer,
} from '../../store/programmingSelector/actions.ts'; } from '../../store/programmingSelector/actions.ts';
import AddPlexServer from '../settings/AddPlexServer.tsx'; import AddPlexServer from '../settings/AddPlexServer.tsx';
import { CustomShowProgrammingSelector } from './CustomShowProgrammingSelector.tsx'; import { CustomShowProgrammingSelector } from './CustomShowProgrammingSelector.tsx';
import PlexProgrammingSelector from './PlexProgrammingSelector.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<T extends PlexMedia> { export interface PlexListItemProps<T extends PlexMedia> {
item: T; item: T;
@@ -32,50 +72,76 @@ export interface PlexListItemProps<T extends PlexMedia> {
parent?: string; parent?: string;
} }
export default function ProgrammingSelector() { type Props = {
const { data: plexServers, isLoading: plexServersLoading } = initialMediaSourceId?: string;
usePlexServerSettings(); 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 selectedServer = useStore((s) => s.currentServer);
const selectedLibrary = useStore((s) => s.currentLibrary); const selectedLibrary = useStore((s) => s.currentLibrary);
const knownMedia = useStore((s) => s.knownMediaByServer); const knownMedia = useKnownMedia();
const [mediaSource, setMediaSource] = useState(selectedServer?.name); const [mediaSource, setMediaSource] = useState(selectedServer?.name);
// Convenience sub-selectors for specific library types // Convenience sub-selectors for specific library types
const selectedPlexLibrary = const selectedPlexLibrary =
selectedLibrary?.type === 'plex' ? selectedLibrary.library : undefined; selectedLibrary?.type === 'plex' ? selectedLibrary.library : undefined;
const selectedJellyfinLibrary =
selectedLibrary?.type === 'jellyfin' ? selectedLibrary.library : undefined;
const viewingCustomShows = mediaSource === 'custom-shows'; const viewingCustomShows = mediaSource === 'custom-shows';
/**
* Load Plex libraries
*/
const { data: plexLibraryChildren } = usePlexLibraries( const { data: plexLibraryChildren } = usePlexLibraries(
selectedServer?.name ?? '', selectedServer?.id ?? tag(''),
!isUndefined(selectedServer), selectedServer?.type === 'plex',
);
const { data: jellyfinLibraries } = useJellyfinUserLibraries(
selectedServer?.id ?? '',
selectedServer?.type === 'jellyfin',
); );
useEffect(() => { useEffect(() => {
const server = const server =
!isUndefined(plexServers) && !isEmpty(plexServers) !isUndefined(mediaSources) && !isEmpty(mediaSources)
? plexServers[0] ? mediaSources[0]
: undefined; : undefined;
setProgrammingListingServer(server); setProgrammingListingServer(server);
}, [plexServers]); }, [mediaSources]);
useEffect(() => { useEffect(() => {
if (selectedServer && plexLibraryChildren) { if (selectedServer?.type === 'plex' && plexLibraryChildren) {
if (plexLibraryChildren.size > 0) { if (
plexLibraryChildren.size > 0 &&
(!selectedLibrary || selectedLibrary.type !== 'plex')
) {
setProgrammingListLibrary({ setProgrammingListLibrary({
type: 'plex', type: 'plex',
library: plexLibraryChildren.Directory[0], library: plexLibraryChildren.Directory[0],
}); });
} }
addKnownMediaForServer(selectedServer.name, [ addKnownMediaForPlexServer(selectedServer.id, [
...plexLibraryChildren.Directory, ...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 * Load custom shows
@@ -83,44 +149,65 @@ export default function ProgrammingSelector() {
const { data: customShows } = useCustomShows(); const { data: customShows } = useCustomShows();
const onMediaSourceChange = useCallback( const onMediaSourceChange = useCallback(
(newMediaSource: string) => { (newMediaSourceId: string) => {
if (newMediaSource === 'custom-shows') { if (newMediaSourceId === 'custom-shows') {
// Not dealing with a server // Not dealing with a server
setProgrammingListLibrary({ type: 'custom-show' }); setProgrammingListLibrary({ type: 'custom-show' });
setProgrammingListingServer(undefined); setProgrammingListingServer(undefined);
setMediaSource(newMediaSource); setMediaSource(newMediaSourceId);
} else { } else {
const server = find(plexServers, { name: newMediaSource }); const server = find(
mediaSources,
(source) => source.id === newMediaSourceId,
);
if (server) { if (server) {
setProgrammingListingServer(server); setProgrammingListingServer(server);
setMediaSource(server.name); setMediaSource(server.name);
} }
} }
}, },
[plexServers], [mediaSources],
); );
const onLibraryChange = useCallback( const onLibraryChange = useCallback(
(libraryUuid: string) => { (libraryUuid: string) => {
if (selectedServer) { if (selectedServer?.type === 'plex') {
const known = knownMedia[selectedServer.name] ?? {}; const library = knownMedia.getMediaOfType(
const library = known[libraryUuid]; selectedServer.id,
libraryUuid,
'plex',
);
if (library && isPlexDirectory(library)) { if (library && isPlexDirectory(library)) {
setProgrammingListLibrary({ type: 'plex', 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], [knownMedia, selectedServer],
); );
const renderMediaSourcePrograms = () => { const renderMediaSourcePrograms = () => {
if (selectedLibrary?.type === 'custom-show') { if (selectedLibrary) {
return <CustomShowProgrammingSelector />; switch (selectedLibrary.type) {
} else if (selectedLibrary?.type === 'plex') { case 'plex':
return <PlexProgrammingSelector />; return <PlexProgrammingSelector />;
case 'jellyfin':
return <JellyfinProgrammingSelector />;
case 'custom-show':
return <CustomShowProgrammingSelector />;
}
} }
if (!plexServersLoading && !selectedServer) { // TODO: change the wording here to not be Plex-specific
if (!mediaSourcesLoading && !selectedServer) {
return ( return (
<> <>
<Typography variant="h6" fontWeight={600} align="left" sx={{ mt: 3 }}> <Typography variant="h6" fontWeight={600} align="left" sx={{ mt: 3 }}>
@@ -146,8 +233,64 @@ export default function ProgrammingSelector() {
return null; return null;
}; };
const renderLibraryChoices = () => {
if (isUndefined(selectedServer)) {
return;
}
switch (selectedServer.type) {
case 'plex': {
return (
!isNil(plexLibraryChildren) &&
plexLibraryChildren.size > 0 &&
selectedPlexLibrary && (
<FormControl size="small" sx={{ minWidth: { sm: 200 } }}>
<InputLabel>Library</InputLabel>
<Select
label="Library"
value={selectedPlexLibrary.uuid}
onChange={(e) => onLibraryChange(e.target.value)}
>
{plexLibraryChildren.Directory.map((dir) => (
<MenuItem key={dir.key} value={dir.uuid}>
{dir.title}
</MenuItem>
))}
</Select>
</FormControl>
)
);
}
case 'jellyfin': {
return (
!isNil(jellyfinLibraries) &&
jellyfinLibraries.Items.length > 0 &&
selectedJellyfinLibrary && (
<FormControl size="small" sx={{ minWidth: { sm: 200 } }}>
<InputLabel>Library</InputLabel>
<Select
label="Library"
value={selectedJellyfinLibrary.Id}
onChange={(e) => onLibraryChange(e.target.value)}
>
{chain(jellyfinLibraries.Items)
.sortBy(sortJellyfinLibraries)
.map((lib) => (
<MenuItem key={lib.Id} value={lib.Id}>
{lib.Name}
</MenuItem>
))
.value()}
</Select>
</FormControl>
)
);
}
}
};
const hasAnySources = const hasAnySources =
(plexServers && plexServers.length > 0) || customShows.length > 0; (mediaSources && mediaSources.length > 0) || customShows.length > 0;
return ( return (
<Box> <Box>
@@ -168,15 +311,13 @@ export default function ProgrammingSelector() {
<Select <Select
label="Media Source" label="Media Source"
value={ value={
viewingCustomShows viewingCustomShows ? 'custom-shows' : selectedServer?.id ?? ''
? 'custom-shows'
: selectedServer?.name ?? ''
} }
onChange={(e) => onMediaSourceChange(e.target.value)} onChange={(e) => onMediaSourceChange(e.target.value)}
> >
{map(plexServers, (server) => ( {map(mediaSources, (server) => (
<MenuItem key={server.name} value={server.name}> <MenuItem key={server.id} value={server.id}>
Plex: {server.name} {capitalize(server.type)}: {server.name}
</MenuItem> </MenuItem>
))} ))}
{customShows.length > 0 && ( {customShows.length > 0 && (
@@ -186,24 +327,7 @@ export default function ProgrammingSelector() {
</FormControl> </FormControl>
)} )}
{!isNil(plexLibraryChildren) && {renderLibraryChoices()}
plexLibraryChildren.size > 0 &&
selectedPlexLibrary && (
<FormControl size="small" sx={{ minWidth: { sm: 200 } }}>
<InputLabel>Library</InputLabel>
<Select
label="Library"
value={selectedPlexLibrary.uuid}
onChange={(e) => onLibraryChange(e.target.value)}
>
{plexLibraryChildren.Directory.map((dir) => (
<MenuItem key={dir.key} value={dir.uuid}>
{dir.title}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Stack> </Stack>
{renderMediaSourcePrograms()} {renderMediaSourcePrograms()}
</Box> </Box>

View File

@@ -6,13 +6,14 @@ import { useSnackbar } from 'notistack';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import useStore from '../../store/index.ts'; import useStore from '../../store/index.ts';
import { import {
addKnownMediaForServer, addKnownMediaForPlexServer,
addPlexSelectedMedia, addPlexSelectedMedia,
clearSelectedMedia, clearSelectedMedia,
} from '../../store/programmingSelector/actions.ts'; } from '../../store/programmingSelector/actions.ts';
import { AddedMedia } from '../../types/index.ts'; import { AddedMedia } from '../../types/index.ts';
import { RotatingLoopIcon } from '../base/LoadingIcon.tsx'; import { RotatingLoopIcon } from '../base/LoadingIcon.tsx';
import AddSelectedMediaButton from './AddSelectedMediaButton.tsx'; import AddSelectedMediaButton from './AddSelectedMediaButton.tsx';
import { useCurrentMediaSourceAndLibrary } from '@/store/programmingSelector/selectors.ts';
type Props = { type Props = {
onAddSelectedMedia: (media: AddedMedia[]) => void; onAddSelectedMedia: (media: AddedMedia[]) => void;
@@ -28,10 +29,8 @@ export default function SelectedProgrammingActions({
selectAllEnabled = true, selectAllEnabled = true,
toggleOrSetSelectedProgramsDrawer, // onSelectionModalClose, toggleOrSetSelectedProgramsDrawer, // onSelectionModalClose,
}: Props) { }: Props) {
const [selectedServer, selectedLibrary] = useStore((s) => [ const [selectedServer, selectedLibrary] =
s.currentServer, useCurrentMediaSourceAndLibrary('plex');
s.currentLibrary?.type === 'plex' ? s.currentLibrary : null,
]);
const { urlFilter: plexSearch } = useStore( const { urlFilter: plexSearch } = useStore(
({ plexSearch: plexQuery }) => plexQuery, ({ plexSearch: plexQuery }) => plexQuery,
@@ -64,8 +63,11 @@ export default function SelectedProgrammingActions({
setSelectAllLoading(true); setSelectAllLoading(true);
directPlexSearchFn() directPlexSearchFn()
.then((response) => { .then((response) => {
addKnownMediaForServer(selectedServer.name, response.Metadata ?? []); addKnownMediaForPlexServer(
addPlexSelectedMedia(selectedServer.name, response.Metadata); selectedServer.id,
response.Metadata ?? [],
);
addPlexSelectedMedia(selectedServer, response.Metadata);
}) })
.catch((e) => { .catch((e) => {
console.error('Error while attempting to select all Plex items', e); console.error('Error while attempting to select all Plex items', e);

Some files were not shown because too many files have changed in this diff Show More