mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
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:
committed by
GitHub
parent
69b14fc387
commit
f52df44ef0
7
CONTRIBUTING.md
Normal file
7
CONTRIBUTING.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Contributing to Tunarr
|
||||
|
||||
## Setting up the dev environment
|
||||
|
||||
## Coding Standard
|
||||
|
||||
##
|
||||
330
pnpm-lock.yaml
generated
330
pnpm-lock.yaml
generated
@@ -100,14 +100,14 @@ importers:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
'@mikro-orm/better-sqlite':
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0(@mikro-orm/core@6.2.0)
|
||||
specifier: ^6.3.3
|
||||
version: 6.3.3(@mikro-orm/core@6.3.3)
|
||||
'@mikro-orm/core':
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0
|
||||
specifier: ^6.3.3
|
||||
version: 6.3.3
|
||||
'@mikro-orm/migrations':
|
||||
specifier: 6.2.0
|
||||
version: 6.2.0(@mikro-orm/core@6.2.0)(@types/node@20.11.1)(better-sqlite3@9.4.5)
|
||||
specifier: 6.3.3
|
||||
version: 6.3.3(@mikro-orm/core@6.3.3)(@types/node@20.11.1)(better-sqlite3@9.4.5)
|
||||
'@tunarr/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
@@ -209,11 +209,11 @@ importers:
|
||||
version: 3.22.4
|
||||
devDependencies:
|
||||
'@mikro-orm/cli':
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0(better-sqlite3@9.4.5)
|
||||
specifier: ^6.3.3
|
||||
version: 6.3.3(better-sqlite3@9.4.5)
|
||||
'@mikro-orm/reflection':
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0(@mikro-orm/core@6.2.0)
|
||||
specifier: ^6.3.3
|
||||
version: 6.3.3(@mikro-orm/core@6.3.3)
|
||||
'@types/archiver':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
@@ -601,6 +601,9 @@ importers:
|
||||
vite:
|
||||
specifier: ^5.4.1
|
||||
version: 5.4.1(@types/node@20.11.1)
|
||||
vite-plugin-svgr:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(typescript@5.4.3)(vite@5.4.1)
|
||||
vitest:
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5(@types/node@20.11.1)
|
||||
@@ -2571,18 +2574,20 @@ packages:
|
||||
resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==}
|
||||
dev: true
|
||||
|
||||
/@mikro-orm/better-sqlite@6.2.0(@mikro-orm/core@6.2.0):
|
||||
resolution: {integrity: sha512-ixSZ9QRQJo408YSZFmnTMEm3jsmQRRDHEcq6lnz2YwYC/RJh8t5XObhHP4dvV7vdP9l9Y9M+AJ2UDZrRoRz24g==}
|
||||
/@mikro-orm/better-sqlite@6.3.3(@mikro-orm/core@6.3.3):
|
||||
resolution: {integrity: sha512-W0wyijGR8o9DvT+vVOOVH67OA94d8PT7goA4JF3Ty7Pcw2RkWUFi6/8kbKiGEI3SWU64IA5VHdFsx21vNEyi5g==}
|
||||
engines: {node: '>= 18.12.0'}
|
||||
peerDependencies:
|
||||
'@mikro-orm/core': ^6.0.0
|
||||
dependencies:
|
||||
'@mikro-orm/core': 6.2.0
|
||||
'@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5)
|
||||
'@mikro-orm/core': 6.3.3
|
||||
'@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5)
|
||||
better-sqlite3: 9.4.5
|
||||
fs-extra: 11.2.0
|
||||
sqlstring-sqlite: 0.1.1
|
||||
transitivePeerDependencies:
|
||||
- libsql
|
||||
- mariadb
|
||||
- mysql
|
||||
- mysql2
|
||||
- pg
|
||||
@@ -2592,19 +2597,21 @@ packages:
|
||||
- tedious
|
||||
dev: false
|
||||
|
||||
/@mikro-orm/cli@6.2.0(better-sqlite3@9.4.5):
|
||||
resolution: {integrity: sha512-R3JwXOdCT0YOMjoPckxW45izIYhA/9+WvFNXlzA5p58lUzQ3eGgITaPPTuZkf0/j7t1uwGM5fq7UD2tpGorz8Q==}
|
||||
/@mikro-orm/cli@6.3.3(better-sqlite3@9.4.5):
|
||||
resolution: {integrity: sha512-TbGuPDBt1V98c1g1hwjg+FizqdaGQ0kCAn8CccfO0nfvVYs0lBaLtRgt3w5jDot7eli7bfQ+DxfZsXRFUnmMyA==}
|
||||
engines: {node: '>= 18.12.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
'@jercle/yargonaut': 1.1.5
|
||||
'@mikro-orm/core': 6.2.0
|
||||
'@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5)
|
||||
'@mikro-orm/core': 6.3.3
|
||||
'@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5)
|
||||
fs-extra: 11.2.0
|
||||
tsconfig-paths: 4.2.0
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
- better-sqlite3
|
||||
- libsql
|
||||
- mariadb
|
||||
- mysql
|
||||
- mysql2
|
||||
- pg
|
||||
@@ -2614,8 +2621,8 @@ packages:
|
||||
- tedious
|
||||
dev: true
|
||||
|
||||
/@mikro-orm/core@6.2.0:
|
||||
resolution: {integrity: sha512-Kqsb3S+ab1dbo2joVFLQtdCPTyqSXqiRUnVoMOgAV5uS35nC15SUwDpQMivjLi+8Auz+vcjlbJNK5PrsV+lROQ==}
|
||||
/@mikro-orm/core@6.3.3:
|
||||
resolution: {integrity: sha512-P4kqaRIKDmgmtfn1RfUYPCdjkWRsHKEo41gi5G81Ia5Jb7toQ6O3T6GOeBckGt/rZfUNMGDPMeyYqAR93NVV5w==}
|
||||
engines: {node: '>= 18.12.0'}
|
||||
dependencies:
|
||||
dataloader: 2.2.2
|
||||
@@ -2623,21 +2630,31 @@ packages:
|
||||
esprima: 4.0.1
|
||||
fs-extra: 11.2.0
|
||||
globby: 11.1.0
|
||||
mikro-orm: 6.2.0
|
||||
mikro-orm: 6.3.3
|
||||
reflect-metadata: 0.2.2
|
||||
|
||||
/@mikro-orm/knex@6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5):
|
||||
resolution: {integrity: sha512-jo/KKtIkwqCrfBmU0TLe4Y6kCQIbyio8PcvLtphBiMoLca6Utd8cEednsLYW5OdNyzTF3Vw/XHkm7T68MiT/cQ==}
|
||||
/@mikro-orm/knex@6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5):
|
||||
resolution: {integrity: sha512-eG3SW4GFQKA7SVj5VCkQQyxlQMC/ZUKHWhFwSDgbyF0sYTAV/78Ir0XX1m69nxK1zD+KJXA+4nf0g5HAoWQxUQ==}
|
||||
engines: {node: '>= 18.12.0'}
|
||||
peerDependencies:
|
||||
'@mikro-orm/core': ^6.0.0
|
||||
better-sqlite3: 9.4.5
|
||||
libsql: '*'
|
||||
mariadb: '*'
|
||||
peerDependenciesMeta:
|
||||
better-sqlite3:
|
||||
optional: true
|
||||
libsql:
|
||||
optional: true
|
||||
mariadb:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@mikro-orm/core': 6.2.0
|
||||
'@mikro-orm/core': 6.3.3
|
||||
better-sqlite3: 9.4.5
|
||||
fs-extra: 11.2.0
|
||||
knex: 3.1.0(better-sqlite3@9.4.5)
|
||||
sqlstring: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
- better-sqlite3
|
||||
- mysql
|
||||
- mysql2
|
||||
- pg
|
||||
@@ -2646,19 +2663,21 @@ packages:
|
||||
- supports-color
|
||||
- tedious
|
||||
|
||||
/@mikro-orm/migrations@6.2.0(@mikro-orm/core@6.2.0)(@types/node@20.11.1)(better-sqlite3@9.4.5):
|
||||
resolution: {integrity: sha512-9Nl46QdHBto0fWXdpdfF8rqqG416uc7csA+wi0H+qvY3m4cjiCWYQcsRcmI7FkMbpJq/USkmcDS5yUSU8Sic9Q==}
|
||||
/@mikro-orm/migrations@6.3.3(@mikro-orm/core@6.3.3)(@types/node@20.11.1)(better-sqlite3@9.4.5):
|
||||
resolution: {integrity: sha512-wdUG1+zrofNetlsxS+qJfM+hvDuuqblElt/BgZEkPWy41B/IxPRKU+hiSUqznjD69BrqNoLrj7sIdacZjFjIBg==}
|
||||
engines: {node: '>= 18.12.0'}
|
||||
peerDependencies:
|
||||
'@mikro-orm/core': ^6.0.0
|
||||
dependencies:
|
||||
'@mikro-orm/core': 6.2.0
|
||||
'@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5)
|
||||
'@mikro-orm/core': 6.3.3
|
||||
'@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5)
|
||||
fs-extra: 11.2.0
|
||||
umzug: 3.8.0(@types/node@20.11.1)
|
||||
umzug: 3.8.1(@types/node@20.11.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- better-sqlite3
|
||||
- libsql
|
||||
- mariadb
|
||||
- mysql
|
||||
- mysql2
|
||||
- pg
|
||||
@@ -2668,15 +2687,15 @@ packages:
|
||||
- tedious
|
||||
dev: false
|
||||
|
||||
/@mikro-orm/reflection@6.2.0(@mikro-orm/core@6.2.0):
|
||||
resolution: {integrity: sha512-KkepzpY/u/67wfR59990EQvvDNF2HoWeI3vusS7xEHfHLa/hzwjGcj5SG7MRhdYBvjG626FlsXaYFCIwBB9S+g==}
|
||||
/@mikro-orm/reflection@6.3.3(@mikro-orm/core@6.3.3):
|
||||
resolution: {integrity: sha512-tNwKb1EDco7Wb1BuVrUkGPRNnGNfeJPWe/y9aFmf/77qORPonzXTQQLcW4qzq0nLsoId5VXe4G9mmlhWbP3dEQ==}
|
||||
engines: {node: '>= 18.12.0'}
|
||||
peerDependencies:
|
||||
'@mikro-orm/core': ^6.0.0
|
||||
dependencies:
|
||||
'@mikro-orm/core': 6.2.0
|
||||
'@mikro-orm/core': 6.3.3
|
||||
globby: 11.1.0
|
||||
ts-morph: 22.0.0
|
||||
ts-morph: 23.0.0
|
||||
dev: true
|
||||
|
||||
/@mui/base@5.0.0-beta.23(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0):
|
||||
@@ -2983,6 +3002,20 @@ packages:
|
||||
resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==}
|
||||
dev: false
|
||||
|
||||
/@rollup/pluginutils@5.1.0:
|
||||
resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/estree': 1.0.5
|
||||
estree-walker: 2.0.2
|
||||
picomatch: 2.3.1
|
||||
dev: true
|
||||
|
||||
/@rollup/rollup-android-arm-eabi@4.20.0:
|
||||
resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==}
|
||||
cpu: [arm]
|
||||
@@ -3274,6 +3307,132 @@ packages:
|
||||
engines: {node: '>=14.16'}
|
||||
dev: true
|
||||
|
||||
/@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.24.7):
|
||||
resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.24.7
|
||||
dev: true
|
||||
|
||||
/@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.24.7):
|
||||
resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.24.7
|
||||
dev: true
|
||||
|
||||
/@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.24.7):
|
||||
resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.24.7
|
||||
dev: true
|
||||
|
||||
/@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.24.7):
|
||||
resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.24.7
|
||||
dev: true
|
||||
|
||||
/@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.24.7):
|
||||
resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.24.7
|
||||
dev: true
|
||||
|
||||
/@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.24.7):
|
||||
resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.24.7
|
||||
dev: true
|
||||
|
||||
/@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.24.7):
|
||||
resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.24.7
|
||||
dev: true
|
||||
|
||||
/@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.24.7):
|
||||
resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.24.7
|
||||
dev: true
|
||||
|
||||
/@svgr/babel-preset@8.1.0(@babel/core@7.24.7):
|
||||
resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.24.7
|
||||
'@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.24.7)
|
||||
'@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.24.7)
|
||||
'@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.24.7)
|
||||
'@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.24.7)
|
||||
'@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.24.7)
|
||||
'@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.24.7)
|
||||
'@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.24.7)
|
||||
'@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.24.7)
|
||||
dev: true
|
||||
|
||||
/@svgr/core@8.1.0(typescript@5.4.3):
|
||||
resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==}
|
||||
engines: {node: '>=14'}
|
||||
dependencies:
|
||||
'@babel/core': 7.24.7
|
||||
'@svgr/babel-preset': 8.1.0(@babel/core@7.24.7)
|
||||
camelcase: 6.3.0
|
||||
cosmiconfig: 8.3.6(typescript@5.4.3)
|
||||
snake-case: 3.0.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
dev: true
|
||||
|
||||
/@svgr/hast-util-to-babel-ast@8.0.0:
|
||||
resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==}
|
||||
engines: {node: '>=14'}
|
||||
dependencies:
|
||||
'@babel/types': 7.24.7
|
||||
entities: 4.5.0
|
||||
dev: true
|
||||
|
||||
/@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0):
|
||||
resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@svgr/core': '*'
|
||||
dependencies:
|
||||
'@babel/core': 7.24.7
|
||||
'@svgr/babel-preset': 8.1.0(@babel/core@7.24.7)
|
||||
'@svgr/core': 8.1.0(typescript@5.4.3)
|
||||
'@svgr/hast-util-to-babel-ast': 8.0.0
|
||||
svg-parser: 2.0.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@swc/core-darwin-arm64@1.3.96:
|
||||
resolution: {integrity: sha512-8hzgXYVd85hfPh6mJ9yrG26rhgzCmcLO0h1TIl8U31hwmTbfZLzRitFQ/kqMJNbIBCwmNH1RU2QcJnL3d7f69A==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3562,11 +3721,11 @@ packages:
|
||||
resolution: {integrity: sha512-vd2A2TnM5lbnWZnHi9B+L2gPtkSeOtJOAw358JqokIH1+v2J7vUAzFVPwB/wrye12RFOurffXu33plm4uQ+JBQ==}
|
||||
dev: false
|
||||
|
||||
/@ts-morph/common@0.23.0:
|
||||
resolution: {integrity: sha512-m7Lllj9n/S6sOkCkRftpM7L24uvmfXQFedlW/4hENcuJH1HHm9u5EgxZb9uVjQSCGrbBWBkOGgcTxNg36r6ywA==}
|
||||
/@ts-morph/common@0.24.0:
|
||||
resolution: {integrity: sha512-c1xMmNHWpNselmpIqursHeOHHBTIsJLbB+NuovbTTRCNiTLEr/U9dbJ8qy0jd/O2x5pc3seWuOUN5R2IoOTp8A==}
|
||||
dependencies:
|
||||
fast-glob: 3.3.2
|
||||
minimatch: 9.0.3
|
||||
minimatch: 9.0.5
|
||||
mkdirp: 3.0.1
|
||||
path-browserify: 1.0.1
|
||||
dev: true
|
||||
@@ -5434,6 +5593,11 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/camelcase@6.3.0:
|
||||
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/caniuse-lite@1.0.30001570:
|
||||
resolution: {integrity: sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw==}
|
||||
dev: true
|
||||
@@ -5864,6 +6028,22 @@ packages:
|
||||
yaml: 1.10.2
|
||||
dev: false
|
||||
|
||||
/cosmiconfig@8.3.6(typescript@5.4.3):
|
||||
resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
typescript: '>=4.9.5'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
dependencies:
|
||||
import-fresh: 3.3.0
|
||||
js-yaml: 4.1.0
|
||||
parse-json: 5.2.0
|
||||
path-type: 4.0.0
|
||||
typescript: 5.4.3
|
||||
dev: true
|
||||
|
||||
/cosmiconfig@9.0.0(typescript@5.4.3):
|
||||
resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -6338,6 +6518,13 @@ packages:
|
||||
engines: {node: '>=0.4', npm: '>=1.2'}
|
||||
dev: true
|
||||
|
||||
/dot-case@3.0.4:
|
||||
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
|
||||
dependencies:
|
||||
no-case: 3.0.4
|
||||
tslib: 2.6.2
|
||||
dev: true
|
||||
|
||||
/dot-prop@5.3.0:
|
||||
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6470,6 +6657,11 @@ packages:
|
||||
strip-ansi: 6.0.1
|
||||
dev: true
|
||||
|
||||
/entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
dev: true
|
||||
|
||||
/env-paths@2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -6889,6 +7081,10 @@ packages:
|
||||
engines: {node: '>=4.0'}
|
||||
dev: true
|
||||
|
||||
/estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
dev: true
|
||||
|
||||
/estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
dependencies:
|
||||
@@ -8920,6 +9116,12 @@ packages:
|
||||
steno: 4.0.2
|
||||
dev: false
|
||||
|
||||
/lower-case@2.0.2:
|
||||
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
|
||||
dependencies:
|
||||
tslib: 2.6.2
|
||||
dev: true
|
||||
|
||||
/lowercase-keys@1.0.0:
|
||||
resolution: {integrity: sha512-RPlX0+PHuvxVDZ7xX+EBVAp4RsVxP/TdDSN2mJYdiq1Lc4Hz7EUSjUI7RZrKKlmrIzVhf6Jo2stj7++gVarS0A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -9089,8 +9291,8 @@ packages:
|
||||
braces: 3.0.2
|
||||
picomatch: 2.3.1
|
||||
|
||||
/mikro-orm@6.2.0:
|
||||
resolution: {integrity: sha512-/cjVKrpjtIG1bUm5BvumO6DRo0lkH52C4Waw5qzUqKrg8wl/rigXKYLoLVBPPi7bB0XNwyQbxeh5bRcWJD+y4Q==}
|
||||
/mikro-orm@6.3.3:
|
||||
resolution: {integrity: sha512-Hpm/LdpU8c0jNSJNnp6hJ+zwB13Db/fDCni95SJvuR3H4tFtixTrvxPRtz8iIWdLHXt/7kVdbBZdOLKj249r5A==}
|
||||
engines: {node: '>= 18.12.0'}
|
||||
|
||||
/miller-rabin@4.0.1:
|
||||
@@ -9178,6 +9380,13 @@ packages:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.1
|
||||
|
||||
/minimatch@9.0.5:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
dependencies:
|
||||
brace-expansion: 2.0.1
|
||||
dev: true
|
||||
|
||||
/minimist-options@3.0.2:
|
||||
resolution: {integrity: sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -9342,6 +9551,13 @@ packages:
|
||||
- webpack-dev-server
|
||||
dev: true
|
||||
|
||||
/no-case@3.0.4:
|
||||
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
|
||||
dependencies:
|
||||
lower-case: 2.0.2
|
||||
tslib: 2.6.2
|
||||
dev: true
|
||||
|
||||
/node-abi@3.51.0:
|
||||
resolution: {integrity: sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -11149,6 +11365,13 @@ packages:
|
||||
is-fullwidth-code-point: 5.0.0
|
||||
dev: true
|
||||
|
||||
/snake-case@3.0.4:
|
||||
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
||||
dependencies:
|
||||
dot-case: 3.0.4
|
||||
tslib: 2.6.2
|
||||
dev: true
|
||||
|
||||
/sonic-boom@3.7.0:
|
||||
resolution: {integrity: sha512-IudtNvSqA/ObjN97tfgNmOKyDOs4dNcg4cUUsHDebqsgb8wGBBwb31LIgShNO8fye0dFI52X1+tFoKKI6Rq1Gg==}
|
||||
dependencies:
|
||||
@@ -11591,6 +11814,10 @@ packages:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
/svg-parser@2.0.4:
|
||||
resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==}
|
||||
dev: true
|
||||
|
||||
/syntax-error@1.4.0:
|
||||
resolution: {integrity: sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==}
|
||||
dependencies:
|
||||
@@ -11960,10 +12187,10 @@ packages:
|
||||
webpack: 5.91.0(esbuild@0.21.5)(webpack-cli@5.1.4)
|
||||
dev: true
|
||||
|
||||
/ts-morph@22.0.0:
|
||||
resolution: {integrity: sha512-M9MqFGZREyeb5fTl6gNHKZLqBQA0TjA1lea+CR48R8EBTDuWrNqW6ccC5QvjNR4s6wDumD3LTCjOFSp9iwlzaw==}
|
||||
/ts-morph@23.0.0:
|
||||
resolution: {integrity: sha512-FcvFx7a9E8TUe6T3ShihXJLiJOiqyafzFKUO4aqIHDUCIvADdGNShcbc2W5PMr3LerXRv7mafvFZ9lRENxJmug==}
|
||||
dependencies:
|
||||
'@ts-morph/common': 0.23.0
|
||||
'@ts-morph/common': 0.24.0
|
||||
code-block-writer: 13.0.1
|
||||
dev: true
|
||||
|
||||
@@ -12338,8 +12565,8 @@ packages:
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/umzug@3.8.0(@types/node@20.11.1):
|
||||
resolution: {integrity: sha512-FRBvdZxllW3eUzsqG3CIfgOVChUONrKNZozNOJfvmcfBn5pMKcJjICuMMEsDLHYa/aqd7a2NtXfYEG86XHe1lQ==}
|
||||
/umzug@3.8.1(@types/node@20.11.1):
|
||||
resolution: {integrity: sha512-k0HjOc3b/s8vH24BUTvnaFiKhfWI9UQAGpqHDG+3866CGlBTB83Xs5wZ1io1mwYLj/GHvQ34AxKhbpYnWtkRJg==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
'@rushstack/ts-command-line': 4.19.1(@types/node@20.11.1)
|
||||
@@ -12533,6 +12760,21 @@ packages:
|
||||
- terser
|
||||
dev: true
|
||||
|
||||
/vite-plugin-svgr@4.2.0(typescript@5.4.3)(vite@5.4.1):
|
||||
resolution: {integrity: sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==}
|
||||
peerDependencies:
|
||||
vite: ^2.6.0 || 3 || 4 || 5
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.1.0
|
||||
'@svgr/core': 8.1.0(typescript@5.4.3)
|
||||
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0)
|
||||
vite: 5.4.1(@types/node@20.11.1)
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
- supports-color
|
||||
- typescript
|
||||
dev: true
|
||||
|
||||
/vite@5.0.11(@types/node@20.11.1):
|
||||
resolution: {integrity: sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { CustomShow } from './src/dao/entities/CustomShow.js';
|
||||
import { CustomShowContent } from './src/dao/entities/CustomShowContent.js';
|
||||
import { FillerListContent } from './src/dao/entities/FillerListContent.js';
|
||||
import { FillerShow } from './src/dao/entities/FillerShow.js';
|
||||
import { PlexServerSettings } from './src/dao/entities/PlexServerSettings.js';
|
||||
import { MediaSource } from './src/dao/entities/MediaSource.js';
|
||||
import { Program } from './src/dao/entities/Program.js';
|
||||
import { DATABASE_LOCATION_ENV_VAR } from './src/util/constants.js';
|
||||
import { Migration20240124115044 } from './src/migrations/Migration20240124115044.js';
|
||||
@@ -29,6 +29,8 @@ import { Migration20240603204620 } from './src/migrations/Migration2024060320462
|
||||
import { Migration20240603204638 } from './src/migrations/Migration20240603204638.js';
|
||||
import { Migration20240618005544 } from './src/migrations/Migration20240618005544.js';
|
||||
import { LoggerFactory } from './src/util/logging/LoggerFactory.js';
|
||||
import { Migration20240719145409 } from './src/migrations/Migration20240719145409.js';
|
||||
import { Migration20240805185042 } from './src/migrations/Migration20240805185042.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -49,7 +51,7 @@ export default defineConfig({
|
||||
CustomShowContent,
|
||||
FillerListContent,
|
||||
FillerShow,
|
||||
PlexServerSettings,
|
||||
MediaSource,
|
||||
Program,
|
||||
],
|
||||
flushMode: 'commit',
|
||||
@@ -127,6 +129,14 @@ export default defineConfig({
|
||||
name: 'Force regenerate program_external_id table',
|
||||
class: Migration20240618005544,
|
||||
},
|
||||
{
|
||||
name: 'rename_plex_server_settings_table',
|
||||
class: Migration20240719145409,
|
||||
},
|
||||
{
|
||||
name: 'add_jellyfin_sources',
|
||||
class: Migration20240805185042,
|
||||
},
|
||||
],
|
||||
},
|
||||
extensions: [Migrator],
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
"@fastify/swagger": "^8.12.1",
|
||||
"@fastify/swagger-ui": "^4.0.0",
|
||||
"@iptv/xmltv": "^1.0.1",
|
||||
"@mikro-orm/better-sqlite": "^6.2.0",
|
||||
"@mikro-orm/core": "^6.2.0",
|
||||
"@mikro-orm/migrations": "6.2.0",
|
||||
"@mikro-orm/better-sqlite": "^6.3.3",
|
||||
"@mikro-orm/core": "^6.3.3",
|
||||
"@mikro-orm/migrations": "6.3.3",
|
||||
"@tunarr/shared": "workspace:*",
|
||||
"@tunarr/types": "workspace:*",
|
||||
"archiver": "^7.0.1",
|
||||
@@ -74,8 +74,8 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mikro-orm/cli": "^6.2.0",
|
||||
"@mikro-orm/reflection": "^6.2.0",
|
||||
"@mikro-orm/cli": "^6.3.3",
|
||||
"@mikro-orm/reflection": "^6.3.3",
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/async-retry": "^1.4.8",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
|
||||
65
server/src/api/debug/debugJellyfinApi.ts
Normal file
65
server/src/api/debug/debugJellyfinApi.ts
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -4,28 +4,29 @@ import { ChannelLineupQuery } from '@tunarr/types/api';
|
||||
import { ChannelLineupSchema } from '@tunarr/types/schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { compact, first, isNil, isUndefined } from 'lodash-es';
|
||||
import { compact, isNil, reject, some, map } from 'lodash-es';
|
||||
import os from 'node:os';
|
||||
import z from 'zod';
|
||||
import { ArchiveDatabaseBackup } from '../dao/backup/ArchiveDatabaseBackup.js';
|
||||
import { getEm } from '../dao/dataSource.js';
|
||||
import {
|
||||
StreamLineupItem,
|
||||
isContentBackedLineupIteam,
|
||||
} from '../dao/derived_types/StreamLineup.js';
|
||||
import { StreamLineupItem } from '../dao/derived_types/StreamLineup.js';
|
||||
import { Channel } from '../dao/entities/Channel.js';
|
||||
import { LineupCreator } from '../services/dynamic_channels/LineupCreator.js';
|
||||
import { PlayerContext } from '../stream/Player.js';
|
||||
import { generateChannelContext } from '../stream/StreamProgramCalculator.js';
|
||||
import { PlexPlayer } from '../stream/plex/PlexPlayer.js';
|
||||
import { PlexTranscoder } from '../stream/plex/PlexTranscoder.js';
|
||||
import { StreamContextChannel } from '../stream/types.js';
|
||||
import { SavePlexProgramExternalIdsTask } from '../tasks/SavePlexProgramExternalIdsTask.js';
|
||||
import { SavePlexProgramExternalIdsTask } from '../tasks/plex/SavePlexProgramExternalIdsTask.js';
|
||||
import { PlexTaskQueue } from '../tasks/TaskQueue.js';
|
||||
import { RouterPluginAsyncCallback } from '../types/serverType.js';
|
||||
import { Maybe } from '../types/util.js';
|
||||
import { ifDefined, mapAsyncSeq } from '../util/index.js';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory.js';
|
||||
import { DebugJellyfinApiRouter } from './debug/debugJellyfinApi.js';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
|
||||
import { directDbAccess } from '../dao/direct/directDbAccess.js';
|
||||
import { MediaSourceType } from '../dao/entities/MediaSource.js';
|
||||
import { enumValues } from '../util/enumUtil.js';
|
||||
|
||||
const ChannelQuerySchema = {
|
||||
querystring: z.object({
|
||||
@@ -33,10 +34,13 @@ const ChannelQuerySchema = {
|
||||
}),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
const logger = LoggerFactory.child({ caller: import.meta });
|
||||
|
||||
await fastify.register(DebugJellyfinApiRouter, {
|
||||
prefix: '/debug',
|
||||
});
|
||||
|
||||
fastify.get(
|
||||
'/debug/plex',
|
||||
{ schema: ChannelQuerySchema },
|
||||
@@ -86,67 +90,6 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/debug/plex-transcoder/video-stats',
|
||||
{ schema: ChannelQuerySchema },
|
||||
async (req, res) => {
|
||||
const channel = await req.serverCtx.channelDB.getChannelAndProgramsSLOW(
|
||||
req.query.channelId,
|
||||
);
|
||||
|
||||
if (!channel) {
|
||||
return res.status(404).send('No channel found');
|
||||
}
|
||||
|
||||
const lineupItem = await getLineupItemForDebug(
|
||||
req,
|
||||
channel,
|
||||
new Date().getTime(),
|
||||
);
|
||||
|
||||
if (isUndefined(lineupItem)) {
|
||||
return res
|
||||
.status(500)
|
||||
.send('Couldnt get a lineup item for this channel');
|
||||
}
|
||||
|
||||
if (!isContentBackedLineupIteam(lineupItem)) {
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
`Needed lineup item of type commercial or program, but got "${lineupItem.type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO use plex server from item.
|
||||
const plexServer = await req.serverCtx.plexServerDB.getAll().then(first);
|
||||
|
||||
if (isNil(plexServer)) {
|
||||
return res.status(404).send('Could not find plex server');
|
||||
}
|
||||
|
||||
const plexSettings = req.serverCtx.settings.plexSettings();
|
||||
|
||||
const combinedChannel: StreamContextChannel = {
|
||||
...generateChannelContext(channel),
|
||||
transcoding: channel?.transcoding,
|
||||
};
|
||||
|
||||
const transcoder = new PlexTranscoder(
|
||||
`debug-${new Date().getTime()}`,
|
||||
plexServer,
|
||||
plexSettings,
|
||||
combinedChannel,
|
||||
lineupItem,
|
||||
);
|
||||
|
||||
transcoder.setTranscodingArgs(false, true, false, false);
|
||||
await transcoder.getDecision(false);
|
||||
|
||||
return res.send(transcoder.getVideoStats());
|
||||
},
|
||||
);
|
||||
|
||||
async function getLineupItemForDebug(
|
||||
req: FastifyRequest,
|
||||
channel: Loaded<Channel, 'programs'>,
|
||||
@@ -403,17 +346,54 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
return res.send();
|
||||
});
|
||||
|
||||
fastify.get('/debug/db/test_direct_access', async (_req, res) => {
|
||||
// const result = await directDbAccess()
|
||||
// .selectFrom('channel_programs')
|
||||
// .where('channel_uuid', '=', '0ff3ec64-1022-4afd-9178-3f27f1121d47')
|
||||
// .innerJoin('program', 'channel_programs.program_uuid', 'program.uuid')
|
||||
// .leftJoin('program_grouping', join => {
|
||||
// join.onRef('')
|
||||
// })
|
||||
// .select(['program'])
|
||||
// .execute();
|
||||
// return res.send(result);
|
||||
return res.send();
|
||||
});
|
||||
fastify.get(
|
||||
'/debug/db/test_direct_access',
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (_req, res) => {
|
||||
const mediaSource = (await _req.serverCtx.mediaSourceDB.getById(
|
||||
_req.query.id,
|
||||
))!;
|
||||
|
||||
const knownProgramIds = await directDbAccess()
|
||||
.selectFrom('programExternalId as p1')
|
||||
.where(({ eb, and }) =>
|
||||
and([
|
||||
eb('p1.externalSourceId', '=', mediaSource.name),
|
||||
eb('p1.sourceType', '=', mediaSource.type),
|
||||
]),
|
||||
)
|
||||
.selectAll('p1')
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('programExternalId as p2')
|
||||
.whereRef('p2.programUuid', '=', 'p1.programUuid')
|
||||
.whereRef('p2.uuid', '!=', 'p1.uuid')
|
||||
.select([
|
||||
'p2.sourceType',
|
||||
'p2.externalSourceId',
|
||||
'p2.externalKey',
|
||||
]),
|
||||
).as('otherExternalIds'),
|
||||
)
|
||||
.groupBy('p1.uuid')
|
||||
.execute();
|
||||
|
||||
const mediaSourceTypes = map(enumValues(MediaSourceType), (typ) =>
|
||||
typ.toString(),
|
||||
);
|
||||
const danglingPrograms = reject(knownProgramIds, (program) => {
|
||||
some(program.otherExternalIds, (eid) =>
|
||||
mediaSourceTypes.includes(eid.sourceType),
|
||||
);
|
||||
});
|
||||
return res.send(danglingPrograms);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,28 +4,14 @@ import { isError, isNil } from 'lodash-es';
|
||||
import path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { z } from 'zod';
|
||||
import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js';
|
||||
import { Plex } from '../external/plex.js';
|
||||
import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js';
|
||||
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js';
|
||||
import { FFMPEGInfo } from '../ffmpeg/ffmpegInfo.js';
|
||||
import { serverOptions } from '../globals.js';
|
||||
import { GlobalScheduler } from '../services/scheduler.js';
|
||||
import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js';
|
||||
import { RouterPluginAsyncCallback } from '../types/serverType.js';
|
||||
import { fileExists } from '../util/fsUtil.js';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory.js';
|
||||
import { channelsApi } from './channelsApi.js';
|
||||
import { customShowsApiV2 } from './customShowsApi.js';
|
||||
import { debugApi } from './debugApi.js';
|
||||
import { fillerListsApi } from './fillerListsApi.js';
|
||||
import { metadataApiRouter } from './metadataApi.js';
|
||||
import { programmingApi } from './programmingApi.js';
|
||||
import { tasksApiRouter } from './tasksApi.js';
|
||||
import { ffmpegSettingsRouter } from './ffmpegSettingsApi.js';
|
||||
import { guideRouter } from './guideApi.js';
|
||||
import { hdhrSettingsRouter } from './hdhrSettingsApi.js';
|
||||
import { plexServersRouter } from './plexServersApi.js';
|
||||
import { plexSettingsRouter } from './plexSettingsApi.js';
|
||||
import { xmlTvSettingsRouter } from './xmltvSettingsApi.js';
|
||||
import {
|
||||
isEdgeBuild,
|
||||
isNonEmptyString,
|
||||
@@ -33,7 +19,22 @@ import {
|
||||
run,
|
||||
tunarrBuild,
|
||||
} from '../util/index.js';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory.js';
|
||||
import { channelsApi } from './channelsApi.js';
|
||||
import { customShowsApiV2 } from './customShowsApi.js';
|
||||
import { debugApi } from './debugApi.js';
|
||||
import { ffmpegSettingsRouter } from './ffmpegSettingsApi.js';
|
||||
import { fillerListsApi } from './fillerListsApi.js';
|
||||
import { guideRouter } from './guideApi.js';
|
||||
import { hdhrSettingsRouter } from './hdhrSettingsApi.js';
|
||||
import { jellyfinApiRouter } from './jellyfinApi.js';
|
||||
import { mediaSourceRouter } from './mediaSourceApi.js';
|
||||
import { metadataApiRouter } from './metadataApi.js';
|
||||
import { plexSettingsRouter } from './plexSettingsApi.js';
|
||||
import { programmingApi } from './programmingApi.js';
|
||||
import { systemSettingsRouter } from './systemSettingsApi.js';
|
||||
import { tasksApiRouter } from './tasksApi.js';
|
||||
import { xmlTvSettingsRouter } from './xmltvSettingsApi.js';
|
||||
import { getTunarrVersion } from '../util/version.js';
|
||||
|
||||
export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
@@ -56,13 +57,14 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
.register(programmingApi)
|
||||
.register(debugApi)
|
||||
.register(metadataApiRouter)
|
||||
.register(plexServersRouter)
|
||||
.register(mediaSourceRouter)
|
||||
.register(ffmpegSettingsRouter)
|
||||
.register(plexSettingsRouter)
|
||||
.register(xmlTvSettingsRouter)
|
||||
.register(hdhrSettingsRouter)
|
||||
.register(systemSettingsRouter)
|
||||
.register(guideRouter);
|
||||
.register(guideRouter)
|
||||
.register(jellyfinApiRouter);
|
||||
|
||||
fastify.get(
|
||||
'/version',
|
||||
@@ -214,21 +216,21 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
'/plex',
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({ name: z.string(), path: z.string() }),
|
||||
querystring: z.object({ id: z.string(), path: z.string() }),
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const server = await req.entityManager
|
||||
.repo(PlexServerSettings)
|
||||
.findOne({ name: req.query.name });
|
||||
.repo(MediaSource)
|
||||
.findOne({ uuid: req.query.id, type: MediaSourceType.Plex });
|
||||
if (isNil(server)) {
|
||||
return res
|
||||
.status(404)
|
||||
.send({ error: 'No server found with name: ' + req.query.name });
|
||||
.send({ error: 'No server found with id: ' + req.query.id });
|
||||
}
|
||||
|
||||
const plex = new Plex(server);
|
||||
return res.send(await plex.doGet(req.query.path));
|
||||
const plex = MediaSourceApiFactory().get(server);
|
||||
return res.send(await plex.doGetPath(req.query.path));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
149
server/src/api/jellyfinApi.ts
Normal file
149
server/src/api/jellyfinApi.ts
Normal 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();
|
||||
};
|
||||
@@ -1,40 +1,41 @@
|
||||
import {
|
||||
BaseErrorSchema,
|
||||
BasicIdParamSchema,
|
||||
InsertPlexServerRequestSchema,
|
||||
UpdatePlexServerRequestSchema,
|
||||
InsertMediaSourceRequestSchema,
|
||||
UpdateMediaSourceRequestSchema,
|
||||
} from '@tunarr/types/api';
|
||||
import { PlexServerSettingsSchema } from '@tunarr/types/schemas';
|
||||
import { MediaSourceSettingsSchema } from '@tunarr/types/schemas';
|
||||
import { isError, isNil, isObject } from 'lodash-es';
|
||||
import z from 'zod';
|
||||
import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js';
|
||||
import { Plex } from '../external/plex.js';
|
||||
import { PlexApiFactory } from '../external/PlexApiFactory.js';
|
||||
import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js';
|
||||
import { PlexApiClient } from '../external/plex/PlexApiClient.js';
|
||||
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js';
|
||||
import { GlobalScheduler } from '../services/scheduler.js';
|
||||
import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js';
|
||||
import { RouterPluginAsyncCallback } from '../types/serverType.js';
|
||||
import { firstDefined, wait } from '../util/index.js';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory.js';
|
||||
import { JellyfinApiClient } from '../external/jellyfin/JellyfinApiClient.js';
|
||||
|
||||
export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
fastify,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
) => {
|
||||
const logger = LoggerFactory.child({ caller: import.meta });
|
||||
|
||||
fastify.get(
|
||||
'/plex-servers',
|
||||
'/media-sources',
|
||||
{
|
||||
schema: {
|
||||
response: {
|
||||
200: z.array(PlexServerSettingsSchema),
|
||||
200: z.array(MediaSourceSettingsSchema),
|
||||
500: z.string(),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
try {
|
||||
const servers = await req.serverCtx.plexServerDB.getAll();
|
||||
const servers = await req.serverCtx.mediaSourceDB.getAll();
|
||||
const dtos = servers.map((server) => server.toDTO());
|
||||
return res.send(dtos);
|
||||
} catch (err) {
|
||||
@@ -45,7 +46,7 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/plex-servers/:id/status',
|
||||
'/media-sources/:id/status',
|
||||
{
|
||||
schema: {
|
||||
params: BasicIdParamSchema,
|
||||
@@ -60,27 +61,44 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
async (req, res) => {
|
||||
try {
|
||||
const server = await req.serverCtx.plexServerDB.getById(req.params.id);
|
||||
const server = await req.serverCtx.mediaSourceDB.getById(req.params.id);
|
||||
|
||||
if (isNil(server)) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const plex = new Plex(server);
|
||||
let healthyPromise: Promise<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([
|
||||
(async () => {
|
||||
return await plex.checkServerStatus();
|
||||
})(),
|
||||
new Promise<-1>((resolve) => {
|
||||
const status = await Promise.race([
|
||||
healthyPromise,
|
||||
new Promise<false>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(-1);
|
||||
resolve(false);
|
||||
}, 60000);
|
||||
}),
|
||||
]);
|
||||
|
||||
return res.send({
|
||||
healthy: s === 1,
|
||||
healthy: status,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
@@ -90,13 +108,15 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/plex-servers/foreignstatus',
|
||||
'/media-sources/foreignstatus',
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
name: z.string().optional(),
|
||||
accessToken: z.string(),
|
||||
uri: z.string(),
|
||||
type: z.union([z.literal('plex'), z.literal('jellyfin')]),
|
||||
username: z.string().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -108,36 +128,46 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
try {
|
||||
const plex = new Plex({
|
||||
...req.body,
|
||||
name: req.body.name ?? 'unknown',
|
||||
});
|
||||
let healthyPromise: Promise<boolean>;
|
||||
switch (req.body.type) {
|
||||
case 'plex': {
|
||||
const plex = new PlexApiClient({
|
||||
...req.body,
|
||||
name: req.body.name ?? 'unknown',
|
||||
});
|
||||
|
||||
const s: boolean = await Promise.race([
|
||||
(async () => {
|
||||
const res = await plex.checkServerStatus();
|
||||
return res === 1;
|
||||
})(),
|
||||
new Promise<false>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(false);
|
||||
}, 60000);
|
||||
}),
|
||||
]);
|
||||
healthyPromise = plex.checkServerStatus();
|
||||
break;
|
||||
}
|
||||
case 'jellyfin': {
|
||||
const jellyfin = new JellyfinApiClient({
|
||||
url: req.body.uri,
|
||||
name: req.body.name ?? 'unknown',
|
||||
apiKey: req.body.accessToken,
|
||||
});
|
||||
|
||||
return res.send({
|
||||
healthy: s,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('%O', err);
|
||||
return res.status(500).send();
|
||||
healthyPromise = jellyfin.ping();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const healthy = await Promise.race([
|
||||
healthyPromise,
|
||||
new Promise<false>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(false);
|
||||
}, 60000);
|
||||
}),
|
||||
]);
|
||||
|
||||
return res.send({
|
||||
healthy,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete(
|
||||
'/plex-servers/:id',
|
||||
'/media-sources/:id',
|
||||
{
|
||||
schema: {
|
||||
params: BasicIdParamSchema,
|
||||
@@ -149,15 +179,14 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { deletedServer } = await req.serverCtx.plexServerDB.deleteServer(
|
||||
req.params.id,
|
||||
);
|
||||
const { deletedServer } =
|
||||
await req.serverCtx.mediaSourceDB.deleteMediaSource(req.params.id);
|
||||
|
||||
// Are these useful? What do they even do?
|
||||
req.serverCtx.eventService.push({
|
||||
type: 'settings-update',
|
||||
message: `Plex server ${deletedServer.name} removed.`,
|
||||
module: 'plex-server',
|
||||
message: `Media source ${deletedServer.name} removed.`,
|
||||
module: 'media-source',
|
||||
detail: {
|
||||
serverId: req.params.id,
|
||||
serverName: deletedServer.name,
|
||||
@@ -180,8 +209,8 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
logger.error('Error %O', err);
|
||||
req.serverCtx.eventService.push({
|
||||
type: 'settings-update',
|
||||
message: 'Error deleting plex server.',
|
||||
module: 'plex-server',
|
||||
message: 'Error deleting media-source.',
|
||||
module: 'media-source',
|
||||
detail: {
|
||||
action: 'delete',
|
||||
serverId: req.params.id,
|
||||
@@ -196,11 +225,11 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
'/plex-servers/:id',
|
||||
'/media-sources/:id',
|
||||
{
|
||||
schema: {
|
||||
params: BasicIdParamSchema,
|
||||
body: UpdatePlexServerRequestSchema,
|
||||
body: UpdateMediaSourceRequestSchema,
|
||||
response: {
|
||||
200: z.void(),
|
||||
500: z.void(),
|
||||
@@ -209,7 +238,9 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
async (req, res) => {
|
||||
try {
|
||||
const report = await req.serverCtx.plexServerDB.updateServer(req.body);
|
||||
const report = await req.serverCtx.mediaSourceDB.updateMediaSource(
|
||||
req.body,
|
||||
);
|
||||
let modifiedPrograms = 0;
|
||||
let destroyedPrograms = 0;
|
||||
report.forEach((r) => {
|
||||
@@ -220,8 +251,8 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
});
|
||||
req.serverCtx.eventService.push({
|
||||
type: 'settings-update',
|
||||
message: `Plex server ${req.body.name} updated. ${modifiedPrograms} programs modified, ${destroyedPrograms} programs deleted`,
|
||||
module: 'plex-server',
|
||||
message: `Media source ${req.body.name} updated. ${modifiedPrograms} programs modified, ${destroyedPrograms} programs deleted`,
|
||||
module: 'media-source',
|
||||
detail: {
|
||||
serverName: req.body.name,
|
||||
action: 'update',
|
||||
@@ -231,11 +262,11 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
|
||||
return res.status(200).send();
|
||||
} catch (err) {
|
||||
logger.error('Could not update plex server. ', err);
|
||||
logger.error(err, 'Could not update plex server. ');
|
||||
req.serverCtx.eventService.push({
|
||||
type: 'settings-update',
|
||||
message: 'Error updating plex server.',
|
||||
module: 'plex-server',
|
||||
message: 'Error updating media source.',
|
||||
module: 'media-source',
|
||||
detail: {
|
||||
action: 'update',
|
||||
serverName: firstDefined(req, 'body', 'name'),
|
||||
@@ -249,10 +280,10 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/plex-servers',
|
||||
'/media-sources',
|
||||
{
|
||||
schema: {
|
||||
body: InsertPlexServerRequestSchema,
|
||||
body: InsertMediaSourceRequestSchema,
|
||||
response: {
|
||||
201: z.object({
|
||||
id: z.string(),
|
||||
@@ -264,13 +295,13 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
async (req, res) => {
|
||||
try {
|
||||
const newServerId = await req.serverCtx.plexServerDB.addServer(
|
||||
const newServerId = await req.serverCtx.mediaSourceDB.addMediaSource(
|
||||
req.body,
|
||||
);
|
||||
req.serverCtx.eventService.push({
|
||||
type: 'settings-update',
|
||||
message: `Plex server ${req.body.name} added.`,
|
||||
module: 'plex-server',
|
||||
message: `Media source "${req.body.name}" added.`,
|
||||
module: 'media-source',
|
||||
detail: {
|
||||
serverId: newServerId,
|
||||
serverName: req.body.name,
|
||||
@@ -280,19 +311,19 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
});
|
||||
return res.status(201).send({ id: newServerId });
|
||||
} catch (err) {
|
||||
logger.error('Could not add plex server.', err);
|
||||
logger.error(err, 'Could not add media source');
|
||||
req.serverCtx.eventService.push({
|
||||
type: 'settings-update',
|
||||
message: 'Error adding plex server.',
|
||||
message: 'Error adding media source.',
|
||||
module: 'plex-server',
|
||||
detail: {
|
||||
action: 'add',
|
||||
serverName: firstDefined(req, 'body', 'name'),
|
||||
serverName: req.body.name,
|
||||
error: isError(err) ? firstDefined(err, 'message') : 'unknown',
|
||||
},
|
||||
level: 'error',
|
||||
});
|
||||
return res.status(400).send('Could not add plex server.');
|
||||
return res.status(500).send('Could not add media source.');
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -316,17 +347,17 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
|
||||
async (req, res) => {
|
||||
try {
|
||||
const server = await req.entityManager
|
||||
.repo(PlexServerSettings)
|
||||
.repo(MediaSource)
|
||||
.findOne({ name: req.query.serverName });
|
||||
|
||||
if (isNil(server)) {
|
||||
return res.status(404).send({ message: 'Plex server not found.' });
|
||||
}
|
||||
|
||||
const plex = PlexApiFactory().get(server);
|
||||
const plex = MediaSourceApiFactory().get(server);
|
||||
|
||||
const s = await Promise.race([
|
||||
plex.checkServerStatus().then((res) => res === 1),
|
||||
plex.checkServerStatus(),
|
||||
wait(15000).then(() => false),
|
||||
]);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ProgramSourceType,
|
||||
programSourceTypeFromString,
|
||||
} from '../dao/custom_types/ProgramSourceType';
|
||||
import { PlexApiFactory } from '../external/PlexApiFactory';
|
||||
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory';
|
||||
import { RouterPluginAsyncCallback } from '../types/serverType';
|
||||
|
||||
const externalIdSchema = z
|
||||
@@ -70,6 +70,11 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
switch (req.query.id.externalSourceType) {
|
||||
case ProgramSourceType.PLEX: {
|
||||
result = await handlePlexItem(req.query);
|
||||
break;
|
||||
}
|
||||
case ProgramSourceType.JELLYFIN: {
|
||||
result = await handleJellyfishItem(req.query);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +118,9 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
);
|
||||
|
||||
async function handlePlexItem(query: ExternalMetadataQuery) {
|
||||
const plexApi = await PlexApiFactory().getOrSet(query.id.externalSourceId);
|
||||
const plexApi = await MediaSourceApiFactory().getOrSet(
|
||||
query.id.externalSourceId,
|
||||
);
|
||||
|
||||
if (isNil(plexApi)) {
|
||||
return null;
|
||||
@@ -130,4 +137,26 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleJellyfishItem(query: ExternalMetadataQuery) {
|
||||
const jellyfinClient = await MediaSourceApiFactory().getJellyfinByName(
|
||||
query.id.externalSourceId,
|
||||
);
|
||||
|
||||
if (isNil(jellyfinClient)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.asset === 'thumb') {
|
||||
return jellyfinClient.getThumbUrl(query.id.externalItemId);
|
||||
// return jellyfinClient.getThumbUrl({
|
||||
// itemKey: query.id.externalItemId,
|
||||
// width: query.thumbOptions?.width,
|
||||
// height: query.thumbOptions?.height,
|
||||
// upscale: '1',
|
||||
// });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,16 +17,23 @@ import z from 'zod';
|
||||
import {
|
||||
ProgramSourceType,
|
||||
programSourceTypeFromString,
|
||||
programSourceTypeToMediaSource,
|
||||
} from '../dao/custom_types/ProgramSourceType.js';
|
||||
import { getEm } from '../dao/dataSource.js';
|
||||
import { Program, ProgramType } from '../dao/entities/Program.js';
|
||||
import { Plex } from '../external/plex.js';
|
||||
import { PlexApiClient } from '../external/plex/PlexApiClient.js';
|
||||
import { TruthyQueryParam } from '../types/schemas.js';
|
||||
import { RouterPluginAsyncCallback } from '../types/serverType.js';
|
||||
import { ProgramGrouping } from '../dao/entities/ProgramGrouping.js';
|
||||
import { ifDefined } from '../util/index.js';
|
||||
import { ifDefined, isNonEmptyString } from '../util/index.js';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory.js';
|
||||
import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js';
|
||||
import {
|
||||
ProgramExternalIdType,
|
||||
programExternalIdTypeFromSourceType,
|
||||
programExternalIdTypeToMediaSourceType,
|
||||
} from '../dao/custom_types/ProgramExternalIdType.js';
|
||||
import { MediaSource } from '../dao/entities/MediaSource.js';
|
||||
import { JellyfinApiClient } from '../external/jellyfin/JellyfinApiClient.js';
|
||||
|
||||
const LookupExternalProgrammingSchema = z.object({
|
||||
externalId: z
|
||||
@@ -94,30 +101,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const handlePlexItem = async (
|
||||
externalKey: string | undefined,
|
||||
externalSourceId: string,
|
||||
) => {
|
||||
if (isNil(externalKey)) {
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
const server =
|
||||
await req.serverCtx.plexServerDB.getByExternalid(externalSourceId);
|
||||
|
||||
if (isNil(server)) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const result = Plex.getThumbUrl({
|
||||
uri: server.uri,
|
||||
itemKey: externalKey,
|
||||
accessToken: server.accessToken,
|
||||
height: req.query.height,
|
||||
width: req.query.width,
|
||||
upscale: req.query.upscale.toString(),
|
||||
});
|
||||
|
||||
const handleResult = async (mediaSource: MediaSource, result: string) => {
|
||||
if (req.query.proxy) {
|
||||
try {
|
||||
const proxyRes = await axios.request<stream.Readable>({
|
||||
@@ -141,8 +125,9 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
} catch (e) {
|
||||
if (isAxiosError(e) && e.response?.status === 404) {
|
||||
logger.error(
|
||||
'Error retrieving thumb from Plex at url: %s. Status: 404',
|
||||
result.replaceAll(server.accessToken, 'REDACTED_TOKEN'),
|
||||
'Error retrieving thumb from %s at url: %s. Status: 404',
|
||||
mediaSource.type,
|
||||
result.replaceAll(mediaSource.accessToken, 'REDACTED_TOKEN'),
|
||||
);
|
||||
return res.status(404).send();
|
||||
}
|
||||
@@ -154,37 +139,118 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
};
|
||||
|
||||
if (!isNil(program)) {
|
||||
const mediaSource = await req.serverCtx.mediaSourceDB.getByExternalId(
|
||||
programSourceTypeToMediaSource(program.sourceType),
|
||||
program.externalSourceId,
|
||||
);
|
||||
|
||||
if (isNil(mediaSource)) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
let keyToUse = program.externalKey;
|
||||
if (program.type === ProgramType.Track && !isNil(program.album)) {
|
||||
ifDefined(
|
||||
find(
|
||||
program.album.$.externalRefs,
|
||||
(ref) =>
|
||||
ref.sourceType ===
|
||||
programExternalIdTypeFromSourceType(program.sourceType) &&
|
||||
ref.externalSourceId === program.externalSourceId,
|
||||
),
|
||||
(ref) => {
|
||||
keyToUse = ref.externalKey;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (isNil(keyToUse)) {
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
switch (program.sourceType) {
|
||||
case ProgramSourceType.PLEX: {
|
||||
let keyToUse = program.externalKey;
|
||||
if (program.type === ProgramType.Track && !isNil(program.album)) {
|
||||
ifDefined(
|
||||
find(
|
||||
program.album.$.externalRefs,
|
||||
(ref) =>
|
||||
ref.sourceType === ProgramExternalIdType.PLEX &&
|
||||
ref.externalSourceId === program.externalSourceId,
|
||||
),
|
||||
(ref) => {
|
||||
keyToUse = ref.externalKey;
|
||||
},
|
||||
);
|
||||
}
|
||||
return handlePlexItem(keyToUse, program.externalSourceId);
|
||||
return handleResult(
|
||||
mediaSource,
|
||||
PlexApiClient.getThumbUrl({
|
||||
uri: mediaSource.uri,
|
||||
itemKey: keyToUse,
|
||||
accessToken: mediaSource.accessToken,
|
||||
height: req.query.height,
|
||||
width: req.query.width,
|
||||
upscale: req.query.upscale.toString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
case ProgramSourceType.JELLYFIN:
|
||||
return handleResult(
|
||||
mediaSource,
|
||||
JellyfinApiClient.getThumbUrl({
|
||||
uri: mediaSource.uri,
|
||||
itemKey: keyToUse,
|
||||
accessToken: mediaSource.accessToken,
|
||||
height: req.query.height,
|
||||
width: req.query.width,
|
||||
upscale: req.query.upscale.toString(),
|
||||
}),
|
||||
);
|
||||
default:
|
||||
return res.status(405).send();
|
||||
}
|
||||
} else {
|
||||
// We can assume that we have a grouping here...
|
||||
// We only support Plex now
|
||||
const source = find(grouping!.externalRefs, {
|
||||
sourceType: ProgramExternalIdType.PLEX,
|
||||
});
|
||||
const source = find(
|
||||
grouping!.externalRefs,
|
||||
(ref) =>
|
||||
ref.sourceType === ProgramExternalIdType.PLEX ||
|
||||
ref.sourceType === ProgramExternalIdType.JELLYFIN,
|
||||
);
|
||||
|
||||
if (isNil(source)) {
|
||||
return res.status(500).send();
|
||||
} else if (!isNonEmptyString(source.externalSourceId)) {
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
const mediaSource = await req.serverCtx.mediaSourceDB.getByExternalId(
|
||||
programExternalIdTypeToMediaSourceType(source.sourceType)!,
|
||||
source.externalSourceId,
|
||||
);
|
||||
|
||||
if (isNil(mediaSource)) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
switch (source.sourceType) {
|
||||
case ProgramExternalIdType.PLEX:
|
||||
return handleResult(
|
||||
mediaSource,
|
||||
PlexApiClient.getThumbUrl({
|
||||
uri: mediaSource.uri,
|
||||
itemKey: source.externalSourceId,
|
||||
accessToken: mediaSource.accessToken,
|
||||
height: req.query.height,
|
||||
width: req.query.width,
|
||||
upscale: req.query.upscale.toString(),
|
||||
}),
|
||||
);
|
||||
case ProgramExternalIdType.JELLYFIN:
|
||||
return handleResult(
|
||||
mediaSource,
|
||||
JellyfinApiClient.getThumbUrl({
|
||||
uri: mediaSource.uri,
|
||||
itemKey: source.externalSourceId,
|
||||
accessToken: mediaSource.accessToken,
|
||||
height: req.query.height,
|
||||
width: req.query.width,
|
||||
upscale: req.query.upscale.toString(),
|
||||
}),
|
||||
);
|
||||
default:
|
||||
// Impossible
|
||||
return res.status(500).send();
|
||||
}
|
||||
return handlePlexItem(source.externalKey, source.externalSourceId!);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -206,21 +272,39 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
},
|
||||
async (req, res) => {
|
||||
const em = getEm();
|
||||
const program = await em.repo(Program).findOne({ uuid: req.params.id });
|
||||
const program = await em
|
||||
.repo(Program)
|
||||
.findOne({ uuid: req.params.id }, { populate: ['externalIds'] });
|
||||
if (isNil(program)) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const plexServers = await req.serverCtx.plexServerDB.getAll();
|
||||
const mediaSources = await req.serverCtx.mediaSourceDB.getAll();
|
||||
|
||||
switch (program.sourceType) {
|
||||
case ProgramSourceType.PLEX: {
|
||||
if (isNil(program.externalKey)) {
|
||||
return res.status(500).send();
|
||||
}
|
||||
const externalId = program.externalIds.$.find(
|
||||
(eid) =>
|
||||
eid.sourceType === ProgramExternalIdType.JELLYFIN ||
|
||||
eid.sourceType === ProgramExternalIdType.PLEX,
|
||||
);
|
||||
|
||||
const server = find(plexServers, { name: program.externalSourceId });
|
||||
if (isNil(server) || isNil(server.clientIdentifier)) {
|
||||
if (!externalId) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const server = find(
|
||||
mediaSources,
|
||||
(source) =>
|
||||
source.uuid === externalId.externalSourceId ||
|
||||
source.name === externalId.externalSourceId,
|
||||
);
|
||||
|
||||
if (isNil(server)) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
switch (externalId.sourceType) {
|
||||
case ProgramExternalIdType.PLEX: {
|
||||
if (isNil(server.clientIdentifier)) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
@@ -234,6 +318,14 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
return res.send({ url });
|
||||
}
|
||||
|
||||
return res.redirect(302, url).send();
|
||||
}
|
||||
case ProgramExternalIdType.JELLYFIN: {
|
||||
const url = `${server.uri}/web/#/details?id=${externalId.externalKey}`;
|
||||
if (!req.query.forward) {
|
||||
return res.send({ url });
|
||||
}
|
||||
|
||||
return res.redirect(302, url).send();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -13,24 +13,31 @@ import {
|
||||
flatten,
|
||||
forEach,
|
||||
isEmpty,
|
||||
isUndefined,
|
||||
map,
|
||||
partition,
|
||||
} from 'lodash-es';
|
||||
import { PlexQueryResult } from '../external/plex.js';
|
||||
import { PlexApiFactory } from '../external/PlexApiFactory.js';
|
||||
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js';
|
||||
import { Maybe } from '../types/util.js';
|
||||
import { asyncPool } from '../util/asyncPool.js';
|
||||
import { groupByUniqAndMap, mapAsyncSeq } from '../util/index.js';
|
||||
import {
|
||||
groupByUniqAndMap,
|
||||
mapAsyncSeq,
|
||||
nullToUndefined,
|
||||
} from '../util/index.js';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory.js';
|
||||
import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js';
|
||||
import { getEm } from './dataSource.js';
|
||||
import { ProgramType } from './entities/Program.js';
|
||||
import {
|
||||
ProgramGrouping,
|
||||
programGroupingTypeForJellyfinType,
|
||||
programGroupingTypeForString,
|
||||
} from './entities/ProgramGrouping.js';
|
||||
import { ProgramGroupingExternalId } from './entities/ProgramGroupingExternalId.js';
|
||||
import { ProgramDB } from './programDB.js';
|
||||
import { QueryResult } from '../external/BaseApiClient.js';
|
||||
import { JellyfinItem, isJellyfinType } from '@tunarr/types/jellyfin';
|
||||
|
||||
export class ProgramGroupingCalculator {
|
||||
#logger = LoggerFactory.child({ className: ProgramGroupingCalculator.name });
|
||||
@@ -134,7 +141,7 @@ export class ProgramGroupingCalculator {
|
||||
);
|
||||
}
|
||||
|
||||
const plexApi = await PlexApiFactory().getOrSet(plexServerName);
|
||||
const plexApi = await MediaSourceApiFactory().getOrSet(plexServerName);
|
||||
|
||||
if (!plexApi) {
|
||||
return;
|
||||
@@ -245,6 +252,220 @@ export class ProgramGroupingCalculator {
|
||||
await em.flush();
|
||||
}
|
||||
|
||||
async createHierarchyForManyFromJellyfin(
|
||||
programType: ProgramType,
|
||||
jellyfinServerName: string,
|
||||
programIds: {
|
||||
programId: string;
|
||||
jellyfinItemId: string;
|
||||
parentKey: string;
|
||||
}[],
|
||||
parentKeys: string[],
|
||||
grandparentKey: string,
|
||||
) {
|
||||
if (
|
||||
programType !== ProgramType.Episode &&
|
||||
programType !== ProgramType.Track
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentKeyByProgramId = groupByUniqAndMap(
|
||||
programIds,
|
||||
'programId',
|
||||
({ parentKey }) => parentKey,
|
||||
);
|
||||
|
||||
const programs = await this.programDB.getProgramsByIds(
|
||||
map(programIds, 'programId'),
|
||||
);
|
||||
|
||||
const em = getEm();
|
||||
|
||||
const existingParents = flatten(
|
||||
await mapAsyncSeq(
|
||||
chunk([...parentKeys], 25),
|
||||
(chunk) => {
|
||||
const ors = map(
|
||||
chunk,
|
||||
(id) =>
|
||||
({
|
||||
sourceType: ProgramExternalIdType.JELLYFIN,
|
||||
externalKey: id,
|
||||
externalSourceId: jellyfinServerName,
|
||||
}) as const,
|
||||
);
|
||||
|
||||
return em.find(
|
||||
ProgramGrouping,
|
||||
{
|
||||
externalRefs: {
|
||||
$or: ors,
|
||||
},
|
||||
},
|
||||
{
|
||||
populate: ['externalRefs'],
|
||||
fields: ['uuid', 'type'],
|
||||
},
|
||||
);
|
||||
},
|
||||
{ parallelism: 2 },
|
||||
),
|
||||
);
|
||||
|
||||
const grandparent = await em.findOne(
|
||||
ProgramGrouping,
|
||||
{
|
||||
externalRefs: {
|
||||
sourceType: ProgramExternalIdType.JELLYFIN,
|
||||
externalKey: grandparentKey,
|
||||
externalSourceId: jellyfinServerName,
|
||||
},
|
||||
},
|
||||
{
|
||||
populate: [
|
||||
'externalRefs',
|
||||
'seasons:ref',
|
||||
'showEpisodes:ref',
|
||||
'albums:ref',
|
||||
'albumTracks:ref',
|
||||
],
|
||||
fields: ['uuid', 'type'],
|
||||
},
|
||||
);
|
||||
|
||||
if (isEmpty(programs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [validPrograms, invalidPrograms] = partition(
|
||||
programs,
|
||||
(p) => p.type === programType,
|
||||
);
|
||||
|
||||
if (isEmpty(validPrograms)) {
|
||||
return;
|
||||
} else if (invalidPrograms.length > 0) {
|
||||
this.#logger.debug(
|
||||
"Found %d programs that don't have the correct type: %O",
|
||||
invalidPrograms.length,
|
||||
invalidPrograms,
|
||||
);
|
||||
}
|
||||
|
||||
const jellyfinApi =
|
||||
await MediaSourceApiFactory().getJellyfinByName(jellyfinServerName);
|
||||
|
||||
if (!jellyfinApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeGrandparentAndRef = this.handleJellyfinGrouping(
|
||||
grandparent,
|
||||
await jellyfinApi.getItem(grandparentKey),
|
||||
jellyfinServerName,
|
||||
);
|
||||
let newOrUpdatedGrandparent: Maybe<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(
|
||||
existing: Loaded<
|
||||
ProgramGrouping,
|
||||
@@ -252,7 +473,7 @@ export class ProgramGroupingCalculator {
|
||||
'uuid' | 'type',
|
||||
never
|
||||
> | null,
|
||||
queryResult: PlexQueryResult<PlexMedia>,
|
||||
queryResult: QueryResult<PlexMedia>,
|
||||
plexServerName: string,
|
||||
): Maybe<[ProgramGrouping, ProgramGroupingExternalId]> {
|
||||
if (queryResult.type === 'error') {
|
||||
@@ -338,4 +559,94 @@ export class ProgramGroupingCalculator {
|
||||
|
||||
return [entity, ref] as const;
|
||||
}
|
||||
|
||||
private handleJellyfinGrouping(
|
||||
existing: Loaded<
|
||||
ProgramGrouping,
|
||||
'externalRefs',
|
||||
'uuid' | 'type',
|
||||
never
|
||||
> | null,
|
||||
queryResult: QueryResult<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ExternalIdType } from '@tunarr/types/schemas';
|
||||
import { enumKeys } from '../../util/enumUtil.js';
|
||||
import { ProgramSourceType } from './ProgramSourceType.js';
|
||||
import { MediaSourceType } from '../entities/MediaSource.js';
|
||||
|
||||
export enum ProgramExternalIdType {
|
||||
PLEX = 'plex',
|
||||
@@ -7,6 +9,7 @@ export enum ProgramExternalIdType {
|
||||
TMDB = 'tmdb',
|
||||
IMDB = 'imdb',
|
||||
TVDB = 'tvdb',
|
||||
JELLYFIN = 'jellyfin',
|
||||
}
|
||||
|
||||
export function programExternalIdTypeFromExternalIdType(
|
||||
@@ -15,6 +18,30 @@ export function programExternalIdTypeFromExternalIdType(
|
||||
return programExternalIdTypeFromString(str)!;
|
||||
}
|
||||
|
||||
export function programExternalIdTypeFromSourceType(
|
||||
src: ProgramSourceType,
|
||||
): ProgramExternalIdType {
|
||||
switch (src) {
|
||||
case ProgramSourceType.PLEX:
|
||||
return ProgramExternalIdType.PLEX;
|
||||
case ProgramSourceType.JELLYFIN:
|
||||
return ProgramExternalIdType.JELLYFIN;
|
||||
}
|
||||
}
|
||||
|
||||
export function programExternalIdTypeToMediaSourceType(
|
||||
src: ProgramExternalIdType,
|
||||
) {
|
||||
switch (src) {
|
||||
case ProgramExternalIdType.PLEX:
|
||||
return MediaSourceType.Plex;
|
||||
case ProgramExternalIdType.JELLYFIN:
|
||||
return MediaSourceType.Jellyfin;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function programExternalIdTypeFromString(
|
||||
str: string,
|
||||
): ProgramExternalIdType | undefined {
|
||||
@@ -26,3 +53,16 @@ export function programExternalIdTypeFromString(
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export function programExternalIdTypeFromJellyfinProvider(provider: string) {
|
||||
switch (provider.toLowerCase()) {
|
||||
case 'tmdb':
|
||||
return ProgramExternalIdType.TMDB;
|
||||
case 'imdb':
|
||||
return ProgramExternalIdType.IMDB;
|
||||
case 'tvdb':
|
||||
return ProgramExternalIdType.TVDB;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { enumKeys } from '../../util/enumUtil.js';
|
||||
import { MediaSourceType } from '../entities/MediaSource.js';
|
||||
|
||||
export enum ProgramSourceType {
|
||||
PLEX = 'plex',
|
||||
JELLYFIN = 'jellyfin',
|
||||
}
|
||||
|
||||
export function programSourceTypeFromString(
|
||||
@@ -15,3 +17,21 @@ export function programSourceTypeFromString(
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export function programSourceTypeToMediaSource(src: ProgramSourceType) {
|
||||
switch (src) {
|
||||
case ProgramSourceType.PLEX:
|
||||
return MediaSourceType.Plex;
|
||||
case ProgramSourceType.JELLYFIN:
|
||||
return MediaSourceType.Jellyfin;
|
||||
}
|
||||
}
|
||||
|
||||
export function programSourceTypeFromMediaSource(src: MediaSourceType) {
|
||||
switch (src) {
|
||||
case MediaSourceType.Plex:
|
||||
return ProgramSourceType.PLEX;
|
||||
case MediaSourceType.Jellyfin:
|
||||
return ProgramSourceType.JELLYFIN;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// active streaming session
|
||||
|
||||
import { z } from 'zod';
|
||||
import { MediaSourceType } from '../entities/MediaSource.js';
|
||||
|
||||
const baseStreamLineupItemSchema = z.object({
|
||||
originalTimestamp: z.number().nonnegative().optional(),
|
||||
@@ -63,6 +64,7 @@ const ProgramTypeEnum = z.enum(['movie', 'episode', 'track']);
|
||||
|
||||
const BaseContentBackedStreamLineupItemSchema =
|
||||
baseStreamLineupItemSchema.extend({
|
||||
// ID in the program DB table
|
||||
programId: z.string().uuid(),
|
||||
// These are taken from the Program DB entity
|
||||
plexFilePath: z.string().optional(),
|
||||
@@ -70,6 +72,7 @@ const BaseContentBackedStreamLineupItemSchema =
|
||||
filePath: z.string().optional(),
|
||||
externalKey: z.string(),
|
||||
programType: ProgramTypeEnum,
|
||||
externalSource: z.nativeEnum(MediaSourceType),
|
||||
});
|
||||
|
||||
const CommercialStreamLineupItemSchema =
|
||||
|
||||
@@ -153,7 +153,7 @@ const defaultProgramJoins: ProgramJoins = {
|
||||
|
||||
type ProgramFields = readonly `program.${keyof RawProgram}`[];
|
||||
|
||||
const AllProgramFields: ProgramFields = [
|
||||
export const AllProgramFields: ProgramFields = [
|
||||
'program.albumName',
|
||||
'program.albumUuid',
|
||||
'program.artistName',
|
||||
|
||||
60
server/src/dao/entities/MediaSource.ts
Normal file
60
server/src/dao/entities/MediaSource.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { CustomShow } from './CustomShow.js';
|
||||
import { FillerShow } from './FillerShow.js';
|
||||
import { ProgramExternalId } from './ProgramExternalId.js';
|
||||
import { ProgramGrouping } from './ProgramGrouping.js';
|
||||
import { JellyfinItemKind } from '@tunarr/types/jellyfin';
|
||||
|
||||
/**
|
||||
* Program represents a 'playable' entity. A movie, episode, or music track
|
||||
@@ -259,3 +260,17 @@ export function programTypeFromString(str: string): ProgramType | undefined {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export function programTypeFromJellyfinType(
|
||||
kind: JellyfinItemKind,
|
||||
): ProgramType | undefined {
|
||||
switch (kind) {
|
||||
case 'Audio':
|
||||
return ProgramType.Track;
|
||||
case 'Episode':
|
||||
return ProgramType.Episode;
|
||||
case 'Movie':
|
||||
return ProgramType.Movie;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isNonEmptyString } from '../../util/index.js';
|
||||
import { ProgramExternalIdType } from '../custom_types/ProgramExternalIdType.js';
|
||||
import { BaseEntity } from './BaseEntity.js';
|
||||
import { Program } from './Program.js';
|
||||
import { createExternalId } from '@tunarr/shared';
|
||||
|
||||
/**
|
||||
* References to external sources for a {@link Program}
|
||||
@@ -30,7 +31,7 @@ import { Program } from './Program.js';
|
||||
name: 'unique_program_multi_external_id',
|
||||
properties: ['program', 'sourceType', 'externalSourceId'],
|
||||
expression:
|
||||
'create unique index `unique_program_multiple_external_id` on `program_external_id` (`program_uuid`, `source_type`) WHERE `external_source_id` IS NOT NULL',
|
||||
'create unique index `unique_program_multiple_external_id` on `program_external_id` (`program_uuid`, `source_type`, `external_source_id`) WHERE `external_source_id` IS NOT NULL',
|
||||
})
|
||||
export class ProgramExternalId extends BaseEntity {
|
||||
@Enum(() => ProgramExternalIdType)
|
||||
@@ -88,9 +89,11 @@ export class ProgramExternalId extends BaseEntity {
|
||||
}
|
||||
|
||||
toExternalIdString(): string {
|
||||
return `${this.sourceType.toString()}|${this.externalSourceId ?? ''}|${
|
||||
this.externalKey
|
||||
}`;
|
||||
return createExternalId(
|
||||
this.sourceType,
|
||||
this.externalSourceId ?? '',
|
||||
this.externalKey,
|
||||
);
|
||||
}
|
||||
|
||||
toKnexInsertData() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ProgramGroupingExternalId } from './ProgramGroupingExternalId.js';
|
||||
import { Maybe } from '../../types/util.js';
|
||||
import { find } from 'lodash-es';
|
||||
import { enumKeys } from '../../util/enumUtil.js';
|
||||
import { JellyfinItemKind } from '@tunarr/types/jellyfin';
|
||||
|
||||
/**
|
||||
* A ProgramGrouping represents some logical collection of Programs.
|
||||
@@ -89,3 +90,20 @@ export function programGroupingTypeForString(
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export function programGroupingTypeForJellyfinType(
|
||||
t: JellyfinItemKind,
|
||||
): Maybe<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
sortBy,
|
||||
} from 'lodash-es';
|
||||
import path from 'path';
|
||||
import { PlexApiFactory } from '../../external/PlexApiFactory.js';
|
||||
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js';
|
||||
import { globalOptions } from '../../globals.js';
|
||||
import { serverContext } from '../../serverContext.js';
|
||||
import { GlobalScheduler } from '../../services/scheduler.js';
|
||||
@@ -41,7 +41,7 @@ import { attempt } from '../../util/index.js';
|
||||
import { LoggerFactory } from '../../util/logging/LoggerFactory.js';
|
||||
import { EntityManager, withDb } from '../dataSource.js';
|
||||
import { CachedImage } from '../entities/CachedImage.js';
|
||||
import { PlexServerSettings as PlexServerSettingsEntity } from '../entities/PlexServerSettings.js';
|
||||
import { MediaSource as PlexServerSettingsEntity } from '../entities/MediaSource.js';
|
||||
import { Settings, SettingsDB, defaultXmlTvSettings } from '../settings.js';
|
||||
import {
|
||||
LegacyChannelMigrator,
|
||||
@@ -320,9 +320,9 @@ export class LegacyDbMigrator {
|
||||
// will take care of that -- we may want to do it here if we want
|
||||
// to remove the fixer eventually, though.
|
||||
for (const entity of entities) {
|
||||
const plexApi = PlexApiFactory().get(entity);
|
||||
const status = await plexApi.checkServerStatus();
|
||||
if (status === 1) {
|
||||
const plexApi = MediaSourceApiFactory().get(entity);
|
||||
const healthy = await plexApi.checkServerStatus();
|
||||
if (healthy) {
|
||||
this.logger.debug(
|
||||
'Plex server name: %s url: %s healthy',
|
||||
entity.name,
|
||||
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
PlexTvShow,
|
||||
} from '@tunarr/types/plex';
|
||||
import { first, groupBy, isNil, isNull, isUndefined, keys } from 'lodash-es';
|
||||
import { Plex } from '../../external/plex';
|
||||
import { PlexApiFactory } from '../../external/PlexApiFactory';
|
||||
import { PlexApiClient } from '../../external/plex/PlexApiClient';
|
||||
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory';
|
||||
import { isNonEmptyString, wait } from '../../util';
|
||||
import { LoggerFactory } from '../../util/logging/LoggerFactory';
|
||||
import { ProgramSourceType } from '../custom_types/ProgramSourceType';
|
||||
import { getEm } from '../dataSource';
|
||||
import { PlexServerSettings } from '../entities/PlexServerSettings';
|
||||
import { MediaSource } from '../entities/MediaSource';
|
||||
import { Program, ProgramType } from '../entities/Program';
|
||||
import {
|
||||
ProgramGrouping,
|
||||
@@ -84,7 +84,7 @@ export class LegacyMetadataBackfiller {
|
||||
programs: Program[],
|
||||
) {
|
||||
const em = getEm();
|
||||
const server = await em.findOne(PlexServerSettings, { name: serverName });
|
||||
const server = await em.findOne(MediaSource, { name: serverName });
|
||||
if (isNil(server)) {
|
||||
this.logger.warn(
|
||||
'Could not find plex server details for server %s',
|
||||
@@ -163,14 +163,14 @@ export class LegacyMetadataBackfiller {
|
||||
}
|
||||
|
||||
// Otherwise, we need to go and find details...
|
||||
const plex = PlexApiFactory().get(server);
|
||||
const plex = MediaSourceApiFactory().get(server);
|
||||
|
||||
// This where the types have to diverge, because the Plex
|
||||
// API types differ.
|
||||
|
||||
if (type === ProgramType.Episode) {
|
||||
// Lookup the episode in Plex
|
||||
const plexResult = await plex.doGet<PlexEpisodeView>(
|
||||
const plexResult = await plex.doGetPath<PlexEpisodeView>(
|
||||
'/library/metadata/' + externalKey,
|
||||
);
|
||||
|
||||
@@ -197,10 +197,10 @@ export class LegacyMetadataBackfiller {
|
||||
// These will be used on subsequent iterations to identify matches
|
||||
// without hitting Plex.
|
||||
if (!isUndefined(show)) {
|
||||
const seasons = await plex.doGet<PlexSeasonView>(show.key);
|
||||
const seasons = await plex.doGetPath<PlexSeasonView>(show.key);
|
||||
if (!isUndefined(seasons?.Metadata)) {
|
||||
for (const season of seasons.Metadata) {
|
||||
const seasonEpisodes = await plex.doGet<PlexEpisodeView>(
|
||||
const seasonEpisodes = await plex.doGetPath<PlexEpisodeView>(
|
||||
season.key,
|
||||
);
|
||||
if (!isUndefined(seasonEpisodes?.Metadata)) {
|
||||
@@ -302,7 +302,7 @@ export class LegacyMetadataBackfiller {
|
||||
}
|
||||
} else {
|
||||
// Lookup the episode in Plex
|
||||
const plexResult = await plex.doGet<PlexMusicTrackView>(
|
||||
const plexResult = await plex.doGetPath<PlexMusicTrackView>(
|
||||
'/library/metadata/' + externalKey,
|
||||
);
|
||||
|
||||
@@ -329,10 +329,10 @@ export class LegacyMetadataBackfiller {
|
||||
// These will be used on subsequent iterations to identify matches
|
||||
// without hitting Plex.
|
||||
if (!isUndefined(artist)) {
|
||||
const albums = await plex.doGet<PlexMusicAlbumView>(artist.key);
|
||||
const albums = await plex.doGetPath<PlexMusicAlbumView>(artist.key);
|
||||
if (!isUndefined(albums?.Metadata)) {
|
||||
for (const album of albums.Metadata) {
|
||||
const albumTracks = await plex.doGet<PlexMusicTrackView>(
|
||||
const albumTracks = await plex.doGetPath<PlexMusicTrackView>(
|
||||
album.key,
|
||||
);
|
||||
if (!isUndefined(albumTracks?.Metadata)) {
|
||||
@@ -443,7 +443,7 @@ export class LegacyMetadataBackfiller {
|
||||
Metadata: InferredMetadataType[];
|
||||
},
|
||||
>(
|
||||
plex: Plex,
|
||||
plex: PlexApiClient,
|
||||
ratingKey: string,
|
||||
cb: (item: InferredMetadataType) => ProgramGrouping | undefined,
|
||||
) {
|
||||
@@ -485,8 +485,11 @@ export class LegacyMetadataBackfiller {
|
||||
InferredPlexType extends { Metadata: InferredMetadataType[] } = {
|
||||
Metadata: InferredMetadataType[];
|
||||
},
|
||||
>(plex: Plex, ratingKey: string): Promise<InferredMetadataType | undefined> {
|
||||
const plexResult = await plex.doGet<InferredPlexType>(
|
||||
>(
|
||||
plex: PlexApiClient,
|
||||
ratingKey: string,
|
||||
): Promise<InferredMetadataType | undefined> {
|
||||
const plexResult = await plex.doGetPath<InferredPlexType>(
|
||||
'/library/metadata/' + ratingKey,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import {
|
||||
InsertPlexServerRequest,
|
||||
UpdatePlexServerRequest,
|
||||
InsertMediaSourceRequest,
|
||||
UpdateMediaSourceRequest,
|
||||
} from '@tunarr/types/api';
|
||||
import ld, { isNil, isUndefined, keys, map, mapValues } from 'lodash-es';
|
||||
import { groupByUniq } from '../util/index.js';
|
||||
import { ChannelDB } from './channelDb.js';
|
||||
import { ProgramSourceType } from './custom_types/ProgramSourceType.js';
|
||||
import {
|
||||
ProgramSourceType,
|
||||
programSourceTypeFromMediaSource,
|
||||
} from './custom_types/ProgramSourceType.js';
|
||||
import { getEm } from './dataSource.js';
|
||||
import { PlexServerSettings as PlexServerSettingsEntity } from './entities/PlexServerSettings.js';
|
||||
import {
|
||||
MediaSource,
|
||||
MediaSourceType,
|
||||
mediaSourceTypeFromApi,
|
||||
} from './entities/MediaSource.js';
|
||||
import { Program } from './entities/Program.js';
|
||||
|
||||
//hmnn this is more of a "PlexServerService"...
|
||||
@@ -22,7 +29,8 @@ type Report = {
|
||||
destroyedPrograms: number;
|
||||
modifiedPrograms: number;
|
||||
};
|
||||
export class PlexServerDB {
|
||||
|
||||
export class MediaSourceDB {
|
||||
#channelDb: ChannelDB;
|
||||
|
||||
constructor(channelDb: ChannelDB) {
|
||||
@@ -31,25 +39,33 @@ export class PlexServerDB {
|
||||
|
||||
async getAll() {
|
||||
const em = getEm();
|
||||
return em.repo(PlexServerSettingsEntity).findAll();
|
||||
return em.repo(MediaSource).findAll();
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
return getEm().repo(PlexServerSettingsEntity).findOne({ uuid: id });
|
||||
return getEm().repo(MediaSource).findOne({ uuid: id });
|
||||
}
|
||||
|
||||
async getByExternalid(nameOrClientId: string) {
|
||||
async getByExternalId(sourceType: MediaSourceType, nameOrClientId: string) {
|
||||
return getEm()
|
||||
.repo(PlexServerSettingsEntity)
|
||||
.repo(MediaSource)
|
||||
.findOne({
|
||||
$or: [{ name: nameOrClientId }, { clientIdentifier: nameOrClientId }],
|
||||
$and: [
|
||||
{
|
||||
$or: [
|
||||
{ name: nameOrClientId },
|
||||
{ clientIdentifier: nameOrClientId },
|
||||
],
|
||||
},
|
||||
{ type: sourceType },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteServer(id: string, removePrograms: boolean = true) {
|
||||
async deleteMediaSource(id: string, removePrograms: boolean = true) {
|
||||
const deletedServer = await getEm().transactional(async (em) => {
|
||||
const ref = em.getReference(PlexServerSettingsEntity, id);
|
||||
const existing = await em.findOneOrFail(PlexServerSettingsEntity, ref, {
|
||||
const ref = em.getReference(MediaSource, id);
|
||||
const existing = await em.findOneOrFail(MediaSource, ref, {
|
||||
populate: ['uuid', 'name'],
|
||||
});
|
||||
em.remove(ref);
|
||||
@@ -60,39 +76,51 @@ export class PlexServerDB {
|
||||
if (!removePrograms) {
|
||||
reports = [];
|
||||
} else {
|
||||
reports = await this.fixupProgramReferences(deletedServer.name);
|
||||
reports = await this.fixupProgramReferences(
|
||||
deletedServer.name,
|
||||
programSourceTypeFromMediaSource(deletedServer.type),
|
||||
);
|
||||
}
|
||||
|
||||
return { deletedServer, reports };
|
||||
}
|
||||
|
||||
async updateServer(server: UpdatePlexServerRequest) {
|
||||
async updateMediaSource(server: UpdateMediaSourceRequest) {
|
||||
const em = getEm();
|
||||
const repo = em.repo(PlexServerSettingsEntity);
|
||||
const repo = em.repo(MediaSource);
|
||||
const id = server.id;
|
||||
|
||||
if (isNil(id)) {
|
||||
throw Error('Missing server id from request');
|
||||
}
|
||||
|
||||
const s = await repo.findOne(id);
|
||||
const s = await repo.findOne({ uuid: id });
|
||||
|
||||
if (isNil(s)) {
|
||||
throw Error("Server doesn't exist.");
|
||||
}
|
||||
|
||||
const sendGuideUpdates =
|
||||
server.type === 'plex' ? server.sendGuideUpdates ?? false : false;
|
||||
const sendChannelUpdates =
|
||||
server.type === 'plex' ? server.sendChannelUpdates ?? false : false;
|
||||
|
||||
em.assign(s, {
|
||||
name: server.name,
|
||||
uri: server.uri,
|
||||
accessToken: server.accessToken,
|
||||
sendGuideUpdates: server.sendGuideUpdates ?? false,
|
||||
sendChannelUpdates: server.sendChannelUpdates ?? false,
|
||||
sendGuideUpdates,
|
||||
sendChannelUpdates,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
this.normalizeServer(s);
|
||||
|
||||
const report = await this.fixupProgramReferences(id, s);
|
||||
const report = await this.fixupProgramReferences(
|
||||
id,
|
||||
programSourceTypeFromMediaSource(s.type),
|
||||
s,
|
||||
);
|
||||
|
||||
await repo.upsert(s);
|
||||
await em.flush();
|
||||
@@ -100,45 +128,77 @@ export class PlexServerDB {
|
||||
return report;
|
||||
}
|
||||
|
||||
async addServer(server: InsertPlexServerRequest): Promise<string> {
|
||||
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
|
||||
const em = getEm();
|
||||
const repo = em.repo(PlexServerSettingsEntity);
|
||||
const repo = em.repo(MediaSource);
|
||||
const name = isUndefined(server.name) ? 'plex' : server.name;
|
||||
// let i = 2;
|
||||
// const prefix = name;
|
||||
// let resultName = name;
|
||||
// while (this.doesNameExist(resultName)) {
|
||||
// resultName = `${prefix}${i}`;
|
||||
// i += 1;
|
||||
// }
|
||||
// name = resultName;
|
||||
|
||||
const sendGuideUpdates = server.sendGuideUpdates ?? false;
|
||||
const sendChannelUpdates = server.sendChannelUpdates ?? false;
|
||||
const sendGuideUpdates =
|
||||
server.type === 'plex' ? server.sendGuideUpdates ?? false : false;
|
||||
const sendChannelUpdates =
|
||||
server.type === 'plex' ? server.sendChannelUpdates ?? false : false;
|
||||
const index = await repo.count();
|
||||
|
||||
const newServer = em.create(PlexServerSettingsEntity, {
|
||||
const newServer = em.create(MediaSource, {
|
||||
...server,
|
||||
name,
|
||||
sendGuideUpdates,
|
||||
sendChannelUpdates,
|
||||
index,
|
||||
type: mediaSourceTypeFromApi(server.type),
|
||||
});
|
||||
|
||||
this.normalizeServer(newServer);
|
||||
|
||||
return await em.insert(PlexServerSettingsEntity, newServer);
|
||||
return await em.insert(MediaSource, newServer);
|
||||
}
|
||||
|
||||
// private async removeDanglingPrograms(mediaSource: MediaSource) {
|
||||
// const knownProgramIds = await directDbAccess()
|
||||
// .selectFrom('programExternalId as p1')
|
||||
// .where(({ eb, and }) =>
|
||||
// and([
|
||||
// eb('p1.externalSourceId', '=', mediaSource.name),
|
||||
// eb('p1.sourceType', '=', mediaSource.type),
|
||||
// ]),
|
||||
// )
|
||||
// .selectAll('p1')
|
||||
// .select((eb) =>
|
||||
// jsonArrayFrom(
|
||||
// eb
|
||||
// .selectFrom('programExternalId as p2')
|
||||
// .whereRef('p2.programUuid', '=', 'p1.programUuid')
|
||||
// .whereRef('p2.uuid', '!=', 'p1.uuid')
|
||||
// .select(['p2.sourceType', 'p2.externalSourceId', 'p2.externalKey']),
|
||||
// ).as('otherExternalIds'),
|
||||
// )
|
||||
// .groupBy('p1.uuid')
|
||||
// .execute();
|
||||
|
||||
// const mediaSourceTypes = map(enumValues(MediaSourceType), (typ) =>
|
||||
// typ.toString(),
|
||||
// );
|
||||
// const danglingPrograms = reject(knownProgramIds, (program) => {
|
||||
// some(program.otherExternalIds, (eid) =>
|
||||
// mediaSourceTypes.includes(eid.sourceType),
|
||||
// );
|
||||
// });
|
||||
// }
|
||||
|
||||
private async fixupProgramReferences(
|
||||
serverName: string,
|
||||
newServer?: PlexServerSettingsEntity,
|
||||
serverType: ProgramSourceType,
|
||||
newServer?: MediaSource,
|
||||
) {
|
||||
// TODO: We need to update this to:
|
||||
// 1. handle different source types
|
||||
// 2. use program_external_id table
|
||||
// 3. not delete programs if they still have another reference via
|
||||
// the external id table (program that exists on 2 servers)
|
||||
const em = getEm();
|
||||
const allPrograms = await em
|
||||
.repo(Program)
|
||||
.find(
|
||||
{ sourceType: ProgramSourceType.PLEX, externalSourceId: serverName },
|
||||
{ sourceType: serverType, externalSourceId: serverName },
|
||||
{ populate: ['fillerShows', 'channels', 'customShows'] },
|
||||
);
|
||||
|
||||
@@ -234,7 +294,7 @@ export class PlexServerDB {
|
||||
return [...channelReports, ...fillerReports, ...customShowReports];
|
||||
}
|
||||
|
||||
private fixupProgram(program: Program, newServer: PlexServerSettingsEntity) {
|
||||
private fixupProgram(program: Program, newServer: MediaSource) {
|
||||
let modified = false;
|
||||
const fixIcon = (icon: string | undefined) => {
|
||||
if (
|
||||
@@ -261,7 +321,7 @@ export class PlexServerDB {
|
||||
return modified;
|
||||
}
|
||||
|
||||
private normalizeServer(server: PlexServerSettingsEntity) {
|
||||
private normalizeServer(server: MediaSource) {
|
||||
while (server.uri.endsWith('/')) {
|
||||
server.uri = server.uri.slice(0, -1);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref } from '@mikro-orm/core';
|
||||
import { createExternalId } from '@tunarr/shared';
|
||||
import {
|
||||
ChannelProgram,
|
||||
ContentProgram,
|
||||
@@ -6,13 +7,15 @@ import {
|
||||
isContentProgram,
|
||||
isCustomProgram,
|
||||
} from '@tunarr/types';
|
||||
import { PlexEpisode, PlexMedia, PlexMusicTrack } from '@tunarr/types/plex';
|
||||
import { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex';
|
||||
import dayjs from 'dayjs';
|
||||
import ld, {
|
||||
compact,
|
||||
filter,
|
||||
find,
|
||||
forEach,
|
||||
isNil,
|
||||
isUndefined,
|
||||
map,
|
||||
partition,
|
||||
reduce,
|
||||
@@ -21,8 +24,8 @@ import ld, {
|
||||
import { performance } from 'perf_hooks';
|
||||
import { GlobalScheduler } from '../services/scheduler.js';
|
||||
import { ReconcileProgramDurationsTask } from '../tasks/ReconcileProgramDurationsTask.js';
|
||||
import { SavePlexProgramExternalIdsTask } from '../tasks/SavePlexProgramExternalIdsTask.js';
|
||||
import { PlexTaskQueue } from '../tasks/TaskQueue.js';
|
||||
import { SavePlexProgramExternalIdsTask } from '../tasks/plex/SavePlexProgramExternalIdsTask.js';
|
||||
import { JellyfinTaskQueue, PlexTaskQueue } from '../tasks/TaskQueue.js';
|
||||
import { SavePlexProgramGroupingsTask } from '../tasks/plex/SavePlexProgramGroupingsTask.js';
|
||||
import { ProgramMinterFactory } from '../util/ProgramMinter.js';
|
||||
import { groupByUniqFunc, isNonEmptyString } from '../util/index.js';
|
||||
@@ -30,10 +33,19 @@ import { LoggerFactory } from '../util/logging/LoggerFactory.js';
|
||||
import { time, timeNamedAsync } from '../util/perf.js';
|
||||
import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js';
|
||||
import { getEm } from './dataSource.js';
|
||||
import { Program, programTypeFromString } from './entities/Program.js';
|
||||
import {
|
||||
Program,
|
||||
programTypeFromJellyfinType,
|
||||
programTypeFromString,
|
||||
} from './entities/Program.js';
|
||||
import { ProgramExternalId } from './entities/ProgramExternalId.js';
|
||||
import { upsertProgramExternalIds_deprecated } from './programExternalIdHelpers.js';
|
||||
import { createExternalId } from '@tunarr/shared';
|
||||
import { ContentProgramOriginalProgram } from '@tunarr/types/schemas';
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import { JellyfinItem } from '@tunarr/types/jellyfin';
|
||||
import { SaveJellyfinProgramGroupingsTask } from '../tasks/jellyfin/SaveJellyfinProgramGroupingsTask.js';
|
||||
import { ProgramSourceType } from './custom_types/ProgramSourceType.js';
|
||||
import { SaveJellyfinProgramExternalIdsTask } from '../tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.js';
|
||||
|
||||
export async function upsertContentPrograms(
|
||||
programs: ChannelProgram[],
|
||||
@@ -58,7 +70,102 @@ export async function upsertContentPrograms(
|
||||
)
|
||||
.value();
|
||||
|
||||
// TODO handle custom shows
|
||||
// This code dedupes incoming programs using their external (IMDB, TMDB, etc) IDs.
|
||||
// Eventually, it could be used to save source-agnostic programs, but it's unclear
|
||||
// if that gives us benefit yet.
|
||||
// const pMap = reduce(
|
||||
// contentPrograms,
|
||||
// (acc, program) => {
|
||||
// const externalIds: {
|
||||
// type: ProgramExternalIdType;
|
||||
// id: string;
|
||||
// program: ContentProgram;
|
||||
// }[] = [];
|
||||
// switch (program.originalProgram!.sourceType) {
|
||||
// case 'plex': {
|
||||
// const x = ld
|
||||
// .chain(program.originalProgram!.program.Guid ?? [])
|
||||
// .map((guid) => parsePlexExternalGuid(guid.id))
|
||||
// .thru(removeErrors)
|
||||
// .map((eid) => ({
|
||||
// type: eid.sourceType,
|
||||
// id: eid.externalKey,
|
||||
// program,
|
||||
// }))
|
||||
// .value();
|
||||
// externalIds.push(...x);
|
||||
// break;
|
||||
// }
|
||||
// case 'jellyfin': {
|
||||
// const p = compact(
|
||||
// map(program.originalProgram!.program.ProviderIds, (value, key) => {
|
||||
// const typ = programExternalIdTypeFromString(key.toLowerCase());
|
||||
// return isNil(value) || isUndefined(typ)
|
||||
// ? null
|
||||
// : { type: typ, id: value, program };
|
||||
// }),
|
||||
// );
|
||||
// externalIds.push(...p);
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
|
||||
// forEach(externalIds, ({ type, id, program }) => {
|
||||
// if (!isValidSingleExternalIdType(type)) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const key = createGlobalExternalIdString(type, id);
|
||||
// const last = acc[key];
|
||||
// if (last) {
|
||||
// acc[key] = { type, id, programs: [...last.programs, program] };
|
||||
// } else {
|
||||
// acc[key] = { type, id, programs: [program] };
|
||||
// }
|
||||
// });
|
||||
|
||||
// return acc;
|
||||
// },
|
||||
// {} as Record<
|
||||
// `${string}|${string}`,
|
||||
// {
|
||||
// type: ProgramExternalIdType;
|
||||
// id: string;
|
||||
// programs: ContentProgram[];
|
||||
// }
|
||||
// >,
|
||||
// );
|
||||
|
||||
// const existingPrograms = flatten(
|
||||
// await mapAsyncSeq(chunk(values(pMap), 500), (items) => {
|
||||
// return directDbAccess()
|
||||
// .selectFrom('programExternalId')
|
||||
// .where(({ or, eb }) => {
|
||||
// const clauses = map(items, (item) =>
|
||||
// eb('programExternalId.sourceType', '=', item.type).and(
|
||||
// 'programExternalId.externalKey',
|
||||
// '=',
|
||||
// item.id,
|
||||
// ),
|
||||
// );
|
||||
// return or(clauses);
|
||||
// })
|
||||
// .selectAll('programExternalId')
|
||||
// .select((eb) =>
|
||||
// jsonArrayFrom(
|
||||
// eb
|
||||
// .selectFrom('program')
|
||||
// .whereRef('programExternalId.programUuid', '=', 'program.uuid')
|
||||
// .select(AllProgramFields),
|
||||
// ).as('program'),
|
||||
// )
|
||||
// .groupBy('programExternalId.programUuid')
|
||||
// .execute();
|
||||
// }),
|
||||
// );
|
||||
// console.log('results!!!!', existingPrograms);
|
||||
|
||||
// TODO: handle custom shows
|
||||
const programsToPersist = ld
|
||||
.chain(contentPrograms)
|
||||
.map((p) => {
|
||||
@@ -72,31 +179,6 @@ export async function upsertContentPrograms(
|
||||
})
|
||||
.value();
|
||||
|
||||
const plexRatingExternalIdToMedia = ld
|
||||
.chain(programsToPersist)
|
||||
.reduce(
|
||||
(acc, { apiProgram, externalIds }) => {
|
||||
const flattened = filter(externalIds, {
|
||||
sourceType: ProgramExternalIdType.PLEX,
|
||||
externalSourceId: apiProgram.externalSourceName!,
|
||||
});
|
||||
|
||||
return {
|
||||
...acc,
|
||||
...reduce(
|
||||
flattened,
|
||||
(acc2, eid) => ({
|
||||
...acc2,
|
||||
[eid.toExternalIdString()]: apiProgram.originalProgram!,
|
||||
}),
|
||||
{} as Record<string, PlexMedia>,
|
||||
),
|
||||
};
|
||||
},
|
||||
{} as Record<string, PlexMedia>,
|
||||
)
|
||||
.value();
|
||||
|
||||
const programInfoByUniqueId = groupByUniqFunc(
|
||||
programsToPersist,
|
||||
({ program }) => program.uniqueId(),
|
||||
@@ -125,9 +207,6 @@ export async function upsertContentPrograms(
|
||||
),
|
||||
);
|
||||
|
||||
// We're dealing specifically with Plex items right now. We want to treat
|
||||
// _at least_ the rating key / GUID as invariants in the program_external_id
|
||||
// table for each program.
|
||||
const programExternalIds = ld
|
||||
.chain(upsertedPrograms)
|
||||
.flatMap((program) => {
|
||||
@@ -139,19 +218,113 @@ export async function upsertContentPrograms(
|
||||
})
|
||||
.value();
|
||||
|
||||
const externalIdsByGrandparentId = ld
|
||||
const sourceExternalIdToOriginalProgram: Record<
|
||||
string,
|
||||
ContentProgramOriginalProgram
|
||||
> = ld
|
||||
.chain(programsToPersist)
|
||||
.reduce((acc, { apiProgram, externalIds }) => {
|
||||
if (isUndefined(apiProgram.originalProgram)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const itemId = find(
|
||||
externalIds,
|
||||
(eid) =>
|
||||
eid.sourceType === apiProgram.originalProgram!.sourceType &&
|
||||
eid.externalSourceId === apiProgram.externalSourceName!,
|
||||
);
|
||||
|
||||
if (!itemId) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[itemId.toExternalIdString()]: apiProgram.originalProgram,
|
||||
};
|
||||
}, {})
|
||||
.value();
|
||||
|
||||
schedulePlexProgramGroupingTasks(
|
||||
programExternalIds,
|
||||
sourceExternalIdToOriginalProgram,
|
||||
);
|
||||
|
||||
scheduleJellyfinProgramGroupingTasks(
|
||||
programExternalIds,
|
||||
sourceExternalIdToOriginalProgram,
|
||||
);
|
||||
|
||||
const [requiredExternalIds, backgroundExternalIds] = partition(
|
||||
programExternalIds,
|
||||
(p) =>
|
||||
p.sourceType === ProgramExternalIdType.PLEX ||
|
||||
p.sourceType === ProgramExternalIdType.PLEX_GUID ||
|
||||
p.sourceType === ProgramExternalIdType.JELLYFIN,
|
||||
);
|
||||
|
||||
// Fail hard on not saving Plex external IDs. We need them for streaming
|
||||
// TODO: We could optimize further here by only saving IDs necessary for streaming
|
||||
await timeNamedAsync('upsert external ids', logger, () =>
|
||||
upsertProgramExternalIds_deprecated(requiredExternalIds),
|
||||
);
|
||||
|
||||
setImmediate(() => {
|
||||
upsertProgramExternalIds_deprecated(backgroundExternalIds).catch((e) => {
|
||||
logger.error(
|
||||
e,
|
||||
'Error saving non-essential external IDs. A fixer will run for these',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await em.flush();
|
||||
|
||||
schedulePlexExternalIdsTask(upsertedPrograms);
|
||||
scheduleJellyfinExternalIdsTask(upsertedPrograms);
|
||||
|
||||
setImmediate(() => {
|
||||
GlobalScheduler.scheduleOneOffTask(
|
||||
ReconcileProgramDurationsTask.name,
|
||||
dayjs().add(500, 'ms'),
|
||||
new ReconcileProgramDurationsTask(),
|
||||
);
|
||||
PlexTaskQueue.resume();
|
||||
JellyfinTaskQueue.resume();
|
||||
});
|
||||
|
||||
const end = performance.now();
|
||||
logger.debug(
|
||||
'upsertContentPrograms to %d millis. %d upsertedPrograms',
|
||||
round(end - start, 3),
|
||||
upsertedPrograms.length,
|
||||
);
|
||||
|
||||
return upsertedPrograms;
|
||||
}
|
||||
|
||||
function schedulePlexProgramGroupingTasks(
|
||||
programExternalIds: ProgramExternalId[],
|
||||
sourceExternalIdToOriginalProgram: Record<
|
||||
string,
|
||||
ContentProgramOriginalProgram
|
||||
>,
|
||||
) {
|
||||
const plexExternalIdsByGrandparentId = ld
|
||||
.chain(programExternalIds)
|
||||
.map((externalId) => {
|
||||
const plexMedia =
|
||||
plexRatingExternalIdToMedia[externalId.toExternalIdString()];
|
||||
const media =
|
||||
sourceExternalIdToOriginalProgram[externalId.toExternalIdString()];
|
||||
|
||||
if (
|
||||
plexMedia &&
|
||||
(plexMedia.type === 'track' || plexMedia.type === 'episode')
|
||||
media &&
|
||||
media.sourceType === 'plex' &&
|
||||
(media.program.type === 'track' || media.program.type === 'episode')
|
||||
) {
|
||||
return {
|
||||
externalId,
|
||||
plexMedia,
|
||||
plexMedia: media.program,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,8 +353,9 @@ export async function upsertContentPrograms(
|
||||
)
|
||||
.value();
|
||||
|
||||
// TODO Need to implement this for Jellyfin
|
||||
setImmediate(() => {
|
||||
forEach(externalIdsByGrandparentId, (externalIds, grandparentId) => {
|
||||
forEach(plexExternalIdsByGrandparentId, (externalIds, grandparentId) => {
|
||||
const parentIds = map(
|
||||
externalIds,
|
||||
(eid) => eid.plexMedia.parentRatingKey,
|
||||
@@ -203,81 +377,162 @@ export async function upsertContentPrograms(
|
||||
).catch((e) => console.error(e));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const [requiredExternalIds, backgroundExternalIds] = partition(
|
||||
programExternalIds,
|
||||
(p) =>
|
||||
p.sourceType === ProgramExternalIdType.PLEX ||
|
||||
p.sourceType === ProgramExternalIdType.PLEX_GUID,
|
||||
);
|
||||
function scheduleJellyfinProgramGroupingTasks(
|
||||
programExternalIds: ProgramExternalId[],
|
||||
sourceExternalIdToOriginalProgram: Record<
|
||||
string,
|
||||
ContentProgramOriginalProgram
|
||||
>,
|
||||
) {
|
||||
const externalIdsByGrandparentId = ld
|
||||
.chain(programExternalIds)
|
||||
.map((externalId) => {
|
||||
const media =
|
||||
sourceExternalIdToOriginalProgram[externalId.toExternalIdString()];
|
||||
|
||||
// Fail hard on not saving Plex external IDs. We need them for streaming
|
||||
// TODO: We could optimize further here by only saving IDs necessary for streaming
|
||||
await timeNamedAsync('upsert external ids', logger, () =>
|
||||
upsertProgramExternalIds_deprecated(requiredExternalIds),
|
||||
);
|
||||
if (
|
||||
media &&
|
||||
media.sourceType === 'jellyfin' &&
|
||||
(media.program.Type === 'Audio' || media.program.Type === 'Episode')
|
||||
) {
|
||||
return {
|
||||
externalId,
|
||||
item: media.program,
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
})
|
||||
.compact()
|
||||
.reduce(
|
||||
(acc, { item, externalId }) => {
|
||||
const grandparentKey = item.SeriesId ?? item.AlbumArtist;
|
||||
if (isNonEmptyString(grandparentKey)) {
|
||||
const existing = acc[grandparentKey] ?? [];
|
||||
return {
|
||||
...acc,
|
||||
[grandparentKey]: [...existing, { externalId, item }],
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<
|
||||
string,
|
||||
{
|
||||
externalId: ProgramExternalId;
|
||||
item: JellyfinItem;
|
||||
}[]
|
||||
>,
|
||||
)
|
||||
.value();
|
||||
|
||||
console.log('SCHEDULING a bunchhhh of things: ', externalIdsByGrandparentId);
|
||||
|
||||
setImmediate(() => {
|
||||
upsertProgramExternalIds_deprecated(backgroundExternalIds).catch((e) => {
|
||||
logger.error(
|
||||
e,
|
||||
'Error saving non-essential external IDs. A fixer will run for these',
|
||||
forEach(externalIdsByGrandparentId, (externalIds, grandparentId) => {
|
||||
const parentIds = compact(
|
||||
map(externalIds, ({ item }) =>
|
||||
item.Type === 'Audio'
|
||||
? item.AlbumId
|
||||
: item.Type === 'Episode'
|
||||
? item.SeasonId
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
const programAndParentIds = seq.collect(externalIds, (eid) => {
|
||||
const parentKey = eid.item.AlbumId ?? eid.item.SeasonId;
|
||||
if (!isNonEmptyString(parentKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
jellyfinItemId: eid.item.Id,
|
||||
programId: eid.externalId.program.uuid,
|
||||
parentKey,
|
||||
};
|
||||
});
|
||||
|
||||
JellyfinTaskQueue.add(
|
||||
new SaveJellyfinProgramGroupingsTask({
|
||||
grandparentKey: grandparentId,
|
||||
parentKeys: compact(parentIds),
|
||||
programAndJellyfinIds: programAndParentIds,
|
||||
programType: programTypeFromJellyfinType(externalIds[0].item.Type)!,
|
||||
jellyfinServerName: externalIds[0].externalId.externalSourceId!,
|
||||
}),
|
||||
).catch((e) => console.error(e));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await em.flush();
|
||||
function schedulePlexExternalIdsTask(upsertedPrograms: Program[]) {
|
||||
const logger = LoggerFactory.root;
|
||||
|
||||
PlexTaskQueue.pause();
|
||||
const [, pQueueTime] = time(() => {
|
||||
forEach(upsertedPrograms, (program) => {
|
||||
try {
|
||||
const task = new SavePlexProgramExternalIdsTask(program.uuid);
|
||||
task.logLevel = 'trace';
|
||||
PlexTaskQueue.add(task).catch((e) => {
|
||||
logger.error(e, 'Error saving external IDs for program %s', program);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
e,
|
||||
'Failed to schedule external IDs task for persisted program: %O',
|
||||
program,
|
||||
);
|
||||
}
|
||||
});
|
||||
forEach(
|
||||
filter(upsertedPrograms, (p) => p.sourceType === ProgramSourceType.PLEX),
|
||||
(program) => {
|
||||
try {
|
||||
const task = new SavePlexProgramExternalIdsTask(program.uuid);
|
||||
task.logLevel = 'trace';
|
||||
PlexTaskQueue.add(task).catch((e) => {
|
||||
logger.error(
|
||||
e,
|
||||
'Error saving external IDs for program %s',
|
||||
program,
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
e,
|
||||
'Failed to schedule external IDs task for persisted program: %O',
|
||||
program,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
logger.debug('Took %d ms to schedule tasks', pQueueTime);
|
||||
|
||||
setImmediate(() => {
|
||||
GlobalScheduler.scheduleOneOffTask(
|
||||
ReconcileProgramDurationsTask.name,
|
||||
dayjs().add(500, 'ms'),
|
||||
new ReconcileProgramDurationsTask(),
|
||||
);
|
||||
PlexTaskQueue.resume();
|
||||
});
|
||||
|
||||
const end = performance.now();
|
||||
logger.debug(
|
||||
'upsertContentPrograms to %d millis. %d upsertedPrograms',
|
||||
round(end - start, 3),
|
||||
upsertedPrograms.length,
|
||||
);
|
||||
|
||||
return upsertedPrograms;
|
||||
}
|
||||
|
||||
// Creates a unique ID that matches the output of the entity Program#uniqueId
|
||||
// function. Useful to matching non-persisted API programs with persisted programs
|
||||
export function contentProgramUniqueId(p: ContentProgram) {
|
||||
// ID should always be defined in the persistent case
|
||||
if (p.persisted) {
|
||||
return p.id!;
|
||||
}
|
||||
function scheduleJellyfinExternalIdsTask(upsertedPrograms: Program[]) {
|
||||
const logger = LoggerFactory.root;
|
||||
|
||||
// These should always be defined for the non-persisted case
|
||||
return `${p.externalSourceType}|${p.externalSourceName}|${p.originalProgram?.key}`;
|
||||
JellyfinTaskQueue.pause();
|
||||
const [, pQueueTime] = time(() => {
|
||||
forEach(
|
||||
filter(
|
||||
upsertedPrograms,
|
||||
(p) => p.sourceType === ProgramSourceType.JELLYFIN,
|
||||
),
|
||||
(program) => {
|
||||
try {
|
||||
const task = new SaveJellyfinProgramExternalIdsTask(program.uuid);
|
||||
task.logLevel = 'trace';
|
||||
JellyfinTaskQueue.add(task).catch((e) => {
|
||||
logger.error(
|
||||
e,
|
||||
'Error saving external IDs for program %s',
|
||||
program,
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
e,
|
||||
'Failed to schedule external IDs task for persisted program: %O',
|
||||
program,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
logger.debug('Took %d ms to schedule tasks', pQueueTime);
|
||||
}
|
||||
|
||||
// Takes a listing of programs and makes a mapping of a unique identifier,
|
||||
|
||||
216
server/src/external/BaseApiClient.ts
vendored
Normal file
216
server/src/external/BaseApiClient.ts
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
144
server/src/external/MediaSourceApiFactory.ts
vendored
Normal file
144
server/src/external/MediaSourceApiFactory.ts
vendored
Normal 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;
|
||||
};
|
||||
79
server/src/external/PlexApiFactory.ts
vendored
79
server/src/external/PlexApiFactory.ts
vendored
@@ -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;
|
||||
};
|
||||
191
server/src/external/jellyfin/JellyfinApiClient.ts
vendored
Normal file
191
server/src/external/jellyfin/JellyfinApiClient.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
595
server/src/external/plex.ts
vendored
595
server/src/external/plex.ts
vendored
@@ -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[] };
|
||||
};
|
||||
310
server/src/external/plex/PlexApiClient.ts
vendored
Normal file
310
server/src/external/plex/PlexApiClient.ts
vendored
Normal 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[] };
|
||||
};
|
||||
56
server/src/external/plex/PlexQueryCache.ts
vendored
Normal file
56
server/src/external/plex/PlexQueryCache.ts
vendored
Normal 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}`;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import path from 'path';
|
||||
import { DeepReadonly, DeepRequired } from 'ts-essentials';
|
||||
import { serverOptions } from '../globals.js';
|
||||
import { StreamDetails } from '../stream/plex/PlexTranscoder.js';
|
||||
import { StreamDetails } from '../stream/types.js';
|
||||
import { StreamContextChannel } from '../stream/types.js';
|
||||
import { Maybe } from '../types/util.js';
|
||||
import { TypedEventEmitter } from '../types/eventEmitter.js';
|
||||
@@ -365,6 +365,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter<
|
||||
startTime: number,
|
||||
duration: Maybe<string>,
|
||||
enableIcon: Maybe<Watermark>,
|
||||
extraInnputHeaders: Record<string, string> = {},
|
||||
) {
|
||||
return this.spawn(
|
||||
streamUrl,
|
||||
@@ -373,6 +374,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter<
|
||||
duration,
|
||||
true,
|
||||
enableIcon,
|
||||
extraInnputHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -430,6 +432,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter<
|
||||
duration: Maybe<string>,
|
||||
limitRead: boolean,
|
||||
watermark: Maybe<Watermark>,
|
||||
extraInnputHeaders: Record<string, string> = {},
|
||||
) {
|
||||
const ffmpegArgs: string[] = [
|
||||
'-hide_banner',
|
||||
@@ -461,6 +464,9 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter<
|
||||
let videoFile = -1;
|
||||
let overlayFile = -1;
|
||||
if (isNonEmptyString(streamUrl)) {
|
||||
for (const [key, value] of Object.entries(extraInnputHeaders)) {
|
||||
ffmpegArgs.push('-headers', `'${key}: ${value}'`);
|
||||
}
|
||||
ffmpegArgs.push(`-i`, streamUrl);
|
||||
videoFile = inputFiles++;
|
||||
audioFile = videoFile;
|
||||
@@ -880,7 +886,8 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter<
|
||||
|
||||
const argsWithTokenRedacted = ffmpegArgs
|
||||
.join(' ')
|
||||
.replaceAll(/(.*X-Plex-Token=)([A-z0-9_\\-]+)(.*)/g, '$1REDACTED$3');
|
||||
.replaceAll(/(.*X-Plex-Token=)([A-z0-9_\\-]+)(.*)/g, '$1REDACTED$3')
|
||||
.replaceAll(/(.*X-Emby-Token:\s)([A-z0-9_\\-]+)(.*)/g, '$1REDACTED$3');
|
||||
this.logger.debug(`Starting ffmpeg with args: "%s"`, argsWithTokenRedacted);
|
||||
|
||||
this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
23
server/src/migrations/Migration20240719145409.ts
Normal file
23
server/src/migrations/Migration20240719145409.ts
Normal 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`);',
|
||||
);
|
||||
}
|
||||
}
|
||||
89
server/src/migrations/Migration20240805185042.ts
Normal file
89
server/src/migrations/Migration20240805185042.ts
Normal 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;');
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import path from 'path';
|
||||
import { XmlTvWriter } from './XmlTvWriter.js';
|
||||
import { ChannelDB } from './dao/channelDb.js';
|
||||
import { CustomShowDB } from './dao/customShowDb.js';
|
||||
import { FillerDB } from './dao/fillerDb.js';
|
||||
import { PlexServerDB } from './dao/plexServerDb.js';
|
||||
import { FillerDB } from './dao/fillerDB.js';
|
||||
import { MediaSourceDB } from './dao/mediaSourceDB.js';
|
||||
import { ProgramDB } from './dao/programDB.js';
|
||||
import { SettingsDB, getSettings } from './dao/settings.js';
|
||||
import { serverOptions } from './globals.js';
|
||||
@@ -33,7 +33,7 @@ export class ServerContext {
|
||||
public hdhrService: HdhrService,
|
||||
public customShowDB: CustomShowDB,
|
||||
public channelCache: ChannelCache,
|
||||
public plexServerDB: PlexServerDB,
|
||||
public mediaSourceDB: MediaSourceDB,
|
||||
public settings: SettingsDB,
|
||||
public programDB: ProgramDB,
|
||||
) {}
|
||||
@@ -77,7 +77,7 @@ export const serverContext: () => ServerContext = once(() => {
|
||||
new HdhrService(settings),
|
||||
customShowDB,
|
||||
channelCache,
|
||||
new PlexServerDB(channelDB),
|
||||
new MediaSourceDB(channelDB),
|
||||
settings,
|
||||
new ProgramDB(),
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { flatten, isNil, uniqBy } from 'lodash-es';
|
||||
import map from 'lodash-es/map';
|
||||
import { ProgramDB } from '../dao/programDB';
|
||||
import { Plex } from '../external/plex';
|
||||
import { PlexApiClient } from '../external/plex/PlexApiClient';
|
||||
import { typedProperty } from '../types/path';
|
||||
import { flatMapAsyncSeq, wait } from '../util/index.js';
|
||||
import { Logger, LoggerFactory } from '../util/logging/LoggerFactory';
|
||||
@@ -24,10 +24,10 @@ export type EnrichedPlexTerminalMedia = PlexTerminalMedia & {
|
||||
export class PlexItemEnumerator {
|
||||
#logger: Logger = LoggerFactory.child({ className: PlexItemEnumerator.name });
|
||||
#timer = new Timer(this.#logger);
|
||||
#plex: Plex;
|
||||
#plex: PlexApiClient;
|
||||
#programDB: ProgramDB;
|
||||
|
||||
constructor(plex: Plex, programDB: ProgramDB) {
|
||||
constructor(plex: PlexApiClient, programDB: ProgramDB) {
|
||||
this.#plex = plex;
|
||||
this.#programDB = programDB;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export class PlexItemEnumerator {
|
||||
} else if (isPlexDirectory(item)) {
|
||||
return [];
|
||||
} else {
|
||||
const plexResult = await this.#plex.doGet<PlexChildMediaViewType>(
|
||||
const plexResult = await this.#plex.doGetPath<PlexChildMediaViewType>(
|
||||
item.key,
|
||||
);
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import { isNil, map } from 'lodash-es';
|
||||
import { ChannelDB } from '../../dao/channelDb.js';
|
||||
import { EntityManager } from '../../dao/dataSource.js';
|
||||
import { Channel } from '../../dao/entities/Channel.js';
|
||||
import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js';
|
||||
import { MediaSource } from '../../dao/entities/MediaSource.js';
|
||||
import { ProgramDB } from '../../dao/programDB.js';
|
||||
import { Plex } from '../../external/plex.js';
|
||||
import { PlexApiClient } from '../../external/plex/PlexApiClient.js';
|
||||
import {
|
||||
EnrichedPlexTerminalMedia,
|
||||
PlexItemEnumerator,
|
||||
@@ -26,7 +26,7 @@ export class PlexContentSourceUpdater extends ContentSourceUpdater<DynamicConten
|
||||
className: PlexContentSourceUpdater.name,
|
||||
});
|
||||
#timer = new Timer(this.#logger);
|
||||
#plex: Plex;
|
||||
#plex: PlexApiClient;
|
||||
#channelDB: ChannelDB;
|
||||
|
||||
constructor(
|
||||
@@ -38,14 +38,14 @@ export class PlexContentSourceUpdater extends ContentSourceUpdater<DynamicConten
|
||||
}
|
||||
|
||||
protected async prepare(em: EntityManager) {
|
||||
const server = await em.repo(PlexServerSettings).findOneOrFail({
|
||||
const server = await em.repo(MediaSource).findOneOrFail({
|
||||
$or: [
|
||||
{ name: this.config.plexServerId },
|
||||
{ clientIdentifier: this.config.plexServerId },
|
||||
],
|
||||
});
|
||||
|
||||
this.#plex = new Plex(server);
|
||||
this.#plex = new PlexApiClient(server);
|
||||
}
|
||||
|
||||
protected async run() {
|
||||
@@ -53,7 +53,7 @@ export class PlexContentSourceUpdater extends ContentSourceUpdater<DynamicConten
|
||||
|
||||
// TODO page through the results
|
||||
const plexResult = await this.#timer.timeAsync('plex search', () =>
|
||||
this.#plex.doGet<PlexLibraryListing>(
|
||||
this.#plex.doGetPath<PlexLibraryListing>(
|
||||
`/library/sections/${this.config.plexLibraryKey}/all?${filter.join(
|
||||
'&',
|
||||
)}`,
|
||||
@@ -100,7 +100,7 @@ const plexMediaToContentProgram = (
|
||||
return {
|
||||
id: media.id ?? uniqueId,
|
||||
persisted: !isNil(media.id),
|
||||
originalProgram: media,
|
||||
originalProgram: { sourceType: 'plex', program: media },
|
||||
duration: media.duration,
|
||||
externalSourceName: serverName,
|
||||
externalSourceType: 'plex',
|
||||
|
||||
@@ -22,16 +22,18 @@ import { FfmpegSettings, Watermark } from '@tunarr/types';
|
||||
import { isError, isString, isUndefined } from 'lodash-es';
|
||||
import { Writable } from 'stream';
|
||||
import { isContentBackedLineupIteam } from '../dao/derived_types/StreamLineup.js';
|
||||
import { MediaSourceType } from '../dao/entities/MediaSource.js';
|
||||
import { FfmpegEvents } from '../ffmpeg/ffmpeg.js';
|
||||
import { TypedEventEmitter } from '../types/eventEmitter.js';
|
||||
import { Maybe } from '../types/util.js';
|
||||
import { isNonEmptyString } from '../util/index.js';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory.js';
|
||||
import { makeLocalUrl } from '../util/serverUtil.js';
|
||||
import { OfflinePlayer } from './OfflinePlayer.js';
|
||||
import { Player, PlayerContext } from './Player.js';
|
||||
import { JellyfinPlayer } from './jellyfin/JellyfinPlayer.js';
|
||||
import { PlexPlayer } from './plex/PlexPlayer.js';
|
||||
import { StreamContextChannel } from './types.js';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory.js';
|
||||
import { serverOptions } from '../globals.js';
|
||||
|
||||
export class ProgramPlayer extends Player {
|
||||
private logger = LoggerFactory.child({ caller: import.meta });
|
||||
@@ -53,17 +55,22 @@ export class ProgramPlayer extends Player {
|
||||
this.delegate = new OfflinePlayer(true, context);
|
||||
} else if (program.type === 'loading') {
|
||||
this.logger.debug('About to play loading stream');
|
||||
/* loading */
|
||||
context.isLoading = true;
|
||||
this.delegate = new OfflinePlayer(false, context);
|
||||
} else if (program.type === 'offline') {
|
||||
this.logger.debug('About to play offline stream');
|
||||
/* offline */
|
||||
this.delegate = new OfflinePlayer(false, context);
|
||||
} else if (isContentBackedLineupIteam(program) && program) {
|
||||
this.logger.debug('About to play plex stream');
|
||||
/* plex */
|
||||
this.delegate = new PlexPlayer(context);
|
||||
} else if (isContentBackedLineupIteam(program)) {
|
||||
switch (program.externalSource) {
|
||||
case MediaSourceType.Plex:
|
||||
this.logger.debug('About to play plex stream');
|
||||
this.delegate = new PlexPlayer(context);
|
||||
break;
|
||||
case MediaSourceType.Jellyfin:
|
||||
this.logger.debug('About to play plex stream');
|
||||
this.delegate = new JellyfinPlayer(context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.context.watermark = this.getWatermark(
|
||||
context.ffmpegSettings,
|
||||
@@ -137,8 +144,8 @@ export class ProgramPlayer extends Player {
|
||||
);
|
||||
}
|
||||
this.logger.error(
|
||||
'Error when attempting to play video. Fallback to error stream: ' +
|
||||
actualError.stack,
|
||||
actualError,
|
||||
'Error when attempting to play video. Fallback to error stream',
|
||||
);
|
||||
//Retry once with an error stream:
|
||||
this.context.lineupItem = {
|
||||
@@ -182,7 +189,7 @@ export class ProgramPlayer extends Player {
|
||||
} else if (isNonEmptyString(channel.icon?.path)) {
|
||||
icon = channel.icon.path;
|
||||
} else {
|
||||
icon = `http://localhost:${serverOptions().port}/images/tunarr.png`;
|
||||
icon = makeLocalUrl('/images/tunarr.png');
|
||||
}
|
||||
|
||||
console.log(watermark);
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { Loaded } from '@mikro-orm/core';
|
||||
import constants from '@tunarr/shared/constants';
|
||||
import {
|
||||
find,
|
||||
first,
|
||||
isEmpty,
|
||||
isNil,
|
||||
isNull,
|
||||
isUndefined,
|
||||
pick,
|
||||
} from 'lodash-es';
|
||||
import { first, isEmpty, isNil, isNull, isUndefined, pick } from 'lodash-es';
|
||||
import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js';
|
||||
import { getEm } from '../dao/dataSource.js';
|
||||
import {
|
||||
@@ -34,8 +26,10 @@ import { binarySearchRange } from '../util/binarySearch.js';
|
||||
import { isNonEmptyString, zipWithIndex } from '../util/index.js';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory.js';
|
||||
import { STREAM_CHANNEL_CONTEXT_KEYS, StreamContextChannel } from './types.js';
|
||||
import { FillerDB } from '../dao/fillerDb.js';
|
||||
import { FillerDB } from '../dao/fillerDB.js';
|
||||
import { ChannelDB } from '../dao/channelDb.js';
|
||||
import { MediaSourceType } from '../dao/entities/MediaSource.js';
|
||||
import { ProgramExternalId } from '../dao/entities/ProgramExternalId.js';
|
||||
|
||||
const SLACK = constants.SLACK;
|
||||
|
||||
@@ -164,7 +158,12 @@ export class StreamProgramCalculator {
|
||||
populate: ['externalIds'],
|
||||
populateWhere: {
|
||||
externalIds: {
|
||||
sourceType: ProgramExternalIdType.PLEX,
|
||||
sourceType: {
|
||||
$in: [
|
||||
ProgramExternalIdType.PLEX,
|
||||
ProgramExternalIdType.JELLYFIN,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -178,21 +177,26 @@ export class StreamProgramCalculator {
|
||||
if (!isNil(backingItem)) {
|
||||
// Will play this item on the first found server... unsure if that is
|
||||
// what we want
|
||||
const plexInfo = find(
|
||||
backingItem.externalIds,
|
||||
(eid) => eid.sourceType === ProgramExternalIdType.PLEX,
|
||||
const externalInfo = backingItem.externalIds.find(
|
||||
(eid) =>
|
||||
eid.sourceType === ProgramExternalIdType.PLEX ||
|
||||
eid.sourceType === ProgramExternalIdType.JELLYFIN,
|
||||
);
|
||||
|
||||
if (
|
||||
!isUndefined(plexInfo) &&
|
||||
isNonEmptyString(plexInfo.externalSourceId)
|
||||
!isUndefined(externalInfo) &&
|
||||
isNonEmptyString(externalInfo.externalSourceId)
|
||||
) {
|
||||
program = {
|
||||
type: 'program',
|
||||
plexFilePath: plexInfo.externalFilePath,
|
||||
externalKey: plexInfo.externalKey,
|
||||
filePath: plexInfo.directFilePath,
|
||||
externalSourceId: plexInfo.externalSourceId,
|
||||
externalSource:
|
||||
externalInfo.sourceType === ProgramExternalIdType.JELLYFIN
|
||||
? MediaSourceType.Jellyfin
|
||||
: MediaSourceType.Plex,
|
||||
plexFilePath: externalInfo.externalFilePath,
|
||||
externalKey: externalInfo.externalKey,
|
||||
filePath: externalInfo.directFilePath,
|
||||
externalSourceId: externalInfo.externalSourceId,
|
||||
duration: backingItem.duration,
|
||||
programId: backingItem.uuid,
|
||||
title: backingItem.title,
|
||||
@@ -302,24 +306,38 @@ export class StreamProgramCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// just add the video, starting at 0, playing the entire duration
|
||||
type: 'commercial',
|
||||
title: filler.title,
|
||||
filePath: filler.filePath!,
|
||||
externalKey: filler.externalKey,
|
||||
start: fillerstart,
|
||||
streamDuration: Math.max(
|
||||
1,
|
||||
Math.min(filler.duration - fillerstart, remaining),
|
||||
),
|
||||
duration: filler.duration,
|
||||
programId: filler.uuid,
|
||||
beginningOffset: beginningOffset,
|
||||
externalSourceId: filler.externalSourceId,
|
||||
plexFilePath: filler.plexFilePath!,
|
||||
programType: filler.type as ProgramType,
|
||||
};
|
||||
const externalInfos = await getEm().find(ProgramExternalId, {
|
||||
program: { uuid: filler.uuid },
|
||||
sourceType: {
|
||||
$in: [ProgramExternalIdType.PLEX, ProgramExternalIdType.JELLYFIN],
|
||||
},
|
||||
});
|
||||
|
||||
if (!isEmpty(externalInfos)) {
|
||||
const externalInfo = first(externalInfos)!;
|
||||
return {
|
||||
// just add the video, starting at 0, playing the entire duration
|
||||
type: 'commercial',
|
||||
title: filler.title,
|
||||
filePath: externalInfo.directFilePath,
|
||||
externalKey: externalInfo.externalKey,
|
||||
externalSource:
|
||||
externalInfo.sourceType === ProgramExternalIdType.JELLYFIN
|
||||
? MediaSourceType.Jellyfin
|
||||
: MediaSourceType.Plex,
|
||||
start: fillerstart,
|
||||
streamDuration: Math.max(
|
||||
1,
|
||||
Math.min(filler.duration - fillerstart, remaining),
|
||||
),
|
||||
duration: filler.duration,
|
||||
programId: filler.uuid,
|
||||
beginningOffset: beginningOffset,
|
||||
externalSourceId: externalInfo.externalSourceId!,
|
||||
plexFilePath: externalInfo.externalFilePath,
|
||||
programType: filler.type as ProgramType,
|
||||
};
|
||||
}
|
||||
}
|
||||
// pick the offline screen
|
||||
remaining = Math.min(remaining, 10 * 60 * 1000);
|
||||
|
||||
@@ -15,7 +15,6 @@ import { fileExists } from '../util/fsUtil';
|
||||
import { deepCopy } from '../util/index.js';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory';
|
||||
import {
|
||||
ProgramAndTimeElapsed,
|
||||
StreamProgramCalculator,
|
||||
generateChannelContext,
|
||||
} from './StreamProgramCalculator';
|
||||
@@ -107,80 +106,75 @@ export class VideoStream {
|
||||
}
|
||||
|
||||
let lineupItem: Maybe<StreamLineupItem>;
|
||||
let currentProgram: ProgramAndTimeElapsed | undefined;
|
||||
let channelContext: Loaded<Channel> = channel;
|
||||
const redirectChannels: string[] = [];
|
||||
const upperBounds: number[] = [];
|
||||
|
||||
if (isUndefined(lineupItem)) {
|
||||
const lineup = await serverCtx.channelDB.loadLineup(channel.uuid);
|
||||
currentProgram = await this.calculator.getCurrentProgramAndTimeElapsed(
|
||||
startTimestamp,
|
||||
channel,
|
||||
lineup,
|
||||
let currentProgram = await this.calculator.getCurrentProgramAndTimeElapsed(
|
||||
startTimestamp,
|
||||
channel,
|
||||
lineup,
|
||||
);
|
||||
|
||||
while (
|
||||
!isUndefined(currentProgram) &&
|
||||
currentProgram.program.type === 'redirect'
|
||||
) {
|
||||
redirectChannels.push(channelContext.uuid);
|
||||
upperBounds.push(
|
||||
currentProgram.program.duration - currentProgram.timeElapsed,
|
||||
);
|
||||
|
||||
while (
|
||||
!isUndefined(currentProgram) &&
|
||||
currentProgram.program.type === 'redirect'
|
||||
) {
|
||||
redirectChannels.push(channelContext.uuid);
|
||||
upperBounds.push(
|
||||
currentProgram.program.duration - currentProgram.timeElapsed,
|
||||
);
|
||||
|
||||
if (redirectChannels.includes(currentProgram.program.channel)) {
|
||||
await serverCtx.channelCache.recordPlayback(
|
||||
channelContext.uuid,
|
||||
startTimestamp,
|
||||
{
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
error:
|
||||
'Recursive channel redirect found: ' +
|
||||
redirectChannels.join(', '),
|
||||
duration: 60000,
|
||||
start: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const nextChannelId = currentProgram.program.channel;
|
||||
const newChannelAndLineup =
|
||||
await serverCtx.channelDB.loadChannelAndLineup(nextChannelId);
|
||||
|
||||
if (isNil(newChannelAndLineup)) {
|
||||
const msg = "Invalid redirect to a channel that doesn't exist";
|
||||
this.logger.error(msg);
|
||||
currentProgram = {
|
||||
program: {
|
||||
...createOfflineStreamLineupIteam(60000),
|
||||
type: 'error',
|
||||
error: msg,
|
||||
},
|
||||
timeElapsed: 0,
|
||||
programIndex: -1,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
channelContext = newChannelAndLineup.channel;
|
||||
lineupItem = serverCtx.channelCache.getCurrentLineupItem(
|
||||
if (redirectChannels.includes(currentProgram.program.channel)) {
|
||||
await serverCtx.channelCache.recordPlayback(
|
||||
channelContext.uuid,
|
||||
startTimestamp,
|
||||
{
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
error:
|
||||
'Recursive channel redirect found: ' +
|
||||
redirectChannels.join(', '),
|
||||
duration: 60000,
|
||||
start: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!isUndefined(lineupItem)) {
|
||||
lineupItem = deepCopy(lineupItem);
|
||||
break;
|
||||
} else {
|
||||
currentProgram =
|
||||
await this.calculator.getCurrentProgramAndTimeElapsed(
|
||||
startTimestamp,
|
||||
channelContext,
|
||||
newChannelAndLineup.lineup,
|
||||
);
|
||||
}
|
||||
const nextChannelId = currentProgram.program.channel;
|
||||
const newChannelAndLineup =
|
||||
await serverCtx.channelDB.loadChannelAndLineup(nextChannelId);
|
||||
|
||||
if (isNil(newChannelAndLineup)) {
|
||||
const msg = "Invalid redirect to a channel that doesn't exist";
|
||||
this.logger.error(msg);
|
||||
currentProgram = {
|
||||
program: {
|
||||
...createOfflineStreamLineupIteam(60000),
|
||||
type: 'error',
|
||||
error: msg,
|
||||
},
|
||||
timeElapsed: 0,
|
||||
programIndex: -1,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
channelContext = newChannelAndLineup.channel;
|
||||
lineupItem = serverCtx.channelCache.getCurrentLineupItem(
|
||||
channelContext.uuid,
|
||||
startTimestamp,
|
||||
);
|
||||
|
||||
if (!isUndefined(lineupItem)) {
|
||||
lineupItem = deepCopy(lineupItem);
|
||||
break;
|
||||
} else {
|
||||
currentProgram = await this.calculator.getCurrentProgramAndTimeElapsed(
|
||||
startTimestamp,
|
||||
channelContext,
|
||||
newChannelAndLineup.lineup,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +240,6 @@ export class VideoStream {
|
||||
for (let i = redirectChannels.length - 1; i >= 0; i--) {
|
||||
const thisUpperBound = nth(upperBounds, i);
|
||||
if (!isNil(thisUpperBound)) {
|
||||
console.log('adjusting upper bound....');
|
||||
const nextBound = thisUpperBound + beginningOffset;
|
||||
const prevBound = isNil(lineupItem.streamDuration)
|
||||
? upperBound
|
||||
@@ -310,10 +303,10 @@ export class VideoStream {
|
||||
};
|
||||
|
||||
const playerContext: PlayerContext = {
|
||||
lineupItem: lineupItem,
|
||||
ffmpegSettings: ffmpegSettings,
|
||||
lineupItem,
|
||||
ffmpegSettings,
|
||||
channel: combinedChannel,
|
||||
m3u8: m3u8,
|
||||
m3u8,
|
||||
audioOnly: audioOnly,
|
||||
// A little hacky...
|
||||
entityManager: (
|
||||
@@ -322,7 +315,7 @@ export class VideoStream {
|
||||
settings: serverCtx.settings,
|
||||
};
|
||||
|
||||
const player: ProgramPlayer = new ProgramPlayer(playerContext);
|
||||
const player = new ProgramPlayer(playerContext);
|
||||
let stopped = false;
|
||||
|
||||
const stop = () => {
|
||||
@@ -343,11 +336,10 @@ export class VideoStream {
|
||||
});
|
||||
|
||||
ffmpegEmitter?.on('end', () => {
|
||||
this.logger.trace('playObj.end');
|
||||
stop();
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error('Error when attempting to play video: %O', err);
|
||||
this.logger.error(err, 'Error when attempting to play video');
|
||||
stop();
|
||||
return {
|
||||
type: 'error',
|
||||
|
||||
191
server/src/stream/jellyfin/JellyfinPlayer.ts
Normal file
191
server/src/stream/jellyfin/JellyfinPlayer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
255
server/src/stream/jellyfin/JellyfinStreamDetails.ts
Normal file
255
server/src/stream/jellyfin/JellyfinStreamDetails.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import EventEmitter from 'events';
|
||||
import { isNil, isNull, isUndefined } from 'lodash-es';
|
||||
import { Writable } from 'stream';
|
||||
import { isContentBackedLineupIteam } from '../../dao/derived_types/StreamLineup.js';
|
||||
import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js';
|
||||
import {
|
||||
MediaSource,
|
||||
MediaSourceType,
|
||||
} from '../../dao/entities/MediaSource.js';
|
||||
import { FFMPEG, FfmpegEvents } from '../../ffmpeg/ffmpeg.js';
|
||||
import { GlobalScheduler } from '../../services/scheduler.js';
|
||||
import { UpdatePlexPlayStatusScheduledTask } from '../../tasks/UpdatePlexPlayStatusTask.js';
|
||||
import { UpdatePlexPlayStatusScheduledTask } from '../../tasks/plex/UpdatePlexPlayStatusTask.js';
|
||||
import { TypedEventEmitter } from '../../types/eventEmitter.js';
|
||||
import { Maybe, Nullable } from '../../types/util.js';
|
||||
import { ifDefined } from '../../util/index.js';
|
||||
@@ -61,9 +64,12 @@ export class PlexPlayer extends Player {
|
||||
}
|
||||
|
||||
const ffmpegSettings = this.context.ffmpegSettings;
|
||||
const db = this.context.entityManager.repo(PlexServerSettings);
|
||||
const db = this.context.entityManager.repo(MediaSource);
|
||||
const channel = this.context.channel;
|
||||
const server = await db.findOne({ name: lineupItem.externalSourceId });
|
||||
const server = await db.findOne({
|
||||
name: lineupItem.externalSourceId,
|
||||
type: MediaSourceType.Plex,
|
||||
});
|
||||
if (isNil(server)) {
|
||||
throw Error(
|
||||
`Unable to find server "${lineupItem.externalSourceId}" specified by program.`,
|
||||
|
||||
@@ -17,22 +17,20 @@ import {
|
||||
replace,
|
||||
trimEnd,
|
||||
} from 'lodash-es';
|
||||
import { PlexServerSettings } from '../../dao/entities/PlexServerSettings';
|
||||
import {
|
||||
Plex,
|
||||
isPlexQueryError,
|
||||
isPlexQuerySuccess,
|
||||
} from '../../external/plex';
|
||||
import { PlexApiFactory } from '../../external/PlexApiFactory';
|
||||
import { MediaSource } from '../../dao/entities/MediaSource';
|
||||
import { PlexApiClient } from '../../external/plex/PlexApiClient';
|
||||
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory';
|
||||
import { Nullable } from '../../types/util';
|
||||
import { Logger, LoggerFactory } from '../../util/logging/LoggerFactory';
|
||||
import { PlexStream, StreamDetails } from './PlexTranscoder';
|
||||
import { PlexStream } from '../types';
|
||||
import { StreamDetails } from '../types';
|
||||
import { attempt, isNonEmptyString } from '../../util';
|
||||
import { ContentBackedStreamLineupItem } from '../../dao/derived_types/StreamLineup.js';
|
||||
import { SettingsDB } from '../../dao/settings.js';
|
||||
import { makeLocalUrl } from '../../util/serverUtil.js';
|
||||
import { ProgramDB } from '../../dao/programDB';
|
||||
import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType';
|
||||
import { isQueryError, isQuerySuccess } from '../../external/BaseApiClient.js';
|
||||
|
||||
// The minimum fields we need to get stream details about an item
|
||||
type PlexItemStreamDetailsQuery = Pick<
|
||||
@@ -48,10 +46,10 @@ type PlexItemStreamDetailsQuery = Pick<
|
||||
*/
|
||||
export class PlexStreamDetails {
|
||||
private logger: Logger;
|
||||
private plex: Plex;
|
||||
private plex: PlexApiClient;
|
||||
|
||||
constructor(
|
||||
private server: PlexServerSettings,
|
||||
private server: MediaSource,
|
||||
private settings: SettingsDB,
|
||||
private programDB: ProgramDB,
|
||||
) {
|
||||
@@ -61,7 +59,7 @@ export class PlexStreamDetails {
|
||||
caller: import.meta,
|
||||
});
|
||||
|
||||
this.plex = PlexApiFactory().get(this.server);
|
||||
this.plex = MediaSourceApiFactory().get(this.server);
|
||||
}
|
||||
|
||||
async getStream(item: PlexItemStreamDetailsQuery) {
|
||||
@@ -81,7 +79,7 @@ export class PlexStreamDetails {
|
||||
item.externalKey,
|
||||
);
|
||||
|
||||
if (isPlexQueryError(itemMetadataResult)) {
|
||||
if (isQueryError(itemMetadataResult)) {
|
||||
if (itemMetadataResult.code === 'not_found') {
|
||||
this.logger.debug(
|
||||
'Could not find item %s in Plex. Rating key may have changed. Attempting to update.',
|
||||
@@ -105,7 +103,7 @@ export class PlexStreamDetails {
|
||||
},
|
||||
);
|
||||
|
||||
if (isPlexQuerySuccess(byGuidResult)) {
|
||||
if (isQuerySuccess(byGuidResult)) {
|
||||
if (byGuidResult.data.MediaContainer.size > 0) {
|
||||
this.logger.debug(
|
||||
'Found %d matching items in library. Using the first',
|
||||
@@ -286,7 +284,7 @@ export class PlexStreamDetails {
|
||||
// We have to check that we can hit this URL or the stream will not work
|
||||
if (isNonEmptyString(placeholderThumbPath)) {
|
||||
const result = await attempt(() =>
|
||||
this.plex.doHead(placeholderThumbPath),
|
||||
this.plex.doHead({ url: placeholderThumbPath }),
|
||||
);
|
||||
if (!isError(result)) {
|
||||
streamDetails.placeholderImage =
|
||||
|
||||
@@ -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)})`;
|
||||
}
|
||||
@@ -17,3 +17,35 @@ export type StreamContextChannel = Pick<
|
||||
Channel & { offlinePicture?: string; offlineSoundtrack?: string },
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ export class ReconcileProgramDurationsTask extends Task {
|
||||
!isUndefined(dbItemDuration) &&
|
||||
dbItemDuration !== item.durationMs
|
||||
) {
|
||||
console.debug('Found duration mismatch: %s', item.id);
|
||||
this.logger.debug('Found duration mismatch: %s', item.id);
|
||||
changed = true;
|
||||
return {
|
||||
...item,
|
||||
|
||||
@@ -53,3 +53,9 @@ export const PlexTaskQueue = new TaskQueue('PlexTaskQueue', {
|
||||
intervalCap: 5,
|
||||
interval: 2000,
|
||||
});
|
||||
|
||||
export const JellyfinTaskQueue = new TaskQueue('JellyfinTaskQueue', {
|
||||
concurrency: 2,
|
||||
intervalCap: 5,
|
||||
interval: 2000,
|
||||
});
|
||||
|
||||
@@ -3,9 +3,9 @@ import { PlexDvr } from '@tunarr/types/plex';
|
||||
import dayjs from 'dayjs';
|
||||
import { ChannelDB } from '../dao/channelDb.js';
|
||||
import { withDb } from '../dao/dataSource.js';
|
||||
import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js';
|
||||
import { MediaSource } from '../dao/entities/MediaSource.js';
|
||||
import { SettingsDB, defaultXmlTvSettings } from '../dao/settings.js';
|
||||
import { Plex } from '../external/plex.js';
|
||||
import { PlexApiClient } from '../external/plex/PlexApiClient.js';
|
||||
import { globalOptions } from '../globals.js';
|
||||
import { ServerContext } from '../serverContext.js';
|
||||
import { TVGuideService } from '../services/tvGuideService.js';
|
||||
@@ -88,11 +88,11 @@ export class UpdateXmlTvTask extends Task<void> {
|
||||
const channels = await this.#channelDB.getAllChannels();
|
||||
|
||||
const allPlexServers = await withDb((em) => {
|
||||
return em.find(PlexServerSettings, {});
|
||||
return em.find(MediaSource, {});
|
||||
});
|
||||
|
||||
await mapAsyncSeq(allPlexServers, async (plexServer) => {
|
||||
const plex = new Plex(plexServer);
|
||||
const plex = new PlexApiClient(plexServer);
|
||||
let dvrs: PlexDvr[] = [];
|
||||
|
||||
if (!plexServer.sendGuideUpdates && !plexServer.sendChannelUpdates) {
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType';
|
||||
import { ProgramSourceType } from '../../dao/custom_types/ProgramSourceType.js';
|
||||
import { getEm } from '../../dao/dataSource';
|
||||
import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js';
|
||||
import { MediaSource } from '../../dao/entities/MediaSource.js';
|
||||
import { Program } from '../../dao/entities/Program';
|
||||
import { ProgramExternalId } from '../../dao/entities/ProgramExternalId.js';
|
||||
import { Plex, isPlexQueryError } from '../../external/plex.js';
|
||||
import { PlexApiFactory } from '../../external/PlexApiFactory';
|
||||
import { PlexApiClient } from '../../external/plex/PlexApiClient.js';
|
||||
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory';
|
||||
import { Maybe } from '../../types/util.js';
|
||||
import { asyncPool } from '../../util/asyncPool.js';
|
||||
import { attempt, attemptSync, groupByUniq, wait } from '../../util/index.js';
|
||||
@@ -22,6 +22,7 @@ import { LoggerFactory } from '../../util/logging/LoggerFactory.js';
|
||||
import Fixer from './fixer';
|
||||
import { PlexTerminalMedia } from '@tunarr/types/plex';
|
||||
import { upsertProgramExternalIds_deprecated } from '../../dao/programExternalIdHelpers';
|
||||
import { isQueryError } from '../../external/BaseApiClient.js';
|
||||
|
||||
export class BackfillProgramExternalIds extends Fixer {
|
||||
#logger = LoggerFactory.child({ caller: import.meta });
|
||||
@@ -53,7 +54,7 @@ export class BackfillProgramExternalIds extends Fixer {
|
||||
cursor.totalCount,
|
||||
);
|
||||
|
||||
const plexConnections: Record<string, Plex> = {};
|
||||
const plexConnections: Record<string, PlexApiClient> = {};
|
||||
while (cursor.length > 0) {
|
||||
await wait(50);
|
||||
// process
|
||||
@@ -66,12 +67,12 @@ export class BackfillProgramExternalIds extends Fixer {
|
||||
keys(plexConnections),
|
||||
);
|
||||
|
||||
const serverSettings = await em.find(PlexServerSettings, {
|
||||
const serverSettings = await em.find(MediaSource, {
|
||||
name: { $in: missingServers },
|
||||
});
|
||||
|
||||
forEach(serverSettings, (server) => {
|
||||
plexConnections[server.name] = PlexApiFactory().get(server);
|
||||
plexConnections[server.name] = MediaSourceApiFactory().get(server);
|
||||
});
|
||||
|
||||
for await (const result of asyncPool(
|
||||
@@ -123,7 +124,7 @@ export class BackfillProgramExternalIds extends Fixer {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProgram(program: Program, plex: Maybe<Plex>) {
|
||||
private async handleProgram(program: Program, plex: Maybe<PlexApiClient>) {
|
||||
if (isUndefined(plex)) {
|
||||
throw new Error(
|
||||
'No Plex server connection found for server ' +
|
||||
@@ -133,7 +134,7 @@ export class BackfillProgramExternalIds extends Fixer {
|
||||
|
||||
const metadataResult = await plex.getItemMetadata(program.externalKey);
|
||||
|
||||
if (isPlexQueryError(metadataResult)) {
|
||||
if (isQueryError(metadataResult)) {
|
||||
throw new Error(
|
||||
`Could not retrieve metadata for program ID ${program.uuid}, rating key = ${program.externalKey}`,
|
||||
);
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { find, isNil } from 'lodash-es';
|
||||
import { EntityManager } from '../../dao/dataSource.js';
|
||||
import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js';
|
||||
import { Plex } from '../../external/plex.js';
|
||||
import {
|
||||
MediaSource,
|
||||
MediaSourceType,
|
||||
} from '../../dao/entities/MediaSource.js';
|
||||
import { PlexApiClient } from '../../external/plex/PlexApiClient.js';
|
||||
import Fixer from './fixer.js';
|
||||
|
||||
export class AddPlexServerIdsFixer extends Fixer {
|
||||
async runInternal(em: EntityManager): Promise<void> {
|
||||
const plexServers = await em
|
||||
.repo(PlexServerSettings)
|
||||
.find({ clientIdentifier: null });
|
||||
.repo(MediaSource)
|
||||
.find({ clientIdentifier: null, type: MediaSourceType.Plex });
|
||||
|
||||
for (const server of plexServers) {
|
||||
const api = new Plex(server);
|
||||
const api = new PlexApiClient(server);
|
||||
const devices = await api.getDevices();
|
||||
if (!isNil(devices) && devices.MediaContainer.Device) {
|
||||
const matchingServer = find(
|
||||
|
||||
@@ -16,14 +16,14 @@ import ld, {
|
||||
} from 'lodash-es';
|
||||
import { ProgramSourceType } from '../../dao/custom_types/ProgramSourceType';
|
||||
import { getEm } from '../../dao/dataSource';
|
||||
import { PlexServerSettings } from '../../dao/entities/PlexServerSettings';
|
||||
import { MediaSource } from '../../dao/entities/MediaSource';
|
||||
import { Program, ProgramType } from '../../dao/entities/Program';
|
||||
import {
|
||||
ProgramGrouping,
|
||||
ProgramGroupingType,
|
||||
} from '../../dao/entities/ProgramGrouping';
|
||||
import { ProgramGroupingExternalId } from '../../dao/entities/ProgramGroupingExternalId';
|
||||
import { PlexApiFactory } from '../../external/PlexApiFactory';
|
||||
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory';
|
||||
import { LoggerFactory } from '../../util/logging/LoggerFactory';
|
||||
import Fixer from './fixer';
|
||||
import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType';
|
||||
@@ -35,7 +35,7 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
});
|
||||
|
||||
protected async runInternal(em: EntityManager): Promise<void> {
|
||||
const plexServers = await em.findAll(PlexServerSettings);
|
||||
const plexServers = await em.findAll(MediaSource);
|
||||
|
||||
// Update shows first, then seasons, so we can relate them
|
||||
const serversAndShows = await em
|
||||
@@ -77,8 +77,8 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
continue;
|
||||
}
|
||||
|
||||
const plex = PlexApiFactory().get(server);
|
||||
const plexResult = await plex.doGet<PlexLibraryShows>(
|
||||
const plex = MediaSourceApiFactory().get(server);
|
||||
const plexResult = await plex.doGetPath<PlexLibraryShows>(
|
||||
'/library/metadata/' + grandparentExternalKey,
|
||||
);
|
||||
|
||||
@@ -147,8 +147,8 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
continue;
|
||||
}
|
||||
|
||||
const plex = PlexApiFactory().get(server);
|
||||
const plexResult = await plex.doGet<PlexSeasonView>(
|
||||
const plex = MediaSourceApiFactory().get(server);
|
||||
const plexResult = await plex.doGetPath<PlexSeasonView>(
|
||||
'/library/metadata/' + parentExternalKey,
|
||||
);
|
||||
|
||||
@@ -245,8 +245,8 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
continue;
|
||||
}
|
||||
|
||||
const plex = PlexApiFactory().get(server);
|
||||
const plexResult = await plex.doGet<PlexSeasonView>(
|
||||
const plex = MediaSourceApiFactory().get(server);
|
||||
const plexResult = await plex.doGetPath<PlexSeasonView>(
|
||||
'/library/metadata/' + ref.externalKey,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { EntityManager } from '@mikro-orm/better-sqlite';
|
||||
import { Cursor } from '@mikro-orm/core';
|
||||
import { PlexEpisodeView, PlexSeasonView } from '@tunarr/types/plex';
|
||||
import { first, forEach, groupBy, mapValues, pickBy } from 'lodash-es';
|
||||
import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js';
|
||||
import { MediaSource } from '../../dao/entities/MediaSource.js';
|
||||
import { Program, ProgramType } from '../../dao/entities/Program.js';
|
||||
import { Plex } from '../../external/plex.js';
|
||||
import { PlexApiClient } from '../../external/plex/PlexApiClient.js';
|
||||
import { Maybe } from '../../types/util.js';
|
||||
import { groupByUniqAndMap, wait } from '../../util/index.js';
|
||||
import Fixer from './fixer.js';
|
||||
@@ -14,7 +14,7 @@ export class MissingSeasonNumbersFixer extends Fixer {
|
||||
private logger = LoggerFactory.child({ caller: import.meta });
|
||||
|
||||
async runInternal(em: EntityManager): Promise<void> {
|
||||
const allPlexServers = await em.findAll(PlexServerSettings);
|
||||
const allPlexServers = await em.findAll(MediaSource);
|
||||
|
||||
if (allPlexServers.length === 0) {
|
||||
return;
|
||||
@@ -23,7 +23,7 @@ export class MissingSeasonNumbersFixer extends Fixer {
|
||||
const plexByName = groupByUniqAndMap(
|
||||
allPlexServers,
|
||||
'name',
|
||||
(server) => new Plex(server),
|
||||
(server) => new PlexApiClient(server),
|
||||
);
|
||||
|
||||
let cursor: Maybe<Cursor<Program>> = undefined;
|
||||
@@ -122,9 +122,12 @@ export class MissingSeasonNumbersFixer extends Fixer {
|
||||
} while (cursor.hasNextPage);
|
||||
}
|
||||
|
||||
private async findSeasonNumberUsingEpisode(episodeId: string, plex: Plex) {
|
||||
private async findSeasonNumberUsingEpisode(
|
||||
episodeId: string,
|
||||
plex: PlexApiClient,
|
||||
) {
|
||||
try {
|
||||
const episode = await plex.doGet<PlexEpisodeView>(
|
||||
const episode = await plex.doGetPath<PlexEpisodeView>(
|
||||
`/library/metadata/${episodeId}`,
|
||||
);
|
||||
return episode?.parentIndex;
|
||||
@@ -134,11 +137,14 @@ export class MissingSeasonNumbersFixer extends Fixer {
|
||||
}
|
||||
}
|
||||
|
||||
private async findSeasonNumberUsingParent(seasonId: string, plex: Plex) {
|
||||
private async findSeasonNumberUsingParent(
|
||||
seasonId: string,
|
||||
plex: PlexApiClient,
|
||||
) {
|
||||
// We get the parent because we're dealing with an episode and we want the
|
||||
// season index.
|
||||
try {
|
||||
const season = await plex.doGet<PlexSeasonView>(
|
||||
const season = await plex.doGetPath<PlexSeasonView>(
|
||||
`/library/metadata/${seasonId}`,
|
||||
);
|
||||
return first(season?.Metadata ?? [])?.index;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { ref } from '@mikro-orm/core';
|
||||
import { PlexTerminalMedia } from '@tunarr/types/plex';
|
||||
import { compact, isEmpty, isError, isUndefined, map } from 'lodash-es';
|
||||
import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js';
|
||||
import { getEm } from '../dao/dataSource.js';
|
||||
import { Program } from '../dao/entities/Program.js';
|
||||
import { ProgramExternalId } from '../dao/entities/ProgramExternalId.js';
|
||||
import { upsertProgramExternalIds_deprecated } from '../dao/programExternalIdHelpers.js';
|
||||
import { Plex, isPlexQueryError } from '../external/plex.js';
|
||||
import { PlexApiFactory } from '../external/PlexApiFactory.js';
|
||||
import { Maybe } from '../types/util.js';
|
||||
import { parsePlexExternalGuid } from '../util/externalIds.js';
|
||||
import { isDefined, isNonEmptyString } from '../util/index.js';
|
||||
import { Task } from './Task.js';
|
||||
import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType.js';
|
||||
import { getEm } from '../../dao/dataSource.js';
|
||||
import { Program } from '../../dao/entities/Program.js';
|
||||
import { ProgramExternalId } from '../../dao/entities/ProgramExternalId.js';
|
||||
import { upsertProgramExternalIds_deprecated } from '../../dao/programExternalIdHelpers.js';
|
||||
import { PlexApiClient } from '../../external/plex/PlexApiClient.js';
|
||||
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js';
|
||||
import { Maybe } from '../../types/util.js';
|
||||
import { parsePlexExternalGuid } from '../../util/externalIds.js';
|
||||
import { isDefined, isNonEmptyString } from '../../util/index.js';
|
||||
import { Task } from '../Task.js';
|
||||
import { isQueryError } from '../../external/BaseApiClient.js';
|
||||
|
||||
export class SavePlexProgramExternalIdsTask extends Task {
|
||||
ID = SavePlexProgramExternalIdsTask.name;
|
||||
@@ -36,13 +37,13 @@ export class SavePlexProgramExternalIdsTask extends Task {
|
||||
}
|
||||
|
||||
let chosenId: Maybe<ProgramExternalId> = undefined;
|
||||
let api: Maybe<Plex>;
|
||||
let api: Maybe<PlexApiClient>;
|
||||
for (const id of plexIds) {
|
||||
if (!isNonEmptyString(id.externalSourceId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
api = await PlexApiFactory().getOrSet(id.externalSourceId);
|
||||
api = await MediaSourceApiFactory().getOrSet(id.externalSourceId);
|
||||
|
||||
if (isDefined(api)) {
|
||||
chosenId = id;
|
||||
@@ -56,7 +57,7 @@ export class SavePlexProgramExternalIdsTask extends Task {
|
||||
|
||||
const metadataResult = await api.getItemMetadata(chosenId.externalKey);
|
||||
|
||||
if (isPlexQueryError(metadataResult)) {
|
||||
if (isQueryError(metadataResult)) {
|
||||
this.logger.error(
|
||||
'Error querying Plex for item %s',
|
||||
chosenId.externalKey,
|
||||
@@ -7,7 +7,7 @@ import { ProgramType } from '../../dao/entities/Program.js';
|
||||
type SavePlexProgramGroupingsRequest = {
|
||||
programType: ProgramType;
|
||||
plexServerName: string;
|
||||
programAndPlexIds: { programId: string; plexId: string, parentKey }[];
|
||||
programAndPlexIds: { programId: string; plexId: string; parentKey }[];
|
||||
parentKeys: string[];
|
||||
grandparentKey: string;
|
||||
};
|
||||
@@ -23,8 +23,9 @@ export class SavePlexProgramGroupingsTask extends Task {
|
||||
}
|
||||
|
||||
protected async runInternal(): Promise<unknown> {
|
||||
const calculator = new ProgramGroupingCalculator(this.programDB);
|
||||
await calculator.createHierarchyForManyFromPlex(
|
||||
await new ProgramGroupingCalculator(
|
||||
this.programDB,
|
||||
).createHierarchyForManyFromPlex(
|
||||
this.request.programType,
|
||||
this.request.plexServerName,
|
||||
this.request.programAndPlexIds,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { RecurrenceRule } from 'node-schedule';
|
||||
import { PlexServerSettings } from '../dao/entities/PlexServerSettings';
|
||||
import { PlexApiFactory } from '../external/PlexApiFactory';
|
||||
import { run } from '../util';
|
||||
import { ScheduledTask } from './ScheduledTask';
|
||||
import { Task } from './Task';
|
||||
import { MediaSource } from '../../dao/entities/MediaSource';
|
||||
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory';
|
||||
import { run } from '../../util';
|
||||
import { ScheduledTask } from '../ScheduledTask';
|
||||
import { Task } from '../Task';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlobalScheduler } from '../services/scheduler';
|
||||
import { GlobalScheduler } from '../../services/scheduler';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
type UpdatePlexPlayStatusScheduleRequest = {
|
||||
@@ -33,7 +33,7 @@ export class UpdatePlexPlayStatusScheduledTask extends ScheduledTask {
|
||||
private playState: PlayState = 'playing';
|
||||
|
||||
constructor(
|
||||
private plexServer: PlexServerSettings,
|
||||
private plexServer: MediaSource,
|
||||
private request: UpdatePlexPlayStatusScheduleRequest,
|
||||
public sessionId: string = v4(),
|
||||
) {
|
||||
@@ -96,14 +96,14 @@ class UpdatePlexPlayStatusTask extends Task {
|
||||
}
|
||||
|
||||
constructor(
|
||||
private plexServer: PlexServerSettings,
|
||||
private plexServer: MediaSource,
|
||||
private request: UpdatePlexPlayStatusInvocation,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected async runInternal(): Promise<boolean> {
|
||||
const plex = PlexApiFactory().get(this.plexServer);
|
||||
const plex = MediaSourceApiFactory().get(this.plexServer);
|
||||
|
||||
const deviceName = `tunarr-channel-${this.request.channelNumber}`;
|
||||
const params = {
|
||||
@@ -119,7 +119,7 @@ class UpdatePlexPlayStatusTask extends Task {
|
||||
};
|
||||
|
||||
try {
|
||||
await plex.doPost('/:/timeline', params);
|
||||
await plex.doPost({ url: '/:/timeline', params });
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
error,
|
||||
@@ -4,9 +4,14 @@ import {
|
||||
FastifyPluginAsync,
|
||||
FastifyPluginCallback,
|
||||
RawServerDefault,
|
||||
RouteGenericInterface,
|
||||
} from 'fastify';
|
||||
import { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { FastifyRequest } from 'fastify/types/request';
|
||||
import { FastifySchema } from 'fastify/types/schema';
|
||||
import { ResolveFastifyRequestType } from 'fastify/types/type-provider';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ServerType = FastifyInstance<
|
||||
RawServerDefault,
|
||||
@@ -27,3 +32,27 @@ export type RouterPluginAsyncCallback = FastifyPluginAsync<
|
||||
RawServerDefault,
|
||||
ZodTypeProvider
|
||||
>;
|
||||
|
||||
export type ZodFastifySchema = {
|
||||
body?: z.AnyZodObject;
|
||||
querystring?: z.AnyZodObject;
|
||||
params?: z.AnyZodObject;
|
||||
headers?: z.AnyZodObject;
|
||||
response?: z.AnyZodObject;
|
||||
};
|
||||
|
||||
export type ZodFastifyRequest<Schema extends FastifySchema = ZodFastifySchema> =
|
||||
FastifyRequest<
|
||||
RouteGenericInterface,
|
||||
RawServerDefault,
|
||||
IncomingMessage,
|
||||
Readonly<Schema>,
|
||||
ZodTypeProvider,
|
||||
unknown,
|
||||
FastifyBaseLogger,
|
||||
ResolveFastifyRequestType<
|
||||
ZodTypeProvider,
|
||||
Readonly<Schema>,
|
||||
RouteGenericInterface
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -4,6 +4,8 @@ export type Maybe<T> = T | undefined;
|
||||
|
||||
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 Intersection<X, Y> = {
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { EntityManager, ref } from '@mikro-orm/better-sqlite';
|
||||
import {
|
||||
EntityManager,
|
||||
RequiredEntityData,
|
||||
ref,
|
||||
} from '@mikro-orm/better-sqlite';
|
||||
import {
|
||||
PlexEpisode,
|
||||
PlexMovie,
|
||||
PlexMusicTrack,
|
||||
PlexTerminalMedia,
|
||||
} from '@tunarr/types/plex';
|
||||
import { compact, first, isError, map } from 'lodash-es';
|
||||
import { compact, first, isError, isNil, map } from 'lodash-es';
|
||||
import { ProgramSourceType } from '../dao/custom_types/ProgramSourceType.js';
|
||||
import { Program, ProgramType } from '../dao/entities/Program.js';
|
||||
import { ProgramExternalId } from '../dao/entities/ProgramExternalId.js';
|
||||
import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js';
|
||||
import {
|
||||
ProgramExternalIdType,
|
||||
programExternalIdTypeFromJellyfinProvider,
|
||||
} from '../dao/custom_types/ProgramExternalIdType.js';
|
||||
import { LoggerFactory } from './logging/LoggerFactory.js';
|
||||
import { parsePlexExternalGuid } from './externalIds.js';
|
||||
import { ContentProgramOriginalProgram } from '@tunarr/types/schemas';
|
||||
import { JellyfinItem } from '@tunarr/types/jellyfin';
|
||||
import { nullToUndefined } from '@tunarr/shared/util';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* Generates Program DB entities for Plex media
|
||||
@@ -24,18 +35,46 @@ class PlexProgramMinter {
|
||||
this.#em = em;
|
||||
}
|
||||
|
||||
mint(serverName: string, plexItem: PlexTerminalMedia) {
|
||||
switch (plexItem.type) {
|
||||
case 'movie':
|
||||
return this.mintMovieProgram(serverName, plexItem);
|
||||
case 'episode':
|
||||
return this.mintEpisodeProgram(serverName, plexItem);
|
||||
case 'track':
|
||||
return this.mintTrackProgram(serverName, plexItem);
|
||||
mint(serverName: string, program: ContentProgramOriginalProgram) {
|
||||
switch (program.sourceType) {
|
||||
case 'plex':
|
||||
switch (program.program.type) {
|
||||
case 'movie':
|
||||
return this.mintMovieProgramForPlex(serverName, program.program);
|
||||
case 'episode':
|
||||
return this.mintEpisodeProgramForPlex(serverName, program.program);
|
||||
case 'track':
|
||||
return this.mintTrackProgramForPlex(serverName, program.program);
|
||||
}
|
||||
// Disabled because eslint does not pickup the fact that the above is
|
||||
// exhaustive.
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case 'jellyfin':
|
||||
switch (program.program.Type) {
|
||||
case 'Movie':
|
||||
return this.mintMovieProgramForJellyfin(
|
||||
serverName,
|
||||
program.program,
|
||||
);
|
||||
case 'Episode':
|
||||
return this.mintEpisodeProgramForJellyfin(
|
||||
serverName,
|
||||
program.program,
|
||||
);
|
||||
case 'Audio':
|
||||
default:
|
||||
return this.mintTrackProgramForJellyfin(
|
||||
serverName,
|
||||
program.program,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mintMovieProgram(serverName: string, plexMovie: PlexMovie): Program {
|
||||
private mintMovieProgramForPlex(
|
||||
serverName: string,
|
||||
plexMovie: PlexMovie,
|
||||
): Program {
|
||||
const file = first(first(plexMovie.Media)?.Part ?? []);
|
||||
return this.#em.create(
|
||||
Program,
|
||||
@@ -58,7 +97,34 @@ class PlexProgramMinter {
|
||||
);
|
||||
}
|
||||
|
||||
private mintEpisodeProgram(
|
||||
private mintMovieProgramForJellyfin(
|
||||
serverName: string,
|
||||
item: JellyfinItem,
|
||||
): Program {
|
||||
// const file = first(first(plexMovie.Media)?.Part ?? []);
|
||||
return this.#em.create(
|
||||
Program,
|
||||
{
|
||||
sourceType: ProgramSourceType.JELLYFIN,
|
||||
originalAirDate: nullToUndefined(item.PremiereDate),
|
||||
duration: (item.RunTimeTicks ?? 0) / 10_000,
|
||||
filePath: nullToUndefined(item.Path),
|
||||
externalSourceId: serverName,
|
||||
externalKey: item.Id,
|
||||
plexRatingKey: item.Id,
|
||||
plexFilePath: '',
|
||||
// plexFilePath: file?.key,
|
||||
rating: nullToUndefined(item.OfficialRating),
|
||||
summary: nullToUndefined(item.Overview),
|
||||
title: nullToUndefined(item.Name) ?? '',
|
||||
type: ProgramType.Movie,
|
||||
year: nullToUndefined(item.ProductionYear),
|
||||
} satisfies RequiredEntityData<Program>,
|
||||
{ persist: false },
|
||||
);
|
||||
}
|
||||
|
||||
private mintEpisodeProgramForPlex(
|
||||
serverName: string,
|
||||
plexEpisode: PlexEpisode,
|
||||
): Program {
|
||||
@@ -92,7 +158,39 @@ class PlexProgramMinter {
|
||||
return program;
|
||||
}
|
||||
|
||||
private mintTrackProgram(serverName: string, plexTrack: PlexMusicTrack) {
|
||||
private mintEpisodeProgramForJellyfin(
|
||||
serverName: string,
|
||||
item: JellyfinItem,
|
||||
): Program {
|
||||
// const file = first(first(plexEpisode.Media)?.Part ?? []);
|
||||
return this.#em.create(
|
||||
Program,
|
||||
{
|
||||
sourceType: ProgramSourceType.JELLYFIN,
|
||||
originalAirDate: nullToUndefined(item.PremiereDate),
|
||||
duration: (item.RunTimeTicks ?? 0) / 10_000,
|
||||
externalSourceId: serverName,
|
||||
externalKey: item.Id,
|
||||
rating: nullToUndefined(item.OfficialRating),
|
||||
summary: nullToUndefined(item.Overview),
|
||||
title: nullToUndefined(item.Name) ?? '',
|
||||
type: ProgramType.Episode,
|
||||
year: nullToUndefined(item.ProductionYear),
|
||||
showTitle: nullToUndefined(item.SeriesName),
|
||||
showIcon: nullToUndefined(item.SeriesThumbImageTag),
|
||||
seasonNumber: nullToUndefined(item.ParentIndexNumber),
|
||||
episode: nullToUndefined(item.IndexNumber),
|
||||
parentExternalKey: nullToUndefined(item.SeasonId),
|
||||
grandparentExternalKey: nullToUndefined(item.SeriesId),
|
||||
},
|
||||
{ persist: false },
|
||||
);
|
||||
}
|
||||
|
||||
private mintTrackProgramForPlex(
|
||||
serverName: string,
|
||||
plexTrack: PlexMusicTrack,
|
||||
) {
|
||||
const file = first(first(plexTrack.Media)?.Part ?? []);
|
||||
return this.#em.create(
|
||||
Program,
|
||||
@@ -121,7 +219,52 @@ class PlexProgramMinter {
|
||||
);
|
||||
}
|
||||
|
||||
private mintTrackProgramForJellyfin(serverName: string, item: JellyfinItem) {
|
||||
// const file = first(first(plexTrack.Media)?.Part ?? []);
|
||||
return this.#em.create(
|
||||
Program,
|
||||
{
|
||||
sourceType: ProgramSourceType.JELLYFIN,
|
||||
originalAirDate: nullToUndefined(item.PremiereDate),
|
||||
duration: (item.RunTimeTicks ?? 0) / 10_000,
|
||||
externalSourceId: serverName,
|
||||
externalKey: item.Id,
|
||||
rating: nullToUndefined(item.OfficialRating),
|
||||
summary: nullToUndefined(item.Overview),
|
||||
title: nullToUndefined(item.Name) ?? '',
|
||||
type: ProgramType.Track,
|
||||
year: item.PremiereDate ? dayjs(item.PremiereDate).year() : undefined,
|
||||
parentExternalKey: nullToUndefined(item.AlbumId),
|
||||
grandparentExternalKey: first(item.AlbumArtists)?.Id,
|
||||
albumName: nullToUndefined(item.Album),
|
||||
artistName: nullToUndefined(item.AlbumArtist),
|
||||
},
|
||||
{ persist: false },
|
||||
);
|
||||
}
|
||||
|
||||
mintExternalIds(
|
||||
serverName: string,
|
||||
program: Program,
|
||||
{ sourceType, program: originalProgram }: ContentProgramOriginalProgram,
|
||||
) {
|
||||
switch (sourceType) {
|
||||
case 'plex':
|
||||
return this.mintExternalIdsForPlex(
|
||||
serverName,
|
||||
program,
|
||||
originalProgram,
|
||||
);
|
||||
case 'jellyfin':
|
||||
return this.mintExternalIdsForJellyfin(
|
||||
serverName,
|
||||
program,
|
||||
originalProgram,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mintExternalIdsForPlex(
|
||||
serverName: string,
|
||||
program: Program,
|
||||
media: PlexTerminalMedia,
|
||||
@@ -167,6 +310,60 @@ class PlexProgramMinter {
|
||||
|
||||
return [ratingId, guidId, ...externalGuids];
|
||||
}
|
||||
|
||||
mintExternalIdsForJellyfin(
|
||||
serverName: string,
|
||||
program: Program,
|
||||
media: JellyfinItem,
|
||||
) {
|
||||
const ratingId = this.#em.create(
|
||||
ProgramExternalId,
|
||||
{
|
||||
externalKey: media.Id,
|
||||
sourceType: ProgramExternalIdType.JELLYFIN,
|
||||
program,
|
||||
externalSourceId: serverName,
|
||||
// externalFilePath: file?.key,
|
||||
// directFilePath: file?.file,
|
||||
},
|
||||
{ persist: false },
|
||||
);
|
||||
|
||||
// const guidId = this.#em.create(
|
||||
// ProgramExternalId,
|
||||
// {
|
||||
// externalKey: media.guid,
|
||||
// sourceType: ProgramExternalIdType.PLEX_GUID,
|
||||
// program,
|
||||
// },
|
||||
// { persist: false },
|
||||
// );
|
||||
|
||||
const externalGuids = compact(
|
||||
map(media.ProviderIds, (externalGuid, guidType) => {
|
||||
if (isNil(externalGuid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typ = programExternalIdTypeFromJellyfinProvider(guidType);
|
||||
if (typ) {
|
||||
return this.#em.create(
|
||||
ProgramExternalId,
|
||||
{
|
||||
externalKey: externalGuid,
|
||||
sourceType: typ,
|
||||
program,
|
||||
},
|
||||
{ persist: false },
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
|
||||
return [ratingId, ...externalGuids];
|
||||
}
|
||||
}
|
||||
|
||||
export class ProgramMinterFactory {
|
||||
|
||||
48
server/src/util/axios.ts
Normal file
48
server/src/util/axios.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import _, {
|
||||
map,
|
||||
once,
|
||||
range,
|
||||
reject,
|
||||
zipWith,
|
||||
} from 'lodash-es';
|
||||
import fs from 'node:fs/promises';
|
||||
@@ -464,3 +465,7 @@ export function nullToUndefined<T>(x: T | null | undefined): T | undefined {
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
export function removeErrors<T>(coll: Try<T>[] | null | undefined): T[] {
|
||||
return reject(coll, isError) satisfies T[] as T[];
|
||||
}
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import { ExternalId, SingleExternalId, MultiExternalId } from '@tunarr/types';
|
||||
import { PlexMedia } from '@tunarr/types/plex';
|
||||
import { type ExternalIdType } from '@tunarr/types/schemas';
|
||||
import {
|
||||
SingleExternalIdType,
|
||||
type ExternalIdType,
|
||||
} from '@tunarr/types/schemas';
|
||||
export { scheduleRandomSlots } from './services/randomSlotsService.js';
|
||||
export { scheduleTimeSlots } from './services/timeSlotService.js';
|
||||
export { mod as dayjsMod } from './util/dayjsExtensions.js';
|
||||
|
||||
// TODO replace first arg with shared type
|
||||
export function createExternalId(
|
||||
sourceType: ExternalIdType,
|
||||
sourceType: ExternalIdType, //StrictExclude<ExternalIdType, SingleExternalIdType>,
|
||||
sourceId: string,
|
||||
itemId: string,
|
||||
): `${string}|${string}|${string}` {
|
||||
return `${sourceType}|${sourceId}|${itemId}`;
|
||||
}
|
||||
|
||||
export function createGlobalExternalIdString(
|
||||
sourceType: SingleExternalIdType,
|
||||
id: string,
|
||||
): `${string}|${string}` {
|
||||
return `${sourceType}|${id}`;
|
||||
}
|
||||
|
||||
export function createExternalIdFromMulti(multi: MultiExternalId) {
|
||||
return createExternalId(multi.source, multi.sourceId, multi.id);
|
||||
}
|
||||
|
||||
export function createExternalIdFromGlobal(global: SingleExternalId) {
|
||||
return createExternalId(global.source, '', global.id);
|
||||
return createGlobalExternalIdString(global.source, global.id);
|
||||
}
|
||||
|
||||
// We could type this better if we reuse the other ExternalId
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlexMedia } from '@tunarr/types/plex';
|
||||
import isFunction from 'lodash-es/isFunction.js';
|
||||
import { MarkRequired } from 'ts-essentials';
|
||||
import type { PerTypeCallback } from '../types/index.js';
|
||||
import { isNull } from 'lodash-es';
|
||||
export { mod as dayjsMod } from './dayjsExtensions.js';
|
||||
export * as seq from './seq.js';
|
||||
|
||||
@@ -111,3 +112,10 @@ export function forPlexMedia<T>(choices: PerTypeCallback<PlexMedia, T>) {
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export function nullToUndefined<T>(x: T | null | undefined): T | undefined {
|
||||
if (isNull(x)) {
|
||||
return undefined;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
"types": "./build/plex/index.d.ts",
|
||||
"default": "./build/plex/index.js"
|
||||
},
|
||||
"./jellyfin": {
|
||||
"types": "./build/jellyfin/index.d.ts",
|
||||
"default": "./build/jellyfin/index.js"
|
||||
},
|
||||
"./api": {
|
||||
"types": "./build/api/index.d.ts",
|
||||
"default": "./build/api/index.js"
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import z from 'zod';
|
||||
import {
|
||||
JellyfinServerSettingsSchema,
|
||||
MediaSourceSettingsSchema,
|
||||
PlexServerSettingsSchema,
|
||||
PlexStreamSettingsSchema,
|
||||
} from './schemas/settingsSchemas.js';
|
||||
|
||||
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 const defaultPlexStreamSettings = PlexStreamSettingsSchema.parse({});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../schemas/programmingSchema.js';
|
||||
import {
|
||||
BackupSettingsSchema,
|
||||
JellyfinServerSettingsSchema,
|
||||
PlexServerSettingsSchema,
|
||||
} from '../schemas/settingsSchemas.js';
|
||||
import {
|
||||
@@ -118,19 +119,36 @@ export const RandomSlotProgramLineupSchema = z.object({
|
||||
schedule: RandomSlotScheduleSchema,
|
||||
});
|
||||
|
||||
export const UpdateChannelProgrammingRequestSchema = z.discriminatedUnion(
|
||||
export const UpdateChannelProgrammingRequestSchema: z.ZodDiscriminatedUnion<
|
||||
'type',
|
||||
[
|
||||
ManualProgramLineupSchema,
|
||||
TimeBasedProgramLineupSchema,
|
||||
RandomSlotProgramLineupSchema,
|
||||
],
|
||||
);
|
||||
typeof ManualProgramLineupSchema,
|
||||
typeof TimeBasedProgramLineupSchema,
|
||||
typeof RandomSlotProgramLineupSchema,
|
||||
]
|
||||
> = z.discriminatedUnion('type', [
|
||||
ManualProgramLineupSchema,
|
||||
TimeBasedProgramLineupSchema,
|
||||
RandomSlotProgramLineupSchema,
|
||||
]);
|
||||
|
||||
export type UpdateChannelProgrammingRequest = z.infer<
|
||||
typeof UpdateChannelProgrammingRequestSchema
|
||||
>;
|
||||
|
||||
export const UpdateMediaSourceRequestSchema = z.discriminatedUnion('type', [
|
||||
PlexServerSettingsSchema.partial({
|
||||
sendChannelUpdates: true,
|
||||
sendGuideUpdates: true,
|
||||
clientIdentifier: true,
|
||||
}),
|
||||
JellyfinServerSettingsSchema,
|
||||
]);
|
||||
|
||||
export type UpdateMediaSourceRequest = z.infer<
|
||||
typeof UpdateMediaSourceRequestSchema
|
||||
>;
|
||||
|
||||
export const UpdatePlexServerRequestSchema = PlexServerSettingsSchema.partial({
|
||||
sendChannelUpdates: true,
|
||||
sendGuideUpdates: true,
|
||||
@@ -142,17 +160,18 @@ export type UpdatePlexServerRequest = z.infer<
|
||||
typeof UpdatePlexServerRequestSchema
|
||||
>;
|
||||
|
||||
export const InsertPlexServerRequestSchema = PlexServerSettingsSchema.partial({
|
||||
sendChannelUpdates: true,
|
||||
sendGuideUpdates: true,
|
||||
index: true,
|
||||
clientIdentifier: true,
|
||||
}).omit({
|
||||
id: true,
|
||||
});
|
||||
export const InsertMediaSourceRequestSchema = z.discriminatedUnion('type', [
|
||||
PlexServerSettingsSchema.partial({
|
||||
sendChannelUpdates: true,
|
||||
sendGuideUpdates: true,
|
||||
index: true,
|
||||
clientIdentifier: true,
|
||||
}).omit({ id: true }),
|
||||
JellyfinServerSettingsSchema.omit({ id: true }),
|
||||
]);
|
||||
|
||||
export type InsertPlexServerRequest = z.infer<
|
||||
typeof InsertPlexServerRequestSchema
|
||||
export type InsertMediaSourceRequest = z.infer<
|
||||
typeof InsertMediaSourceRequestSchema
|
||||
>;
|
||||
|
||||
export const VersionApiResponseSchema = z.object({
|
||||
@@ -201,3 +220,9 @@ export type UpdateSystemSettingsRequest = z.infer<
|
||||
>;
|
||||
|
||||
export const UpdateBackupSettingsRequestSchema = BackupSettingsSchema;
|
||||
|
||||
export const JellyfinLoginRequest = z.object({
|
||||
url: z.string().url(),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ export * from './FfmpegSettings.js';
|
||||
export * from './FillerList.js';
|
||||
export * from './GuideApi.js';
|
||||
export * from './HdhrSettings.js';
|
||||
export * from './PlexSettings.js';
|
||||
export * from './MediaSourceSettings.js';
|
||||
export * from './Program.js';
|
||||
export * from './Tasks.js';
|
||||
export * from './Theme.js';
|
||||
|
||||
996
types/src/jellyfin/index.ts
Normal file
996
types/src/jellyfin/index.ts
Normal 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);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from 'zod';
|
||||
import { FindChild } from '../util.js';
|
||||
|
||||
export * from './dvr.js';
|
||||
|
||||
@@ -757,21 +758,12 @@ export type PlexMetadataType<
|
||||
T extends { Metadata: M[] } = { Metadata: M[] },
|
||||
> = T['Metadata'][0];
|
||||
|
||||
type FindChild0<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> =
|
||||
Target extends PlexTerminalMedia
|
||||
? Target
|
||||
: FindChild0<Target, PlexMediaToChildType>;
|
||||
: FindChild<Target, PlexMediaToChildType>;
|
||||
|
||||
export type PlexChildMediaApiType<Target extends PlexMedia> = FindChild0<
|
||||
export type PlexChildMediaApiType<Target extends PlexMedia> = FindChild<
|
||||
Target,
|
||||
PlexMediaApiChildType
|
||||
>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PlexMusicTrackSchema,
|
||||
} from '../plex/index.js';
|
||||
import { ChannelIconSchema, ExternalIdSchema } from './utilSchemas.js';
|
||||
import { JellyfinItem } from '../jellyfin/index.js';
|
||||
|
||||
export const ProgramTypeSchema = z.union([
|
||||
z.literal('movie'),
|
||||
@@ -19,7 +20,10 @@ export const ProgramTypeSchema = z.union([
|
||||
z.literal('flex'),
|
||||
]);
|
||||
|
||||
export const ExternalSourceTypeSchema = z.literal('plex');
|
||||
export const ExternalSourceTypeSchema = z.union([
|
||||
z.literal('plex'),
|
||||
z.literal('jellyfin'),
|
||||
]);
|
||||
|
||||
export const ProgramSchema = z.object({
|
||||
artistName: z.string().optional(),
|
||||
@@ -80,17 +84,30 @@ export const RedirectProgramSchema = BaseProgramSchema.extend({
|
||||
channelName: z.string(),
|
||||
});
|
||||
|
||||
const OriginalProgramSchema = z.discriminatedUnion('sourceType', [
|
||||
z.object({
|
||||
sourceType: z.literal('plex'),
|
||||
program: z.discriminatedUnion('type', [
|
||||
PlexEpisodeSchema,
|
||||
PlexMovieSchema,
|
||||
PlexMusicTrackSchema,
|
||||
]),
|
||||
}),
|
||||
z.object({
|
||||
sourceType: z.literal('jellyfin'),
|
||||
program: JellyfinItem,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type ContentProgramOriginalProgram = z.infer<
|
||||
typeof OriginalProgramSchema
|
||||
>;
|
||||
|
||||
export const CondensedContentProgramSchema = BaseProgramSchema.extend({
|
||||
type: z.literal('content'),
|
||||
id: z.string().optional(), // Populated if persisted
|
||||
// Only populated on client requests to the server
|
||||
originalProgram: z
|
||||
.discriminatedUnion('type', [
|
||||
PlexEpisodeSchema,
|
||||
PlexMovieSchema,
|
||||
PlexMusicTrackSchema,
|
||||
])
|
||||
.optional(),
|
||||
originalProgram: OriginalProgramSchema.optional(),
|
||||
});
|
||||
|
||||
export const ContentProgramTypeSchema = z.union([
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import z from 'zod';
|
||||
import { ResolutionSchema } from './miscSchemas.js';
|
||||
import { TupleToUnion } from '../util.js';
|
||||
import { Tag, TupleToUnion } from '../util.js';
|
||||
import { ScheduleSchema } from './utilSchemas.js';
|
||||
|
||||
export const XmlTvSettingsSchema = z.object({
|
||||
@@ -88,19 +88,42 @@ export const FfmpegSettingsSchema = z.object({
|
||||
disableChannelPrelude: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const PlexServerSettingsSchema = z.object({
|
||||
id: z.string(),
|
||||
const mediaSourceId = z.custom<MediaSourceId>((val) => {
|
||||
return typeof val === 'string';
|
||||
});
|
||||
|
||||
export type MediaSourceId = Tag<string, 'mediaSourceId'>;
|
||||
|
||||
const BaseMediaSourceSettingsSchema = z.object({
|
||||
id: mediaSourceId,
|
||||
name: z.string(),
|
||||
uri: z.string(),
|
||||
accessToken: z.string(),
|
||||
});
|
||||
|
||||
export const PlexServerSettingsSchema = BaseMediaSourceSettingsSchema.extend({
|
||||
type: z.literal('plex'),
|
||||
sendGuideUpdates: z.boolean(),
|
||||
sendChannelUpdates: z.boolean(),
|
||||
index: z.number(),
|
||||
clientIdentifier: z.string().optional(),
|
||||
});
|
||||
|
||||
export const JellyfinServerSettingsSchema =
|
||||
BaseMediaSourceSettingsSchema.extend({
|
||||
type: z.literal('jellyfin'),
|
||||
});
|
||||
|
||||
export const MediaSourceSettingsSchema = z.discriminatedUnion('type', [
|
||||
PlexServerSettingsSchema,
|
||||
JellyfinServerSettingsSchema,
|
||||
]);
|
||||
|
||||
export const PlexStreamSettingsSchema = z.object({
|
||||
streamPath: z.union([z.literal('plex'), z.literal('direct')]).default('plex'),
|
||||
// Plex is deprecated here
|
||||
streamPath: z
|
||||
.union([z.literal('plex'), z.literal('direct'), z.literal('network')])
|
||||
.default('network'),
|
||||
enableDebugLogging: z.boolean().default(false),
|
||||
directStreamBitrate: z.number().default(20000),
|
||||
transcodeBitrate: z.number().default(2000),
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ExternalIdType = [
|
||||
'imdb',
|
||||
'tmdb',
|
||||
'tvdb',
|
||||
'jellyfin',
|
||||
] as const;
|
||||
|
||||
export type ExternalIdType = TupleToUnion<typeof ExternalIdType>;
|
||||
@@ -26,7 +27,7 @@ export const SingleExternalIdSourceSchema = constructZodLiteralUnionType(
|
||||
SingleExternalIdType.map((typ) => z.literal(typ)),
|
||||
);
|
||||
|
||||
export const MultiExternalIdType = ['plex'] as const;
|
||||
export const MultiExternalIdType = ['plex', 'jellyfin'] as const;
|
||||
export type MultiExternalIdType = TupleToUnion<typeof MultiExternalIdType>;
|
||||
|
||||
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
|
||||
export const MultiExternalSourceSchema = z.literal('plex');
|
||||
export const MultiExternalSourceSchema = z.union([
|
||||
z.literal('plex'),
|
||||
z.literal('jellyfin'),
|
||||
]);
|
||||
|
||||
// Represents components of an ID that can be
|
||||
// used to address an object (program or grouping) in
|
||||
|
||||
@@ -14,3 +14,16 @@ export const tag = <
|
||||
): UTag => x as unknown as UTag;
|
||||
|
||||
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;
|
||||
|
||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
||||
'schemas/index': 'src/schemas/index.ts',
|
||||
'plex/index': 'src/plex/index.ts',
|
||||
'api/index': 'src/api/index.ts',
|
||||
'jellyfin/index': 'src/jellyfin/index.ts',
|
||||
},
|
||||
format: 'esm',
|
||||
dts: true,
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"ts-essentials": "^9.4.1",
|
||||
"typescript": "5.4.3",
|
||||
"vite": "^5.4.1",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
1
web/src/assets/jellyfin.svg
Normal file
1
web/src/assets/jellyfin.svg
Normal 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 |
49
web/src/components/GridContainerTabPanel.tsx
Normal file
49
web/src/components/GridContainerTabPanel.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,60 +1,62 @@
|
||||
import {
|
||||
useCurrentMediaSource,
|
||||
useKnownMedia,
|
||||
} from '@/store/programmingSelector/selectors.ts';
|
||||
import { Box, Collapse, List } from '@mui/material';
|
||||
import { PlexMedia, isPlexMedia, isPlexParentItem } from '@tunarr/types/plex';
|
||||
import { MediaSourceSettings } from '@tunarr/types';
|
||||
import { usePrevious } from '@uidotdev/usehooks';
|
||||
import _ from 'lodash-es';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useIntersectionObserver } from 'usehooks-ts';
|
||||
import { chain, first } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useBoolean, useIntersectionObserver } from 'usehooks-ts';
|
||||
import {
|
||||
extractLastIndexes,
|
||||
findFirstItemInNextRowIndex,
|
||||
getEstimatedModalHeight,
|
||||
getImagesPerRow,
|
||||
} from '../helpers/inlineModalUtil';
|
||||
import { toggle } from '../helpers/util.ts';
|
||||
import useStore from '../store';
|
||||
import { PlexGridItem } from './channel_config/PlexGridItem';
|
||||
import { GridInlineModalProps } from './channel_config/MediaItemGrid.tsx';
|
||||
|
||||
type InlineModalProps = {
|
||||
itemGuid: string;
|
||||
modalIndex: number;
|
||||
open?: boolean;
|
||||
rowSize: number;
|
||||
type: PlexMedia['type'] | 'all';
|
||||
};
|
||||
interface InlineModalProps<ItemType, ItemKind extends string>
|
||||
extends GridInlineModalProps<ItemType> {
|
||||
getItemType: (item: ItemType) => ItemKind; // Tmp change //PlexMedia['type'] | 'all';
|
||||
getChildItemType: (item: ItemType) => ItemKind;
|
||||
sourceType: MediaSourceSettings['type'];
|
||||
extractItemId: (item: ItemType) => string;
|
||||
}
|
||||
|
||||
export function InlineModal(props: InlineModalProps) {
|
||||
const { itemGuid, modalIndex, open, rowSize, type } = props;
|
||||
export function InlineModal<ItemType, ItemKind extends string>(
|
||||
props: InlineModalProps<ItemType, ItemKind>,
|
||||
) {
|
||||
const {
|
||||
modalItemGuid: itemGuid,
|
||||
modalIndex,
|
||||
open,
|
||||
rowSize,
|
||||
getItemType,
|
||||
getChildItemType,
|
||||
extractItemId,
|
||||
renderChildren,
|
||||
} = props;
|
||||
const previousItemGuid = usePrevious(itemGuid);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [itemWidth, setItemWidth] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const {
|
||||
value: isOpen,
|
||||
setTrue: setOpen,
|
||||
setFalse: setClosed,
|
||||
} = useBoolean(false);
|
||||
const ref = useRef<HTMLUListElement>(null);
|
||||
const gridItemRef = useRef<HTMLDivElement>(null);
|
||||
const inlineModalRef = useRef<HTMLDivElement>(null);
|
||||
const darkMode = useStore((state) => state.theme.darkMode);
|
||||
const [childLimit, setChildLimit] = useState(9);
|
||||
const [imagesPerRow, setImagesPerRow] = useState(0);
|
||||
const modalChildren: PlexMedia[] = useStore((s) => {
|
||||
const known = s.contentHierarchyByServer[s.currentServer!.name];
|
||||
if (known) {
|
||||
const children = known[itemGuid];
|
||||
if (children) {
|
||||
return _.chain(children)
|
||||
.map((id) => s.knownMediaByServer[s.currentServer!.name][id])
|
||||
.compact()
|
||||
.filter(isPlexMedia)
|
||||
.value();
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
const currentMediaSource = useCurrentMediaSource(props.sourceType);
|
||||
const knownMedia = useKnownMedia();
|
||||
const modalChildren = knownMedia
|
||||
.getChildren(currentMediaSource!.id, itemGuid ?? '')
|
||||
.map((media) => media.item) as ItemType[];
|
||||
|
||||
const modalHeight = useMemo(
|
||||
() =>
|
||||
@@ -63,15 +65,11 @@ export function InlineModal(props: InlineModalProps) {
|
||||
containerWidth,
|
||||
itemWidth,
|
||||
modalChildren.length,
|
||||
type,
|
||||
first(modalChildren) ? getItemType(first(modalChildren)!) : 'unknown',
|
||||
),
|
||||
[containerWidth, itemWidth, modalChildren?.length, rowSize, type],
|
||||
[containerWidth, itemWidth, modalChildren, rowSize, getItemType],
|
||||
);
|
||||
|
||||
const toggleModal = useCallback(() => {
|
||||
setIsOpen(toggle);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && previousItemGuid !== itemGuid) {
|
||||
const containerWidth = ref?.current?.getBoundingClientRect().width || 0;
|
||||
@@ -82,11 +80,17 @@ export function InlineModal(props: InlineModalProps) {
|
||||
setItemWidth(itemWidth);
|
||||
setContainerWidth(containerWidth);
|
||||
setImagesPerRow(imagesPerRow);
|
||||
setChildModalInfo({ childItemGuid: '', childModalIndex: -1 });
|
||||
}
|
||||
}, [ref, gridItemRef, previousItemGuid, itemGuid]);
|
||||
|
||||
const [childItemGuid, setChildItemGuid] = useState<string | null>(null);
|
||||
const [childModalIndex, setChildModalIndex] = useState(-1);
|
||||
const [{ childModalIndex, childItemGuid }, setChildModalInfo] = useState<{
|
||||
childItemGuid: string | null;
|
||||
childModalIndex: number;
|
||||
}>({
|
||||
childItemGuid: null,
|
||||
childModalIndex: -1,
|
||||
});
|
||||
|
||||
const firstItemInNextRowIndex = useMemo(
|
||||
() =>
|
||||
@@ -98,10 +102,25 @@ export function InlineModal(props: InlineModalProps) {
|
||||
[childModalIndex, modalChildren?.length, rowSize],
|
||||
);
|
||||
|
||||
const handleMoveModal = useCallback((index: number, item: PlexMedia) => {
|
||||
setChildItemGuid((prev) => (prev === item.guid ? null : item.guid));
|
||||
setChildModalIndex((prev) => (prev === index ? -1 : index));
|
||||
}, []);
|
||||
const handleMoveModal = useCallback(
|
||||
(index: number, item: ItemType) => {
|
||||
const id = extractItemId(item);
|
||||
setChildModalInfo((prev) => {
|
||||
if (prev.childItemGuid === id) {
|
||||
return {
|
||||
childItemGuid: null,
|
||||
childModalIndex: -1,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
childItemGuid: id,
|
||||
childModalIndex: index,
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
[extractItemId],
|
||||
);
|
||||
|
||||
// TODO: Complete this by updating the limit below, not doing this
|
||||
// right now because already working with a huge changeset.
|
||||
@@ -125,10 +144,52 @@ export function InlineModal(props: InlineModalProps) {
|
||||
).includes(childModalIndex)
|
||||
: false;
|
||||
|
||||
const renderChild = useCallback(
|
||||
(idx: number, item: ItemType) => {
|
||||
return renderChildren(
|
||||
{
|
||||
index: idx,
|
||||
item: item,
|
||||
isModalOpen: modalIndex === idx,
|
||||
moveModal: handleMoveModal,
|
||||
ref: gridItemRef,
|
||||
},
|
||||
{
|
||||
modalItemGuid: childItemGuid ?? '',
|
||||
modalIndex: childModalIndex,
|
||||
open: idx === firstItemInNextRowIndex,
|
||||
renderChildren,
|
||||
rowSize: rowSize,
|
||||
},
|
||||
);
|
||||
},
|
||||
[
|
||||
childItemGuid,
|
||||
childModalIndex,
|
||||
firstItemInNextRowIndex,
|
||||
handleMoveModal,
|
||||
modalIndex,
|
||||
renderChildren,
|
||||
rowSize,
|
||||
],
|
||||
);
|
||||
const show = useCallback(() => {
|
||||
setOpen();
|
||||
}, [setOpen]);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
setClosed();
|
||||
}, [setClosed]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={inlineModalRef}
|
||||
component="div"
|
||||
className={
|
||||
`inline-modal-${itemGuid} ` +
|
||||
(open ? 'inline-modal-open ' : ' ') +
|
||||
(isOpen ? 'animation-done' : '')
|
||||
}
|
||||
sx={{
|
||||
display: isOpen ? 'grid' : 'none',
|
||||
gridColumn: isOpen ? '1 / -1' : undefined,
|
||||
@@ -136,7 +197,7 @@ export function InlineModal(props: InlineModalProps) {
|
||||
>
|
||||
<Collapse
|
||||
in={open}
|
||||
timeout={100}
|
||||
timeout={150}
|
||||
easing={{
|
||||
enter: 'easeInSine',
|
||||
exit: 'easeOutSine',
|
||||
@@ -144,8 +205,8 @@ export function InlineModal(props: InlineModalProps) {
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
sx={{ width: '100%', display: 'grid', gridColumn: '1 / -1' }}
|
||||
onEnter={toggleModal}
|
||||
onExited={toggleModal}
|
||||
onEnter={show}
|
||||
onExited={hide}
|
||||
>
|
||||
<List
|
||||
component="ul"
|
||||
@@ -166,39 +227,24 @@ export function InlineModal(props: InlineModalProps) {
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
{_.chain(modalChildren)
|
||||
.filter(isPlexMedia)
|
||||
.take(childLimit)
|
||||
.map((child: PlexMedia, idx: number) => (
|
||||
<React.Fragment key={child.guid}>
|
||||
{isPlexParentItem(child) && (
|
||||
<InlineModal
|
||||
itemGuid={childItemGuid ?? ''}
|
||||
modalIndex={childModalIndex}
|
||||
open={idx === firstItemInNextRowIndex}
|
||||
rowSize={rowSize}
|
||||
type={child.type}
|
||||
/>
|
||||
)}
|
||||
<PlexGridItem
|
||||
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>
|
||||
{isOpen && (
|
||||
<>
|
||||
{chain(modalChildren)
|
||||
.take(childLimit)
|
||||
.map((item, idx) => renderChild(idx, item))
|
||||
.value()}
|
||||
<InlineModal
|
||||
{...props}
|
||||
getItemType={getChildItemType}
|
||||
modalItemGuid={childItemGuid ?? ''}
|
||||
modalIndex={childModalIndex}
|
||||
open={isFinalChildModalOpen}
|
||||
/>
|
||||
{childLimit < modalChildren.length && (
|
||||
<li style={{ height: 40 }} ref={intersectionRef}></li>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import JellyfinIcon from '@/assets/jellyfin.svg?react';
|
||||
import PlexIcon from '@/assets/plex.svg?react';
|
||||
import { Maybe } from '@/types/util.ts';
|
||||
import { Close as CloseIcon, OpenInNew } from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
@@ -9,15 +12,20 @@ import {
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Stack,
|
||||
SvgIcon,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { createExternalId } from '@tunarr/shared';
|
||||
import { forProgramType } from '@tunarr/shared/util';
|
||||
import { ChannelProgram, TvGuideProgram } from '@tunarr/types';
|
||||
import {
|
||||
ChannelProgram,
|
||||
TvGuideProgram,
|
||||
isContentProgram,
|
||||
} from '@tunarr/types';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
import { capitalize, compact, find, isUndefined } from 'lodash-es';
|
||||
import {
|
||||
ReactEventHandler,
|
||||
useCallback,
|
||||
@@ -94,9 +102,10 @@ export default function ProgramDetailsDialog({
|
||||
forProgramType({
|
||||
content: (program) => (
|
||||
<Chip
|
||||
key="duration"
|
||||
color="primary"
|
||||
label={prettyItemDuration(program.duration)}
|
||||
sx={{ mt: 1 }}
|
||||
sx={{ mt: 1, mr: 1 }}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
@@ -107,12 +116,83 @@ export default function ProgramDetailsDialog({
|
||||
(program: ChannelProgram) => {
|
||||
const ratingString = rating(program);
|
||||
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;
|
||||
},
|
||||
[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(
|
||||
() =>
|
||||
forProgramType({
|
||||
@@ -131,16 +211,35 @@ export default function ProgramDetailsDialog({
|
||||
}
|
||||
|
||||
let key = p.uniqueId;
|
||||
if (
|
||||
p.subtype === 'track' &&
|
||||
p.originalProgram?.type === 'track' &&
|
||||
isNonEmptyString(p.originalProgram.parentRatingKey)
|
||||
) {
|
||||
key = createExternalId(
|
||||
'plex',
|
||||
p.externalSourceName!,
|
||||
p.originalProgram.parentRatingKey,
|
||||
);
|
||||
if (p.subtype === 'track' && p.originalProgram) {
|
||||
switch (p.originalProgram.sourceType) {
|
||||
case 'plex': {
|
||||
if (
|
||||
p.originalProgram.program.type === 'track' &&
|
||||
isNonEmptyString(p.originalProgram.program.parentRatingKey)
|
||||
) {
|
||||
key = createExternalId(
|
||||
p.originalProgram?.sourceType,
|
||||
p.externalSourceName!,
|
||||
p.originalProgram.program.parentRatingKey,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'jellyfin': {
|
||||
if (
|
||||
p.originalProgram.program.Type === 'Audio' &&
|
||||
isNonEmptyString(p.originalProgram.program.AlbumId)
|
||||
) {
|
||||
key = createExternalId(
|
||||
p.originalProgram.sourceType,
|
||||
p.externalSourceName!,
|
||||
p.originalProgram.program.AlbumId,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${settings.backendUri}/api/metadata/external?id=${key}&mode=proxy&asset=thumb`;
|
||||
@@ -181,8 +280,33 @@ export default function ProgramDetailsDialog({
|
||||
const isEpisode =
|
||||
program && program.type === 'content' && program.subtype === 'episode';
|
||||
const imageWidth = smallViewport ? (isEpisode ? '100%' : '55%') : 240;
|
||||
const programStart = dayjs(start);
|
||||
const programEnd = dayjs(stop);
|
||||
|
||||
let externalSourceName: string = '';
|
||||
if (program) {
|
||||
switch (program.type) {
|
||||
case 'content': {
|
||||
const eid = find(
|
||||
program.externalIds,
|
||||
(eid) =>
|
||||
eid.type === 'multi' &&
|
||||
(eid.source === 'plex' || eid.source === 'jellyfin'),
|
||||
);
|
||||
if (eid) {
|
||||
switch (eid.source) {
|
||||
case 'plex':
|
||||
externalSourceName = 'Plex';
|
||||
break;
|
||||
case 'jellyfin':
|
||||
externalSourceName = 'Jellyfin';
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
program && (
|
||||
@@ -206,17 +330,7 @@ export default function ProgramDetailsDialog({
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
{durationChip(program)}
|
||||
{ratingChip(program)}
|
||||
<Chip
|
||||
label={`${programStart.format('h:mm')} - ${programEnd.format(
|
||||
'h:mma',
|
||||
)}`}
|
||||
sx={{ mt: 1 }}
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
<Box>{chips(program)}</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={smallViewport ? 0 : 2}
|
||||
@@ -241,7 +355,13 @@ export default function ProgramDetailsDialog({
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width={smallViewport ? '100%' : imageWidth}
|
||||
height={500}
|
||||
height={
|
||||
program.type === 'content' && program.subtype === 'movie'
|
||||
? 500
|
||||
: smallViewport
|
||||
? undefined
|
||||
: 140
|
||||
}
|
||||
animation={thumbLoadState === 'loading' ? 'pulse' : false}
|
||||
></Skeleton>
|
||||
)}
|
||||
@@ -267,7 +387,7 @@ export default function ProgramDetailsDialog({
|
||||
width={imageWidth}
|
||||
/>
|
||||
)}
|
||||
{externalUrl && (
|
||||
{externalUrl && isNonEmptyString(externalSourceName) && (
|
||||
<Button
|
||||
component="a"
|
||||
target="_blank"
|
||||
@@ -276,7 +396,7 @@ export default function ProgramDetailsDialog({
|
||||
endIcon={<OpenInNew />}
|
||||
variant="contained"
|
||||
>
|
||||
View in Plex
|
||||
View in {externalSourceName}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,51 +1,22 @@
|
||||
import { Unstable_Grid2 as Grid } from '@mui/material';
|
||||
import { ForwardedRef, forwardRef } from 'react';
|
||||
import useStore from '../store';
|
||||
|
||||
type TabPanelProps = {
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
ref?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
}
|
||||
|
||||
const CustomTabPanel = forwardRef(
|
||||
(props: TabPanelProps, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const { children, value, index, ...other } = props;
|
||||
export function TabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
const viewType = useStore((state) => state.theme.programmingSelectorView);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default CustomTabPanel;
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`simple-tabpanel-${index}`}
|
||||
aria-labelledby={`simple-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{/* {value === index && <Box sx={{ p: 3 }}>{children}</Box>} */}
|
||||
{value === index && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import useStore from '../../store/index.ts';
|
||||
import { clearSelectedMedia } from '../../store/programmingSelector/actions.ts';
|
||||
import { CustomShowSelectedMedia } from '../../store/programmingSelector/store.ts';
|
||||
import { AddedCustomShowProgram, AddedMedia } from '../../types/index.ts';
|
||||
import { useKnownMedia } from '@/store/programmingSelector/selectors.ts';
|
||||
import { enumerateJellyfinItem } from '@/hooks/jellyfin/jellyfinHookUtil.ts';
|
||||
|
||||
type Props = {
|
||||
onAdd: (items: AddedMedia[]) => void;
|
||||
@@ -29,7 +31,7 @@ export default function AddSelectedMediaButton({
|
||||
...rest
|
||||
}: Props) {
|
||||
const apiClient = useTunarrApi();
|
||||
const knownMedia = useStore((s) => s.knownMediaByServer);
|
||||
const knownMedia = useKnownMedia();
|
||||
const selectedMedia = useStore((s) => s.selectedMedia);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -41,14 +43,45 @@ export default function AddSelectedMediaButton({
|
||||
selectedMedia,
|
||||
forSelectedMediaType<Promise<AddedMedia[]>>({
|
||||
plex: async (selected) => {
|
||||
const media = knownMedia[selected.server][selected.guid];
|
||||
const media = knownMedia.getMediaOfType(
|
||||
selected.serverId,
|
||||
selected.id,
|
||||
'plex',
|
||||
);
|
||||
|
||||
if (!media) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = await enumeratePlexItem(
|
||||
apiClient,
|
||||
selected.server,
|
||||
selected.serverId,
|
||||
selected.serverName,
|
||||
media,
|
||||
)();
|
||||
|
||||
return map(items, (item) => ({ media: item, type: 'plex' }));
|
||||
},
|
||||
jellyfin: async (selected) => {
|
||||
const media = knownMedia.getMediaOfType(
|
||||
selected.serverId,
|
||||
selected.id,
|
||||
'jellyfin',
|
||||
);
|
||||
|
||||
if (!media) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = await enumerateJellyfinItem(
|
||||
apiClient,
|
||||
selected.serverId,
|
||||
selected.serverName,
|
||||
media,
|
||||
)();
|
||||
|
||||
return map(items, (item) => ({ media: item, type: 'jellyfin' }));
|
||||
},
|
||||
'custom-show': (
|
||||
selected: CustomShowSelectedMedia,
|
||||
): Promise<AddedCustomShowProgram[]> => {
|
||||
|
||||
@@ -340,7 +340,7 @@ export default function ChannelProgrammingList({
|
||||
const [focusedProgramDetails, setFocusedProgramDetails] = useState<
|
||||
ChannelProgram | undefined
|
||||
>();
|
||||
const [startStop, setStartStop] = useState<GuideTime>({});
|
||||
const [, setStartStop] = useState<GuideTime>({});
|
||||
const [editProgram, setEditProgram] = useState<
|
||||
((UIFlexProgram | UIRedirectProgram) & { index: number }) | undefined
|
||||
>();
|
||||
@@ -474,8 +474,6 @@ export default function ChannelProgrammingList({
|
||||
open={!isUndefined(focusedProgramDetails)}
|
||||
onClose={() => setFocusedProgramDetails(undefined)}
|
||||
program={focusedProgramDetails}
|
||||
start={startStop.start}
|
||||
stop={startStop.stop}
|
||||
/>
|
||||
<AddFlexModal
|
||||
open={!isUndefined(editProgram) && editProgram.type === 'flex'}
|
||||
|
||||
168
web/src/components/channel_config/JellyfinGridItem.tsx
Normal file
168
web/src/components/channel_config/JellyfinGridItem.tsx
Normal 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);
|
||||
},
|
||||
);
|
||||
172
web/src/components/channel_config/JellyfinListItem.tsx
Normal file
172
web/src/components/channel_config/JellyfinListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
249
web/src/components/channel_config/MediaGridItem.tsx
Normal file
249
web/src/components/channel_config/MediaGridItem.tsx
Normal 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>;
|
||||
296
web/src/components/channel_config/MediaItemGrid.tsx
Normal file
296
web/src/components/channel_config/MediaItemGrid.tsx
Normal 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 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePlexTyped2 } from '../../hooks/plex/usePlex.ts';
|
||||
import useStore from '../../store/index.ts';
|
||||
import {
|
||||
addKnownMediaForServer,
|
||||
addKnownMediaForPlexServer,
|
||||
addPlexSelectedMedia,
|
||||
} from '../../store/programmingSelector/actions.ts';
|
||||
import { PlexListItem } from './PlexListItem.tsx';
|
||||
@@ -43,20 +43,20 @@ export function PlexDirectoryListItem(props: {
|
||||
PlexLibraryCollections
|
||||
>([
|
||||
{
|
||||
serverName: props.server.name,
|
||||
serverId: props.server.id,
|
||||
path: `/library/sections/${item.key}/all`,
|
||||
enabled: open,
|
||||
},
|
||||
{
|
||||
serverName: props.server.name,
|
||||
serverId: props.server.id,
|
||||
path: `/library/sections/${item.key}/collections`,
|
||||
enabled: open,
|
||||
},
|
||||
]);
|
||||
|
||||
const listings = useStore((s) => s.knownMediaByServer[server.name]);
|
||||
const listings = useStore((s) => s.knownMediaByServer[server.id]);
|
||||
const hierarchy = useStore(
|
||||
(s) => s.contentHierarchyByServer[server.name][item.uuid],
|
||||
(s) => s.contentHierarchyByServer[server.id][item.uuid],
|
||||
);
|
||||
|
||||
const observerTarget = useRef(null);
|
||||
@@ -86,13 +86,13 @@ export function PlexDirectoryListItem(props: {
|
||||
|
||||
useEffect(() => {
|
||||
if (children && children.Metadata) {
|
||||
addKnownMediaForServer(server.name, children.Metadata, item.uuid);
|
||||
addKnownMediaForPlexServer(server.id, children.Metadata, item.uuid);
|
||||
}
|
||||
|
||||
if (collections && collections.Metadata) {
|
||||
addKnownMediaForServer(server.name, collections.Metadata, item.uuid);
|
||||
addKnownMediaForPlexServer(server.id, collections.Metadata, item.uuid);
|
||||
}
|
||||
}, [item.uuid, item.key, server.name, children, collections]);
|
||||
}, [item.uuid, item.key, server.id, children, collections]);
|
||||
|
||||
const handleClick = () => {
|
||||
setLimit(Math.min(hierarchy.length, 20));
|
||||
@@ -102,17 +102,17 @@ export function PlexDirectoryListItem(props: {
|
||||
const addItems = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
addPlexSelectedMedia(server.name, [item]);
|
||||
addPlexSelectedMedia(server, [item]);
|
||||
},
|
||||
[item, server.name],
|
||||
[item, server],
|
||||
);
|
||||
|
||||
const renderCollectionRow = (id: string) => {
|
||||
const media = listings[id];
|
||||
const { type, item } = listings[id];
|
||||
|
||||
if (isPlexMedia(media)) {
|
||||
if (type === 'plex' && isPlexMedia(item)) {
|
||||
return (
|
||||
<PlexListItem key={media.guid} item={media} length={hierarchy.length} />
|
||||
<PlexListItem key={item.guid} item={item} length={hierarchy.length} />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,59 +1,41 @@
|
||||
import { useSettings } from '@/store/settings/selectors.ts';
|
||||
import { CheckCircle, RadioButtonUnchecked } from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Fade,
|
||||
Unstable_Grid2 as Grid,
|
||||
IconButton,
|
||||
ImageListItem,
|
||||
ImageListItemBar,
|
||||
Skeleton,
|
||||
alpha,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { createExternalId } from '@tunarr/shared';
|
||||
import {
|
||||
PlexChildMediaApiType,
|
||||
PlexMedia,
|
||||
isPlexPlaylist,
|
||||
isTerminalItem,
|
||||
} from '@tunarr/types/plex';
|
||||
import { filter, isNaN, isNil, isUndefined } from 'lodash-es';
|
||||
import { isEmpty, isEqual, isNil, isUndefined } from 'lodash-es';
|
||||
import pluralize from 'pluralize';
|
||||
import React, {
|
||||
import {
|
||||
ForwardedRef,
|
||||
MouseEvent,
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useIntersectionObserver } from 'usehooks-ts';
|
||||
import {
|
||||
forPlexMedia,
|
||||
isNonEmptyString,
|
||||
prettyItemDuration,
|
||||
toggle,
|
||||
} from '../../helpers/util.ts';
|
||||
import { usePlexTyped } from '../../hooks/plex/usePlex.ts';
|
||||
import useStore from '../../store/index.ts';
|
||||
import {
|
||||
addKnownMediaForServer,
|
||||
addPlexSelectedMedia,
|
||||
removePlexSelectedMedia,
|
||||
} from '../../store/programmingSelector/actions.ts';
|
||||
import { PlexSelectedMedia } from '../../store/programmingSelector/store.ts';
|
||||
|
||||
export interface PlexGridItemProps<T extends PlexMedia> {
|
||||
item: T;
|
||||
style?: React.CSSProperties;
|
||||
index?: number;
|
||||
parent?: string;
|
||||
moveModal?: (index: number, item: T) => void;
|
||||
modalIndex?: number;
|
||||
onClick?: () => void;
|
||||
ref?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
import { usePlexTyped } from '@/hooks/plex/usePlex.ts';
|
||||
import {
|
||||
addKnownMediaForPlexServer,
|
||||
addPlexSelectedMedia,
|
||||
} from '@/store/programmingSelector/actions.ts';
|
||||
import { useCurrentMediaSource } from '@/store/programmingSelector/selectors.ts';
|
||||
import { SelectedMedia } from '@/store/programmingSelector/store.ts';
|
||||
import { useSettings } from '@/store/settings/selectors.ts';
|
||||
import { createExternalId } from '@tunarr/shared';
|
||||
import { MediaGridItem } from './MediaGridItem.tsx';
|
||||
import { GridItemProps } from './MediaItemGrid.tsx';
|
||||
|
||||
export interface PlexGridItemProps<T extends PlexMedia>
|
||||
extends GridItemProps<T> {}
|
||||
|
||||
const genPlexChildPath = forPlexMedia({
|
||||
collection: (collection) =>
|
||||
@@ -67,6 +49,7 @@ const extractChildCount = forPlexMedia({
|
||||
show: (s) => s.childCount,
|
||||
collection: (s) => parseInt(s.childCount),
|
||||
playlist: (s) => s.leafCount,
|
||||
default: 0,
|
||||
});
|
||||
|
||||
const childItemType = forPlexMedia({
|
||||
@@ -95,213 +78,131 @@ const subtitle = forPlexMedia({
|
||||
},
|
||||
});
|
||||
|
||||
export const PlexGridItem = forwardRef(
|
||||
<T extends PlexMedia>(
|
||||
props: PlexGridItemProps<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 = 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);
|
||||
export const PlexGridItem = memo(
|
||||
forwardRef(
|
||||
<T extends PlexMedia>(
|
||||
props: PlexGridItemProps<T>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) => {
|
||||
const { item, index, moveModal } = props;
|
||||
const server = useCurrentMediaSource('plex')!; // We have to have a server at this point
|
||||
const settings = useSettings();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const currentServer = useCurrentMediaSource('plex');
|
||||
|
||||
const handleClick = () => {
|
||||
setOpen(toggle);
|
||||
const isMusicItem = useCallback(
|
||||
(item: PlexMedia) =>
|
||||
['MusicArtist', 'MusicAlbum', 'Audio'].includes(item.type),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!isUndefined(index) && !isUndefined(moveModal)) {
|
||||
moveModal(index, item);
|
||||
}
|
||||
};
|
||||
const isEpisode = useCallback(
|
||||
(item: PlexMedia) => item.type === 'episode',
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUndefined(children?.Metadata)) {
|
||||
addKnownMediaForServer(server.name, children.Metadata, item.guid);
|
||||
}
|
||||
}, [item.guid, server.name, children]);
|
||||
const onSelect = useCallback(
|
||||
(item: PlexMedia) => {
|
||||
addPlexSelectedMedia(server, [item]);
|
||||
},
|
||||
[server],
|
||||
);
|
||||
|
||||
const handleItem = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement | HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
const { data: childItems } = usePlexTyped<PlexChildMediaApiType<T>>(
|
||||
server.id,
|
||||
genPlexChildPath(props.item),
|
||||
!isTerminalItem(item) && modalOpen,
|
||||
);
|
||||
|
||||
if (selectedMediaIds.includes(item.guid)) {
|
||||
removePlexSelectedMedia(selectedServer!.name, [item.guid]);
|
||||
} else {
|
||||
addPlexSelectedMedia(selectedServer!.name, [item]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isUndefined(childItems) &&
|
||||
!isEmpty(childItems.Metadata) &&
|
||||
isNonEmptyString(currentServer?.id)
|
||||
) {
|
||||
addKnownMediaForPlexServer(
|
||||
currentServer.id,
|
||||
childItems.Metadata,
|
||||
item.guid,
|
||||
);
|
||||
}
|
||||
},
|
||||
[item, selectedServer, selectedMediaIds],
|
||||
);
|
||||
}, [childItems, currentServer?.id, item.guid]);
|
||||
|
||||
const { isIntersecting: isInViewport, ref: imageContainerRef } =
|
||||
useIntersectionObserver({
|
||||
threshold: 0,
|
||||
rootMargin: '0px',
|
||||
freezeOnceVisible: true,
|
||||
});
|
||||
const moveModalToItem = useCallback(() => {
|
||||
moveModal(index, item);
|
||||
}, [index, item, moveModal]);
|
||||
|
||||
const extractChildCount = forPlexMedia({
|
||||
season: (s) => s.leafCount,
|
||||
show: (s) => s.childCount,
|
||||
collection: (s) => parseInt(s.childCount),
|
||||
});
|
||||
const handleItemClick = useCallback(() => {
|
||||
setModalOpen(toggle);
|
||||
moveModalToItem();
|
||||
}, [moveModalToItem]);
|
||||
|
||||
let childCount = isUndefined(item) ? null : extractChildCount(item);
|
||||
if (isNaN(childCount)) {
|
||||
childCount = null;
|
||||
}
|
||||
const thumbnailUrlFunc = useCallback(
|
||||
(item: PlexMedia) => {
|
||||
if (isPlexPlaylist(item)) {
|
||||
return `${server.uri}${item.composite}?X-Plex-Token=${server.accessToken}`;
|
||||
} else {
|
||||
const query = new URLSearchParams({
|
||||
mode: 'proxy',
|
||||
asset: 'thumb',
|
||||
id: createExternalId('plex', server.name, item.ratingKey),
|
||||
// Commenting this out for now as temporary solution for image loading issue
|
||||
// thumbOptions: JSON.stringify({ width: 480, height: 720 }),
|
||||
});
|
||||
|
||||
const isMusicItem = ['artist', 'album', 'track', 'playlist'].includes(
|
||||
item.type,
|
||||
);
|
||||
return `${
|
||||
settings.backendUri
|
||||
}/api/metadata/external?${query.toString()}`;
|
||||
}
|
||||
},
|
||||
[server.accessToken, server.name, server.uri, settings.backendUri],
|
||||
);
|
||||
|
||||
const isEpisodeItem = ['episode'].includes(item.type);
|
||||
const selectedMediaFunc = useCallback(
|
||||
(item: PlexMedia): SelectedMedia => {
|
||||
return {
|
||||
type: 'plex',
|
||||
serverId: currentServer!.id,
|
||||
serverName: currentServer!.name,
|
||||
childCount: extractChildCount(item) ?? 0,
|
||||
id: item.guid,
|
||||
};
|
||||
},
|
||||
[currentServer],
|
||||
);
|
||||
|
||||
let thumbSrc: string;
|
||||
if (isPlexPlaylist(item)) {
|
||||
thumbSrc = `${server.uri}${item.composite}?X-Plex-Token=${server.accessToken}`;
|
||||
} else {
|
||||
const query = new URLSearchParams({
|
||||
mode: 'proxy',
|
||||
asset: 'thumb',
|
||||
id: createExternalId('plex', server.name, item.ratingKey),
|
||||
// Commenting this out for now as temporary solution for image loading issue
|
||||
// thumbOptions: JSON.stringify({ width: 480, height: 720 }),
|
||||
});
|
||||
const metadata = useMemo(
|
||||
() => ({
|
||||
itemId: item.guid,
|
||||
hasThumbnail: isNonEmptyString(
|
||||
isPlexPlaylist(item) ? item.composite : item.thumb,
|
||||
),
|
||||
childCount: extractChildCount(item),
|
||||
title: item.title,
|
||||
subtitle: subtitle(item),
|
||||
thumbnailUrl: thumbnailUrlFunc(item),
|
||||
selectedMedia: selectedMediaFunc(item),
|
||||
isMusicItem: isMusicItem(item),
|
||||
isEpisode: isEpisode(item),
|
||||
isPlaylist: isPlexPlaylist(item),
|
||||
}),
|
||||
[isEpisode, isMusicItem, item, selectedMediaFunc, thumbnailUrlFunc],
|
||||
);
|
||||
|
||||
thumbSrc = `${
|
||||
settings.backendUri
|
||||
}/api/metadata/external?${query.toString()}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<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)
|
||||
}
|
||||
return (
|
||||
currentServer && (
|
||||
<MediaGridItem
|
||||
{...props}
|
||||
key={props.item.guid}
|
||||
itemSource="plex"
|
||||
ref={ref}
|
||||
>
|
||||
{isInViewport && // TODO: Eventually turn this into isNearViewport so images load before they hit the viewport
|
||||
(hasThumb ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
minHeight: isMusicItem ? 100 : isEpisodeItem ? 84 : 225, // 84 accomodates episode img height
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
},
|
||||
metadata={metadata}
|
||||
onClick={handleItemClick}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)
|
||||
);
|
||||
},
|
||||
),
|
||||
isEqual,
|
||||
);
|
||||
|
||||
@@ -28,11 +28,12 @@ import {
|
||||
import { usePlexTyped } from '../../hooks/plex/usePlex.ts';
|
||||
import useStore from '../../store/index.ts';
|
||||
import {
|
||||
addKnownMediaForServer,
|
||||
addKnownMediaForPlexServer,
|
||||
addPlexSelectedMedia,
|
||||
removePlexSelectedMedia,
|
||||
} from '../../store/programmingSelector/actions.ts';
|
||||
import { PlexSelectedMedia } from '../../store/programmingSelector/store.ts';
|
||||
import { useCurrentMediaSource } from '@/store/programmingSelector/selectors.ts';
|
||||
|
||||
export interface PlexListItemProps<T extends PlexMedia> {
|
||||
item: T;
|
||||
@@ -61,15 +62,15 @@ export function PlexListItem<T extends PlexMedia>(props: PlexListItemProps<T>) {
|
||||
const hasChildren = !isTerminalItem(item);
|
||||
const childPath = isPlexCollection(item) ? 'collections' : 'metadata';
|
||||
const { isPending, data: children } = usePlexTyped<PlexChildMediaApiType<T>>(
|
||||
server.name,
|
||||
server.id,
|
||||
`/library/${childPath}/${props.item.ratingKey}/children`,
|
||||
hasChildren && open,
|
||||
);
|
||||
const selectedServer = useStore((s) => s.currentServer);
|
||||
const selectedServer = useCurrentMediaSource('plex');
|
||||
const selectedMedia = useStore((s) =>
|
||||
filter(s.selectedMedia, (m): m is PlexSelectedMedia => m.type === 'plex'),
|
||||
);
|
||||
const selectedMediaIds = map(selectedMedia, typedProperty('guid'));
|
||||
const selectedMediaIds = map(selectedMedia, typedProperty('id'));
|
||||
|
||||
const handleClick = () => {
|
||||
setOpen(!open);
|
||||
@@ -77,18 +78,18 @@ export function PlexListItem<T extends PlexMedia>(props: PlexListItemProps<T>) {
|
||||
|
||||
useEffect(() => {
|
||||
if (children) {
|
||||
addKnownMediaForServer(server.name, children.Metadata, item.guid);
|
||||
addKnownMediaForPlexServer(server.id, children.Metadata, item.guid);
|
||||
}
|
||||
}, [item.guid, server.name, children]);
|
||||
}, [item.guid, server.id, children]);
|
||||
|
||||
const handleItem = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (selectedMediaIds.includes(item.guid)) {
|
||||
removePlexSelectedMedia(selectedServer!.name, [item.guid]);
|
||||
removePlexSelectedMedia(selectedServer!.id, [item.guid]);
|
||||
} else {
|
||||
addPlexSelectedMedia(selectedServer!.name, [item]);
|
||||
addPlexSelectedMedia(selectedServer!, [item]);
|
||||
}
|
||||
},
|
||||
[item, selectedServer, selectedMediaIds],
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { usePlexCollectionsInfinite } from '@/hooks/plex/usePlexCollections.ts';
|
||||
import { usePlexPlaylistsInfinite } from '@/hooks/plex/usePlexPlaylists.ts';
|
||||
import { usePlexSearchInfinite } from '@/hooks/plex/usePlexSearch.ts';
|
||||
import {
|
||||
useCurrentMediaSource,
|
||||
useCurrentSourceLibrary,
|
||||
} from '@/store/programmingSelector/selectors.ts';
|
||||
import FilterAlt from '@mui/icons-material/FilterAlt';
|
||||
import GridView from '@mui/icons-material/GridView';
|
||||
import ViewList from '@mui/icons-material/ViewList';
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Divider,
|
||||
Grow,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
@@ -16,59 +18,32 @@ import {
|
||||
Tabs,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlexMedia,
|
||||
PlexMovie,
|
||||
PlexMusicArtist,
|
||||
PlexTvShow,
|
||||
isPlexParentItem,
|
||||
} from '@tunarr/types/plex';
|
||||
import { usePrevious } from '@uidotdev/usehooks';
|
||||
import _, {
|
||||
chain,
|
||||
compact,
|
||||
first,
|
||||
flatMap,
|
||||
isNil,
|
||||
isUndefined,
|
||||
map,
|
||||
sumBy,
|
||||
} from 'lodash-es';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
useDebounceCallback,
|
||||
useIntersectionObserver,
|
||||
useResizeObserver,
|
||||
} from 'usehooks-ts';
|
||||
import {
|
||||
extractLastIndexes,
|
||||
findFirstItemInNextRowIndex,
|
||||
getImagesPerRow,
|
||||
isNewModalAbove,
|
||||
} from '../../helpers/inlineModalUtil';
|
||||
import { isNonEmptyString, toggle } from '../../helpers/util';
|
||||
import { usePlex } from '../../hooks/plex/usePlex.ts';
|
||||
import { usePlexServerSettings } from '../../hooks/settingsHooks';
|
||||
import useStore from '../../store';
|
||||
import { addKnownMediaForServer } from '../../store/programmingSelector/actions';
|
||||
import { setProgrammingSelectorViewState } from '../../store/themeEditor/actions';
|
||||
import { ProgramSelectorViewType } from '../../types';
|
||||
import { InlineModal } from '../InlineModal';
|
||||
import CustomTabPanel from '../TabPanel';
|
||||
import { PlexMedia, isPlexParentItem } from '@tunarr/types/plex';
|
||||
import { chain, filter, first, isNil, isUndefined, sumBy } from 'lodash-es';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { isNonEmptyString, toggle } from '../../helpers/util.ts';
|
||||
import { usePlexLibraries } from '../../hooks/plex/usePlex.ts';
|
||||
import { useMediaSources } from '../../hooks/settingsHooks.ts';
|
||||
import useStore from '../../store/index.ts';
|
||||
import { addKnownMediaForPlexServer } from '../../store/programmingSelector/actions.ts';
|
||||
import { setProgrammingSelectorViewState } from '../../store/themeEditor/actions.ts';
|
||||
import { ProgramSelectorViewType } from '../../types/index.ts';
|
||||
import { InlineModal } from '../InlineModal.tsx';
|
||||
import { TabPanel } from '../TabPanel.tsx';
|
||||
import StandaloneToggleButton from '../base/StandaloneToggleButton.tsx';
|
||||
import ConnectPlex from '../settings/ConnectPlex';
|
||||
import ConnectPlex from '../settings/ConnectPlex.tsx';
|
||||
import {
|
||||
GridInlineModalProps,
|
||||
GridItemProps,
|
||||
MediaItemGrid,
|
||||
} from './MediaItemGrid.tsx';
|
||||
import { PlexFilterBuilder } from './PlexFilterBuilder.tsx';
|
||||
import { PlexGridItem } from './PlexGridItem';
|
||||
import { PlexListItem } from './PlexListItem';
|
||||
import { PlexGridItem } from './PlexGridItem.tsx';
|
||||
import { PlexSortField } from './PlexSortField.tsx';
|
||||
import { PlexListItem } from './PlexListItem.tsx';
|
||||
import { tag } from '@tunarr/types';
|
||||
import { MediaSourceId } from '@tunarr/types/schemas';
|
||||
|
||||
function a11yProps(index: number) {
|
||||
return {
|
||||
@@ -77,15 +52,6 @@ function a11yProps(index: number) {
|
||||
};
|
||||
}
|
||||
|
||||
type RefMap = {
|
||||
[k: string]: HTMLDivElement | null;
|
||||
};
|
||||
|
||||
type Size = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
enum TabValues {
|
||||
Library = 0,
|
||||
Collections = 1,
|
||||
@@ -93,125 +59,23 @@ enum TabValues {
|
||||
}
|
||||
|
||||
export default function PlexProgrammingSelector() {
|
||||
const { data: plexServers } = usePlexServerSettings();
|
||||
const selectedServer = useStore((s) => s.currentServer);
|
||||
const selectedLibrary = useStore((s) =>
|
||||
s.currentLibrary?.type === 'plex' ? s.currentLibrary : null,
|
||||
);
|
||||
const { data: mediaSources } = useMediaSources();
|
||||
const plexServers = filter(mediaSources, { type: 'plex' });
|
||||
const selectedServer = useCurrentMediaSource('plex');
|
||||
const selectedLibrary = useCurrentSourceLibrary('plex');
|
||||
const viewType = useStore((state) => state.theme.programmingSelectorView);
|
||||
const [tabValue, setTabValue] = useState(TabValues.Library);
|
||||
const [rowSize, setRowSize] = useState(9);
|
||||
const [modalIndex, setModalIndex] = useState(-1);
|
||||
const [modalGuid, setModalGuid] = useState<string>('');
|
||||
const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 });
|
||||
const [searchVisible, setSearchVisible] = 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) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
const scrollToGridItem = useCallback(
|
||||
(guid: string, index: number) => {
|
||||
const selectedElement = gridImageRefs.current[guid];
|
||||
const includeModalInHeightCalc = isNewModalAbove(
|
||||
previousModalIndex,
|
||||
index,
|
||||
rowSize,
|
||||
);
|
||||
|
||||
if (selectedElement) {
|
||||
// magic number for top bar padding; to do: calc it off ref
|
||||
const topBarPadding = 64;
|
||||
// New modal is opening in a row above previous modal
|
||||
const modalMovesUp = selectedElement.offsetTop - topBarPadding;
|
||||
// New modal is opening in the same row or a row below the current modal
|
||||
const modalMovesDown =
|
||||
selectedElement.offsetTop -
|
||||
selectedElement.offsetHeight -
|
||||
topBarPadding;
|
||||
|
||||
window.scrollTo({
|
||||
top: includeModalInHeightCalc ? modalMovesDown : modalMovesUp,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
},
|
||||
[previousModalIndex, rowSize],
|
||||
);
|
||||
|
||||
// Scroll to new selected item when modalIndex changes
|
||||
// Doing this on modalIndex change negates the need to calc inline modal height since it's collapsed at this time
|
||||
useEffect(() => {
|
||||
scrollToGridItem(modalGuid, modalIndex);
|
||||
}, [modalGuid, modalIndex, scrollToGridItem]);
|
||||
|
||||
const handleMoveModal = useCallback(
|
||||
(index: number, item: PlexMedia) => {
|
||||
if (index === modalIndex) {
|
||||
setModalIndex(-1);
|
||||
setModalGuid('');
|
||||
} else {
|
||||
setModalIndex(index);
|
||||
setModalGuid(item.guid);
|
||||
}
|
||||
},
|
||||
[modalIndex],
|
||||
);
|
||||
|
||||
const { data: directoryChildren } = usePlex(
|
||||
selectedServer?.name ?? '',
|
||||
'/library/sections',
|
||||
!isUndefined(selectedServer),
|
||||
const { data: directoryChildren } = usePlexLibraries(
|
||||
selectedServer?.id ?? tag<MediaSourceId>(''),
|
||||
selectedServer?.type === 'plex',
|
||||
);
|
||||
|
||||
const setViewType = (view: ProgramSelectorViewType) => {
|
||||
@@ -225,27 +89,24 @@ export default function PlexProgrammingSelector() {
|
||||
setViewType(newFormats);
|
||||
};
|
||||
|
||||
const {
|
||||
isLoading: isCollectionLoading,
|
||||
data: collectionsData,
|
||||
fetchNextPage: fetchNextCollectionsPage,
|
||||
isFetchingNextPage: isFetchingNextCollectionsPage,
|
||||
hasNextPage: hasNextCollectionsPage,
|
||||
} = usePlexCollectionsInfinite(selectedServer, selectedLibrary, rowSize * 4);
|
||||
|
||||
const {
|
||||
isLoading: isPlaylistLoading,
|
||||
data: playlistData,
|
||||
fetchNextPage: fetchNextPlaylistPage,
|
||||
isFetchingNextPage: isFetchingNextPlaylistPage,
|
||||
hasNextPage: hasNextPlaylistPage,
|
||||
} = usePlexPlaylistsInfinite(
|
||||
const plexCollectionsQuery = usePlexCollectionsInfinite(
|
||||
selectedServer,
|
||||
selectedLibrary,
|
||||
rowSize * 4,
|
||||
// selectedLibrary?.library.type === 'artist',
|
||||
24,
|
||||
);
|
||||
|
||||
const { isLoading: isCollectionLoading, data: collectionsData } =
|
||||
plexCollectionsQuery;
|
||||
|
||||
const plexPlaylistsQuery = usePlexPlaylistsInfinite(
|
||||
selectedServer,
|
||||
selectedLibrary,
|
||||
24,
|
||||
);
|
||||
|
||||
const { isLoading: isPlaylistLoading, data: playlistData } =
|
||||
plexPlaylistsQuery;
|
||||
|
||||
useEffect(() => {
|
||||
// When switching between Libraries, if a collection doesn't exist switch back to 'Library' tab
|
||||
if (
|
||||
@@ -274,19 +135,15 @@ export default function PlexProgrammingSelector() {
|
||||
({ plexSearch: plexQuery }) => plexQuery,
|
||||
);
|
||||
|
||||
const {
|
||||
isLoading: searchLoading,
|
||||
data: searchData,
|
||||
fetchNextPage: fetchNextItemsPage,
|
||||
hasNextPage: hasNextItemsPage,
|
||||
isFetchingNextPage: isFetchingNextItemsPage,
|
||||
} = usePlexSearchInfinite(
|
||||
const plexSearchQuery = usePlexSearchInfinite(
|
||||
selectedServer,
|
||||
selectedLibrary,
|
||||
searchKey,
|
||||
rowSize * 4,
|
||||
24,
|
||||
);
|
||||
|
||||
const { isLoading: searchLoading, data: searchData } = plexSearchQuery;
|
||||
|
||||
useEffect(() => {
|
||||
if (searchData?.pages.length === 1) {
|
||||
const size = searchData.pages[0].totalSize ?? searchData.pages[0].size;
|
||||
@@ -309,235 +166,141 @@ export default function PlexProgrammingSelector() {
|
||||
.map((page) => page.Metadata)
|
||||
.flatten()
|
||||
.value();
|
||||
addKnownMediaForServer(selectedServer.name, allMedia);
|
||||
addKnownMediaForPlexServer(selectedServer.id, allMedia);
|
||||
}
|
||||
}
|
||||
}, [scrollParams, selectedServer, searchData, rowSize]);
|
||||
}, [scrollParams, selectedServer, searchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isNonEmptyString(selectedServer?.name) &&
|
||||
!isUndefined(collectionsData)
|
||||
) {
|
||||
if (isNonEmptyString(selectedServer?.id) && !isUndefined(collectionsData)) {
|
||||
const allCollections = chain(collectionsData.pages)
|
||||
.reject((page) => page.size === 0)
|
||||
.map((page) => page.Metadata)
|
||||
.compact()
|
||||
.flatten()
|
||||
.value();
|
||||
addKnownMediaForServer(selectedServer.name, allCollections);
|
||||
addKnownMediaForPlexServer(selectedServer.id, allCollections);
|
||||
}
|
||||
}, [selectedServer?.name, collectionsData]);
|
||||
}, [selectedServer?.id, collectionsData]);
|
||||
|
||||
const { ref } = useIntersectionObserver({
|
||||
onChange: (_, entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (scrollParams.limit < scrollParams.max) {
|
||||
setScrollParams(({ limit: prevLimit, max }) => ({
|
||||
max,
|
||||
limit: prevLimit + rowSize * 4,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
tabValue === TabValues.Library &&
|
||||
hasNextItemsPage &&
|
||||
!isFetchingNextItemsPage
|
||||
) {
|
||||
fetchNextItemsPage().catch(console.error);
|
||||
}
|
||||
|
||||
if (
|
||||
tabValue === TabValues.Collections &&
|
||||
hasNextCollectionsPage &&
|
||||
!isFetchingNextCollectionsPage
|
||||
) {
|
||||
fetchNextCollectionsPage().catch(console.error);
|
||||
}
|
||||
|
||||
if (
|
||||
tabValue === TabValues.Playlists &&
|
||||
hasNextPlaylistPage &&
|
||||
!isFetchingNextPlaylistPage
|
||||
) {
|
||||
fetchNextPlaylistPage().catch(console.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
threshold: 0.5,
|
||||
});
|
||||
|
||||
const firstItemInNextLibraryRowIndex = useMemo(
|
||||
() =>
|
||||
findFirstItemInNextRowIndex(
|
||||
modalIndex,
|
||||
rowSize,
|
||||
sumBy(searchData?.pages, (p) => p.size) ?? 0,
|
||||
),
|
||||
[searchData, rowSize, modalIndex],
|
||||
);
|
||||
|
||||
const firstItemInNextCollectionRowIndex = useMemo(
|
||||
() =>
|
||||
findFirstItemInNextRowIndex(
|
||||
modalIndex,
|
||||
rowSize,
|
||||
sumBy(collectionsData?.pages, (p) => p.size) ?? 0,
|
||||
),
|
||||
[collectionsData, rowSize, modalIndex],
|
||||
);
|
||||
|
||||
const firstItemInNextPlaylistRowIndex = useMemo(
|
||||
() =>
|
||||
findFirstItemInNextRowIndex(
|
||||
modalIndex,
|
||||
rowSize,
|
||||
sumBy(playlistData?.pages, (p) => p.size) ?? 0,
|
||||
),
|
||||
[playlistData, rowSize, modalIndex],
|
||||
);
|
||||
|
||||
const renderGridItems = (item: PlexMedia, index: number) => {
|
||||
let firstItemIndex: number;
|
||||
const totalItems = useMemo(() => {
|
||||
switch (tabValue) {
|
||||
case TabValues.Library:
|
||||
firstItemIndex = firstItemInNextLibraryRowIndex;
|
||||
break;
|
||||
return first(plexSearchQuery.data?.pages)?.totalSize ?? 0;
|
||||
case TabValues.Collections:
|
||||
firstItemIndex = firstItemInNextCollectionRowIndex;
|
||||
break;
|
||||
return first(collectionsData?.pages)?.totalSize ?? 0;
|
||||
case TabValues.Playlists:
|
||||
firstItemIndex = firstItemInNextPlaylistRowIndex;
|
||||
break;
|
||||
return first(playlistData?.pages)?.totalSize ?? 0;
|
||||
}
|
||||
}, [
|
||||
collectionsData?.pages,
|
||||
playlistData?.pages,
|
||||
plexSearchQuery.data?.pages,
|
||||
tabValue,
|
||||
]);
|
||||
|
||||
const isOpen = index === firstItemIndex;
|
||||
const getPlexItemKey = useCallback((item: PlexMedia) => item.guid, []);
|
||||
|
||||
const renderGridItem = (
|
||||
gridItemProps: GridItemProps<PlexMedia>,
|
||||
modalProps: GridInlineModalProps<PlexMedia>,
|
||||
) => {
|
||||
const isLast = gridItemProps.index === totalItems - 1;
|
||||
|
||||
const renderModal =
|
||||
isPlexParentItem(gridItemProps.item) &&
|
||||
((gridItemProps.index + 1) % modalProps.rowSize === 0 || isLast);
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.guid}>
|
||||
{isPlexParentItem(item) &&
|
||||
(item.type === 'playlist' ? (item.leafCount ?? 0) < 500 : true) && (
|
||||
<InlineModal
|
||||
itemGuid={modalGuid}
|
||||
modalIndex={modalIndex}
|
||||
rowSize={rowSize}
|
||||
open={isOpen}
|
||||
type={item.type}
|
||||
/>
|
||||
)}
|
||||
{/* 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 key={gridItemProps.item.guid}>
|
||||
<PlexGridItem {...gridItemProps} />
|
||||
{renderModal && (
|
||||
<InlineModal
|
||||
{...modalProps}
|
||||
extractItemId={(item) => item.guid}
|
||||
sourceType="plex"
|
||||
getItemType={(item) => item.type}
|
||||
getChildItemType={() => 'season'}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFinalRowInlineModal = (arr: PlexMedia[]) => {
|
||||
// /This Modal is for last row items because they can't be inserted using the above inline modal
|
||||
// Check how many items are in the last row
|
||||
const remainingItems =
|
||||
arr.length % rowSize === 0 ? rowSize : arr.length % rowSize;
|
||||
|
||||
const open = extractLastIndexes(arr, remainingItems).includes(modalIndex);
|
||||
|
||||
return (
|
||||
<InlineModal
|
||||
itemGuid={modalGuid}
|
||||
modalIndex={modalIndex}
|
||||
rowSize={rowSize}
|
||||
open={open}
|
||||
type={'season'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderListItems = () => {
|
||||
const renderPanels = () => {
|
||||
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 (
|
||||
tabValue === TabValues.Collections &&
|
||||
collectionsData &&
|
||||
(first(collectionsData.pages)?.size ?? 0) > 0
|
||||
// tabValue === TabValues.Collections &&
|
||||
(first(collectionsData?.pages)?.size ?? 0) > 0
|
||||
) {
|
||||
elements.push(
|
||||
<CustomTabPanel
|
||||
value={tabValue}
|
||||
<TabPanel
|
||||
index={TabValues.Collections}
|
||||
value={tabValue}
|
||||
key="Collections"
|
||||
>
|
||||
{map(
|
||||
compact(flatMap(collectionsData.pages, (page) => page.Metadata)),
|
||||
(item, index: number) =>
|
||||
viewType === 'list' ? (
|
||||
<PlexListItem key={item.guid} item={item} />
|
||||
) : (
|
||||
renderGridItems(item, index)
|
||||
),
|
||||
)}
|
||||
{renderFinalRowInlineModal(
|
||||
compact(flatMap(collectionsData.pages, (page) => page.Metadata)),
|
||||
)}
|
||||
</CustomTabPanel>,
|
||||
<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={plexCollectionsQuery}
|
||||
/>
|
||||
</TabPanel>,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
tabValue === TabValues.Playlists &&
|
||||
(first(playlistData?.pages)?.size ?? 0) > 0
|
||||
) {
|
||||
elements.push(
|
||||
<CustomTabPanel
|
||||
value={tabValue}
|
||||
index={TabValues.Playlists}
|
||||
key="Playlists"
|
||||
>
|
||||
{map(
|
||||
compact(flatMap(playlistData?.pages, (page) => page.Metadata)),
|
||||
(item, index: number) =>
|
||||
viewType === 'list' ? (
|
||||
if (
|
||||
// tabValue === TabValues.Collections &&
|
||||
(first(playlistData?.pages)?.size ?? 0) > 0
|
||||
) {
|
||||
elements.push(
|
||||
<TabPanel
|
||||
index={TabValues.Playlists}
|
||||
value={tabValue}
|
||||
key="Playlists"
|
||||
>
|
||||
<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} />
|
||||
) : (
|
||||
renderGridItems(item, index)
|
||||
),
|
||||
)}
|
||||
{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>,
|
||||
);
|
||||
)}
|
||||
infiniteQuery={plexPlaylistsQuery}
|
||||
/>
|
||||
</TabPanel>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
@@ -640,42 +403,25 @@ export default function PlexProgrammingSelector() {
|
||||
<Tab
|
||||
value={TabValues.Library}
|
||||
label="Library"
|
||||
{...a11yProps(0)}
|
||||
{...a11yProps(TabValues.Library)}
|
||||
/>
|
||||
{!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)}
|
||||
/>
|
||||
)}
|
||||
{sumBy(collectionsData?.pages, (page) => page.size) > 0 && (
|
||||
<Tab
|
||||
value={TabValues.Collections}
|
||||
label="Collections"
|
||||
{...a11yProps(TabValues.Collections)}
|
||||
/>
|
||||
)}
|
||||
{sumBy(playlistData?.pages, 'size') > 0 && (
|
||||
<Tab
|
||||
value={TabValues.Playlists}
|
||||
label="Playlists"
|
||||
{...a11yProps(TabValues.Playlists)}
|
||||
/>
|
||||
)}
|
||||
</Tabs>
|
||||
</Box>
|
||||
<Box ref={gridContainerRef} sx={{ width: '100%' }}>
|
||||
{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 }} />
|
||||
{renderPanels()}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -8,9 +8,9 @@ import find from 'lodash-es/find';
|
||||
import isUndefined from 'lodash-es/isUndefined';
|
||||
import map from 'lodash-es/map';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { usePlexFilters } from '../../hooks/plex/usePlexFilters';
|
||||
import useStore from '../../store';
|
||||
import { useSelectedLibraryPlexFilters } from '../../hooks/plex/usePlexFilters';
|
||||
import { setPlexSort } from '../../store/programmingSelector/actions';
|
||||
import { useCurrentSourceLibrary } from '@/store/programmingSelector/selectors.ts';
|
||||
|
||||
type PlexSort = {
|
||||
key: string;
|
||||
@@ -19,10 +19,7 @@ type PlexSort = {
|
||||
};
|
||||
|
||||
export function PlexSortField() {
|
||||
const selectedServer = useStore((s) => s.currentServer);
|
||||
const selectedLibrary = useStore((s) =>
|
||||
s.currentLibrary?.type === 'plex' ? s.currentLibrary : null,
|
||||
);
|
||||
const selectedLibrary = useCurrentSourceLibrary('plex');
|
||||
|
||||
const [sort, setSort] = useState<PlexSort>({
|
||||
key: '',
|
||||
@@ -31,10 +28,7 @@ export function PlexSortField() {
|
||||
});
|
||||
|
||||
const { data: plexFilterMetadata, isLoading: filterMetadataLoading } =
|
||||
usePlexFilters(
|
||||
selectedServer?.name ?? '',
|
||||
selectedLibrary?.library.key ?? '',
|
||||
);
|
||||
useSelectedLibraryPlexFilters();
|
||||
|
||||
const libraryFilterMetadata = find(
|
||||
plexFilterMetadata?.Type,
|
||||
|
||||
@@ -9,20 +9,60 @@ import {
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { PlexMedia, isPlexDirectory } from '@tunarr/types/plex';
|
||||
import { find, isEmpty, isNil, isUndefined, map } from 'lodash-es';
|
||||
import {
|
||||
capitalize,
|
||||
chain,
|
||||
find,
|
||||
isEmpty,
|
||||
isNil,
|
||||
isUndefined,
|
||||
map,
|
||||
sortBy,
|
||||
} from 'lodash-es';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { usePlexLibraries } from '../../hooks/plex/usePlex.ts';
|
||||
import { usePlexServerSettings } from '../../hooks/settingsHooks.ts';
|
||||
import { useMediaSources } from '../../hooks/settingsHooks.ts';
|
||||
import { useCustomShows } from '../../hooks/useCustomShows.ts';
|
||||
import useStore from '../../store/index.ts';
|
||||
import {
|
||||
addKnownMediaForServer,
|
||||
addKnownMediaForJellyfinServer,
|
||||
addKnownMediaForPlexServer,
|
||||
setProgrammingListLibrary,
|
||||
setProgrammingListingServer,
|
||||
} from '../../store/programmingSelector/actions.ts';
|
||||
import AddPlexServer from '../settings/AddPlexServer.tsx';
|
||||
import { CustomShowProgrammingSelector } from './CustomShowProgrammingSelector.tsx';
|
||||
import PlexProgrammingSelector from './PlexProgrammingSelector.tsx';
|
||||
import { useJellyfinUserLibraries } from '@/hooks/jellyfin/useJellyfinApi.ts';
|
||||
import { JellyfinProgrammingSelector } from './JellyfinProgrammingSelector.tsx';
|
||||
import { useKnownMedia } from '@/store/programmingSelector/selectors.ts';
|
||||
import { JellyfinItem } from '@tunarr/types/jellyfin';
|
||||
import { tag } from '@tunarr/types';
|
||||
|
||||
const sortJellyfinLibraries = (item: JellyfinItem) => {
|
||||
if (item.CollectionType) {
|
||||
switch (item.CollectionType) {
|
||||
case 'tvshows':
|
||||
return 0;
|
||||
case 'movies':
|
||||
case 'music':
|
||||
return 1;
|
||||
case 'unknown':
|
||||
case 'musicvideos':
|
||||
case 'trailers':
|
||||
case 'homevideos':
|
||||
case 'boxsets':
|
||||
case 'books':
|
||||
case 'photos':
|
||||
case 'livetv':
|
||||
case 'playlists':
|
||||
case 'folders':
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
|
||||
export interface PlexListItemProps<T extends PlexMedia> {
|
||||
item: T;
|
||||
@@ -32,50 +72,76 @@ export interface PlexListItemProps<T extends PlexMedia> {
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
export default function ProgrammingSelector() {
|
||||
const { data: plexServers, isLoading: plexServersLoading } =
|
||||
usePlexServerSettings();
|
||||
type Props = {
|
||||
initialMediaSourceId?: string;
|
||||
initialLibraryId?: string;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export default function ProgrammingSelector(_: Props) {
|
||||
const { data: mediaSources, isLoading: mediaSourcesLoading } =
|
||||
useMediaSources();
|
||||
const selectedServer = useStore((s) => s.currentServer);
|
||||
const selectedLibrary = useStore((s) => s.currentLibrary);
|
||||
const knownMedia = useStore((s) => s.knownMediaByServer);
|
||||
const knownMedia = useKnownMedia();
|
||||
const [mediaSource, setMediaSource] = useState(selectedServer?.name);
|
||||
|
||||
// Convenience sub-selectors for specific library types
|
||||
const selectedPlexLibrary =
|
||||
selectedLibrary?.type === 'plex' ? selectedLibrary.library : undefined;
|
||||
const selectedJellyfinLibrary =
|
||||
selectedLibrary?.type === 'jellyfin' ? selectedLibrary.library : undefined;
|
||||
|
||||
const viewingCustomShows = mediaSource === 'custom-shows';
|
||||
|
||||
/**
|
||||
* Load Plex libraries
|
||||
*/
|
||||
const { data: plexLibraryChildren } = usePlexLibraries(
|
||||
selectedServer?.name ?? '',
|
||||
!isUndefined(selectedServer),
|
||||
selectedServer?.id ?? tag(''),
|
||||
selectedServer?.type === 'plex',
|
||||
);
|
||||
|
||||
const { data: jellyfinLibraries } = useJellyfinUserLibraries(
|
||||
selectedServer?.id ?? '',
|
||||
selectedServer?.type === 'jellyfin',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const server =
|
||||
!isUndefined(plexServers) && !isEmpty(plexServers)
|
||||
? plexServers[0]
|
||||
!isUndefined(mediaSources) && !isEmpty(mediaSources)
|
||||
? mediaSources[0]
|
||||
: undefined;
|
||||
|
||||
setProgrammingListingServer(server);
|
||||
}, [plexServers]);
|
||||
}, [mediaSources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedServer && plexLibraryChildren) {
|
||||
if (plexLibraryChildren.size > 0) {
|
||||
if (selectedServer?.type === 'plex' && plexLibraryChildren) {
|
||||
if (
|
||||
plexLibraryChildren.size > 0 &&
|
||||
(!selectedLibrary || selectedLibrary.type !== 'plex')
|
||||
) {
|
||||
setProgrammingListLibrary({
|
||||
type: 'plex',
|
||||
library: plexLibraryChildren.Directory[0],
|
||||
});
|
||||
}
|
||||
addKnownMediaForServer(selectedServer.name, [
|
||||
addKnownMediaForPlexServer(selectedServer.id, [
|
||||
...plexLibraryChildren.Directory,
|
||||
]);
|
||||
} else if (selectedServer?.type === 'jellyfin' && jellyfinLibraries) {
|
||||
if (
|
||||
jellyfinLibraries.Items.length > 0 &&
|
||||
(!selectedLibrary || selectedLibrary.type !== 'jellyfin')
|
||||
) {
|
||||
setProgrammingListLibrary({
|
||||
type: 'jellyfin',
|
||||
library: sortBy(jellyfinLibraries.Items, sortJellyfinLibraries)[0],
|
||||
});
|
||||
}
|
||||
addKnownMediaForJellyfinServer(selectedServer.id, [
|
||||
...jellyfinLibraries.Items,
|
||||
]);
|
||||
}
|
||||
}, [selectedServer, plexLibraryChildren]);
|
||||
}, [selectedServer, plexLibraryChildren, jellyfinLibraries, selectedLibrary]);
|
||||
|
||||
/**
|
||||
* Load custom shows
|
||||
@@ -83,44 +149,65 @@ export default function ProgrammingSelector() {
|
||||
const { data: customShows } = useCustomShows();
|
||||
|
||||
const onMediaSourceChange = useCallback(
|
||||
(newMediaSource: string) => {
|
||||
if (newMediaSource === 'custom-shows') {
|
||||
(newMediaSourceId: string) => {
|
||||
if (newMediaSourceId === 'custom-shows') {
|
||||
// Not dealing with a server
|
||||
setProgrammingListLibrary({ type: 'custom-show' });
|
||||
setProgrammingListingServer(undefined);
|
||||
setMediaSource(newMediaSource);
|
||||
setMediaSource(newMediaSourceId);
|
||||
} else {
|
||||
const server = find(plexServers, { name: newMediaSource });
|
||||
const server = find(
|
||||
mediaSources,
|
||||
(source) => source.id === newMediaSourceId,
|
||||
);
|
||||
if (server) {
|
||||
setProgrammingListingServer(server);
|
||||
setMediaSource(server.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
[plexServers],
|
||||
[mediaSources],
|
||||
);
|
||||
|
||||
const onLibraryChange = useCallback(
|
||||
(libraryUuid: string) => {
|
||||
if (selectedServer) {
|
||||
const known = knownMedia[selectedServer.name] ?? {};
|
||||
const library = known[libraryUuid];
|
||||
if (selectedServer?.type === 'plex') {
|
||||
const library = knownMedia.getMediaOfType(
|
||||
selectedServer.id,
|
||||
libraryUuid,
|
||||
'plex',
|
||||
);
|
||||
if (library && isPlexDirectory(library)) {
|
||||
setProgrammingListLibrary({ type: 'plex', library });
|
||||
}
|
||||
} else if (selectedServer?.type === 'jellyfin') {
|
||||
const library = knownMedia.getMediaOfType(
|
||||
selectedServer.id,
|
||||
libraryUuid,
|
||||
'jellyfin',
|
||||
);
|
||||
if (library) {
|
||||
setProgrammingListLibrary({ type: 'jellyfin', library });
|
||||
}
|
||||
}
|
||||
},
|
||||
[knownMedia, selectedServer],
|
||||
);
|
||||
|
||||
const renderMediaSourcePrograms = () => {
|
||||
if (selectedLibrary?.type === 'custom-show') {
|
||||
return <CustomShowProgrammingSelector />;
|
||||
} else if (selectedLibrary?.type === 'plex') {
|
||||
return <PlexProgrammingSelector />;
|
||||
if (selectedLibrary) {
|
||||
switch (selectedLibrary.type) {
|
||||
case 'plex':
|
||||
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 (
|
||||
<>
|
||||
<Typography variant="h6" fontWeight={600} align="left" sx={{ mt: 3 }}>
|
||||
@@ -146,8 +233,64 @@ export default function ProgrammingSelector() {
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderLibraryChoices = () => {
|
||||
if (isUndefined(selectedServer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (selectedServer.type) {
|
||||
case 'plex': {
|
||||
return (
|
||||
!isNil(plexLibraryChildren) &&
|
||||
plexLibraryChildren.size > 0 &&
|
||||
selectedPlexLibrary && (
|
||||
<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 =
|
||||
(plexServers && plexServers.length > 0) || customShows.length > 0;
|
||||
(mediaSources && mediaSources.length > 0) || customShows.length > 0;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -168,15 +311,13 @@ export default function ProgrammingSelector() {
|
||||
<Select
|
||||
label="Media Source"
|
||||
value={
|
||||
viewingCustomShows
|
||||
? 'custom-shows'
|
||||
: selectedServer?.name ?? ''
|
||||
viewingCustomShows ? 'custom-shows' : selectedServer?.id ?? ''
|
||||
}
|
||||
onChange={(e) => onMediaSourceChange(e.target.value)}
|
||||
>
|
||||
{map(plexServers, (server) => (
|
||||
<MenuItem key={server.name} value={server.name}>
|
||||
Plex: {server.name}
|
||||
{map(mediaSources, (server) => (
|
||||
<MenuItem key={server.id} value={server.id}>
|
||||
{capitalize(server.type)}: {server.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
{customShows.length > 0 && (
|
||||
@@ -186,24 +327,7 @@ export default function ProgrammingSelector() {
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
)}
|
||||
{renderLibraryChoices()}
|
||||
</Stack>
|
||||
{renderMediaSourcePrograms()}
|
||||
</Box>
|
||||
|
||||
@@ -6,13 +6,14 @@ import { useSnackbar } from 'notistack';
|
||||
import { useCallback, useState } from 'react';
|
||||
import useStore from '../../store/index.ts';
|
||||
import {
|
||||
addKnownMediaForServer,
|
||||
addKnownMediaForPlexServer,
|
||||
addPlexSelectedMedia,
|
||||
clearSelectedMedia,
|
||||
} from '../../store/programmingSelector/actions.ts';
|
||||
import { AddedMedia } from '../../types/index.ts';
|
||||
import { RotatingLoopIcon } from '../base/LoadingIcon.tsx';
|
||||
import AddSelectedMediaButton from './AddSelectedMediaButton.tsx';
|
||||
import { useCurrentMediaSourceAndLibrary } from '@/store/programmingSelector/selectors.ts';
|
||||
|
||||
type Props = {
|
||||
onAddSelectedMedia: (media: AddedMedia[]) => void;
|
||||
@@ -28,10 +29,8 @@ export default function SelectedProgrammingActions({
|
||||
selectAllEnabled = true,
|
||||
toggleOrSetSelectedProgramsDrawer, // onSelectionModalClose,
|
||||
}: Props) {
|
||||
const [selectedServer, selectedLibrary] = useStore((s) => [
|
||||
s.currentServer,
|
||||
s.currentLibrary?.type === 'plex' ? s.currentLibrary : null,
|
||||
]);
|
||||
const [selectedServer, selectedLibrary] =
|
||||
useCurrentMediaSourceAndLibrary('plex');
|
||||
|
||||
const { urlFilter: plexSearch } = useStore(
|
||||
({ plexSearch: plexQuery }) => plexQuery,
|
||||
@@ -64,8 +63,11 @@ export default function SelectedProgrammingActions({
|
||||
setSelectAllLoading(true);
|
||||
directPlexSearchFn()
|
||||
.then((response) => {
|
||||
addKnownMediaForServer(selectedServer.name, response.Metadata ?? []);
|
||||
addPlexSelectedMedia(selectedServer.name, response.Metadata);
|
||||
addKnownMediaForPlexServer(
|
||||
selectedServer.id,
|
||||
response.Metadata ?? [],
|
||||
);
|
||||
addPlexSelectedMedia(selectedServer, response.Metadata);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Error while attempting to select all Plex items', e);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user