diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d76f3391 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing to Tunarr + +## Setting up the dev environment + +## Coding Standard + +## diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cd81c54..1ca16dfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,14 +100,14 @@ importers: specifier: ^1.0.1 version: 1.0.1 '@mikro-orm/better-sqlite': - specifier: ^6.2.0 - version: 6.2.0(@mikro-orm/core@6.2.0) + specifier: ^6.3.3 + version: 6.3.3(@mikro-orm/core@6.3.3) '@mikro-orm/core': - specifier: ^6.2.0 - version: 6.2.0 + specifier: ^6.3.3 + version: 6.3.3 '@mikro-orm/migrations': - specifier: 6.2.0 - version: 6.2.0(@mikro-orm/core@6.2.0)(@types/node@20.11.1)(better-sqlite3@9.4.5) + specifier: 6.3.3 + version: 6.3.3(@mikro-orm/core@6.3.3)(@types/node@20.11.1)(better-sqlite3@9.4.5) '@tunarr/shared': specifier: workspace:* version: link:../shared @@ -209,11 +209,11 @@ importers: version: 3.22.4 devDependencies: '@mikro-orm/cli': - specifier: ^6.2.0 - version: 6.2.0(better-sqlite3@9.4.5) + specifier: ^6.3.3 + version: 6.3.3(better-sqlite3@9.4.5) '@mikro-orm/reflection': - specifier: ^6.2.0 - version: 6.2.0(@mikro-orm/core@6.2.0) + specifier: ^6.3.3 + version: 6.3.3(@mikro-orm/core@6.3.3) '@types/archiver': specifier: ^6.0.2 version: 6.0.2 @@ -601,6 +601,9 @@ importers: vite: specifier: ^5.4.1 version: 5.4.1(@types/node@20.11.1) + vite-plugin-svgr: + specifier: ^4.2.0 + version: 4.2.0(typescript@5.4.3)(vite@5.4.1) vitest: specifier: ^2.0.5 version: 2.0.5(@types/node@20.11.1) @@ -2571,18 +2574,20 @@ packages: resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==} dev: true - /@mikro-orm/better-sqlite@6.2.0(@mikro-orm/core@6.2.0): - resolution: {integrity: sha512-ixSZ9QRQJo408YSZFmnTMEm3jsmQRRDHEcq6lnz2YwYC/RJh8t5XObhHP4dvV7vdP9l9Y9M+AJ2UDZrRoRz24g==} + /@mikro-orm/better-sqlite@6.3.3(@mikro-orm/core@6.3.3): + resolution: {integrity: sha512-W0wyijGR8o9DvT+vVOOVH67OA94d8PT7goA4JF3Ty7Pcw2RkWUFi6/8kbKiGEI3SWU64IA5VHdFsx21vNEyi5g==} engines: {node: '>= 18.12.0'} peerDependencies: '@mikro-orm/core': ^6.0.0 dependencies: - '@mikro-orm/core': 6.2.0 - '@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5) + '@mikro-orm/core': 6.3.3 + '@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5) better-sqlite3: 9.4.5 fs-extra: 11.2.0 sqlstring-sqlite: 0.1.1 transitivePeerDependencies: + - libsql + - mariadb - mysql - mysql2 - pg @@ -2592,19 +2597,21 @@ packages: - tedious dev: false - /@mikro-orm/cli@6.2.0(better-sqlite3@9.4.5): - resolution: {integrity: sha512-R3JwXOdCT0YOMjoPckxW45izIYhA/9+WvFNXlzA5p58lUzQ3eGgITaPPTuZkf0/j7t1uwGM5fq7UD2tpGorz8Q==} + /@mikro-orm/cli@6.3.3(better-sqlite3@9.4.5): + resolution: {integrity: sha512-TbGuPDBt1V98c1g1hwjg+FizqdaGQ0kCAn8CccfO0nfvVYs0lBaLtRgt3w5jDot7eli7bfQ+DxfZsXRFUnmMyA==} engines: {node: '>= 18.12.0'} hasBin: true dependencies: '@jercle/yargonaut': 1.1.5 - '@mikro-orm/core': 6.2.0 - '@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5) + '@mikro-orm/core': 6.3.3 + '@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5) fs-extra: 11.2.0 tsconfig-paths: 4.2.0 yargs: 17.7.2 transitivePeerDependencies: - better-sqlite3 + - libsql + - mariadb - mysql - mysql2 - pg @@ -2614,8 +2621,8 @@ packages: - tedious dev: true - /@mikro-orm/core@6.2.0: - resolution: {integrity: sha512-Kqsb3S+ab1dbo2joVFLQtdCPTyqSXqiRUnVoMOgAV5uS35nC15SUwDpQMivjLi+8Auz+vcjlbJNK5PrsV+lROQ==} + /@mikro-orm/core@6.3.3: + resolution: {integrity: sha512-P4kqaRIKDmgmtfn1RfUYPCdjkWRsHKEo41gi5G81Ia5Jb7toQ6O3T6GOeBckGt/rZfUNMGDPMeyYqAR93NVV5w==} engines: {node: '>= 18.12.0'} dependencies: dataloader: 2.2.2 @@ -2623,21 +2630,31 @@ packages: esprima: 4.0.1 fs-extra: 11.2.0 globby: 11.1.0 - mikro-orm: 6.2.0 + mikro-orm: 6.3.3 reflect-metadata: 0.2.2 - /@mikro-orm/knex@6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5): - resolution: {integrity: sha512-jo/KKtIkwqCrfBmU0TLe4Y6kCQIbyio8PcvLtphBiMoLca6Utd8cEednsLYW5OdNyzTF3Vw/XHkm7T68MiT/cQ==} + /@mikro-orm/knex@6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5): + resolution: {integrity: sha512-eG3SW4GFQKA7SVj5VCkQQyxlQMC/ZUKHWhFwSDgbyF0sYTAV/78Ir0XX1m69nxK1zD+KJXA+4nf0g5HAoWQxUQ==} engines: {node: '>= 18.12.0'} peerDependencies: '@mikro-orm/core': ^6.0.0 + better-sqlite3: 9.4.5 + libsql: '*' + mariadb: '*' + peerDependenciesMeta: + better-sqlite3: + optional: true + libsql: + optional: true + mariadb: + optional: true dependencies: - '@mikro-orm/core': 6.2.0 + '@mikro-orm/core': 6.3.3 + better-sqlite3: 9.4.5 fs-extra: 11.2.0 knex: 3.1.0(better-sqlite3@9.4.5) sqlstring: 2.3.3 transitivePeerDependencies: - - better-sqlite3 - mysql - mysql2 - pg @@ -2646,19 +2663,21 @@ packages: - supports-color - tedious - /@mikro-orm/migrations@6.2.0(@mikro-orm/core@6.2.0)(@types/node@20.11.1)(better-sqlite3@9.4.5): - resolution: {integrity: sha512-9Nl46QdHBto0fWXdpdfF8rqqG416uc7csA+wi0H+qvY3m4cjiCWYQcsRcmI7FkMbpJq/USkmcDS5yUSU8Sic9Q==} + /@mikro-orm/migrations@6.3.3(@mikro-orm/core@6.3.3)(@types/node@20.11.1)(better-sqlite3@9.4.5): + resolution: {integrity: sha512-wdUG1+zrofNetlsxS+qJfM+hvDuuqblElt/BgZEkPWy41B/IxPRKU+hiSUqznjD69BrqNoLrj7sIdacZjFjIBg==} engines: {node: '>= 18.12.0'} peerDependencies: '@mikro-orm/core': ^6.0.0 dependencies: - '@mikro-orm/core': 6.2.0 - '@mikro-orm/knex': 6.2.0(@mikro-orm/core@6.2.0)(better-sqlite3@9.4.5) + '@mikro-orm/core': 6.3.3 + '@mikro-orm/knex': 6.3.3(@mikro-orm/core@6.3.3)(better-sqlite3@9.4.5) fs-extra: 11.2.0 - umzug: 3.8.0(@types/node@20.11.1) + umzug: 3.8.1(@types/node@20.11.1) transitivePeerDependencies: - '@types/node' - better-sqlite3 + - libsql + - mariadb - mysql - mysql2 - pg @@ -2668,15 +2687,15 @@ packages: - tedious dev: false - /@mikro-orm/reflection@6.2.0(@mikro-orm/core@6.2.0): - resolution: {integrity: sha512-KkepzpY/u/67wfR59990EQvvDNF2HoWeI3vusS7xEHfHLa/hzwjGcj5SG7MRhdYBvjG626FlsXaYFCIwBB9S+g==} + /@mikro-orm/reflection@6.3.3(@mikro-orm/core@6.3.3): + resolution: {integrity: sha512-tNwKb1EDco7Wb1BuVrUkGPRNnGNfeJPWe/y9aFmf/77qORPonzXTQQLcW4qzq0nLsoId5VXe4G9mmlhWbP3dEQ==} engines: {node: '>= 18.12.0'} peerDependencies: '@mikro-orm/core': ^6.0.0 dependencies: - '@mikro-orm/core': 6.2.0 + '@mikro-orm/core': 6.3.3 globby: 11.1.0 - ts-morph: 22.0.0 + ts-morph: 23.0.0 dev: true /@mui/base@5.0.0-beta.23(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0): @@ -2983,6 +3002,20 @@ packages: resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==} dev: false + /@rollup/pluginutils@5.1.0: + resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.5 + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + /@rollup/rollup-android-arm-eabi@4.20.0: resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==} cpu: [arm] @@ -3274,6 +3307,132 @@ packages: engines: {node: '>=14.16'} dev: true + /@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.24.7): + resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.24.7): + resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + dev: true + + /@svgr/babel-preset@8.1.0(@babel/core@7.24.7): + resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.7 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.24.7) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.24.7) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.24.7) + dev: true + + /@svgr/core@8.1.0(typescript@5.4.3): + resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} + engines: {node: '>=14'} + dependencies: + '@babel/core': 7.24.7 + '@svgr/babel-preset': 8.1.0(@babel/core@7.24.7) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@5.4.3) + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@svgr/hast-util-to-babel-ast@8.0.0: + resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==} + engines: {node: '>=14'} + dependencies: + '@babel/types': 7.24.7 + entities: 4.5.0 + dev: true + + /@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0): + resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + dependencies: + '@babel/core': 7.24.7 + '@svgr/babel-preset': 8.1.0(@babel/core@7.24.7) + '@svgr/core': 8.1.0(typescript@5.4.3) + '@svgr/hast-util-to-babel-ast': 8.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + dev: true + /@swc/core-darwin-arm64@1.3.96: resolution: {integrity: sha512-8hzgXYVd85hfPh6mJ9yrG26rhgzCmcLO0h1TIl8U31hwmTbfZLzRitFQ/kqMJNbIBCwmNH1RU2QcJnL3d7f69A==} engines: {node: '>=10'} @@ -3562,11 +3721,11 @@ packages: resolution: {integrity: sha512-vd2A2TnM5lbnWZnHi9B+L2gPtkSeOtJOAw358JqokIH1+v2J7vUAzFVPwB/wrye12RFOurffXu33plm4uQ+JBQ==} dev: false - /@ts-morph/common@0.23.0: - resolution: {integrity: sha512-m7Lllj9n/S6sOkCkRftpM7L24uvmfXQFedlW/4hENcuJH1HHm9u5EgxZb9uVjQSCGrbBWBkOGgcTxNg36r6ywA==} + /@ts-morph/common@0.24.0: + resolution: {integrity: sha512-c1xMmNHWpNselmpIqursHeOHHBTIsJLbB+NuovbTTRCNiTLEr/U9dbJ8qy0jd/O2x5pc3seWuOUN5R2IoOTp8A==} dependencies: fast-glob: 3.3.2 - minimatch: 9.0.3 + minimatch: 9.0.5 mkdirp: 3.0.1 path-browserify: 1.0.1 dev: true @@ -5434,6 +5593,11 @@ packages: engines: {node: '>=6'} dev: true + /camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + dev: true + /caniuse-lite@1.0.30001570: resolution: {integrity: sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw==} dev: true @@ -5864,6 +6028,22 @@ packages: yaml: 1.10.2 dev: false + /cosmiconfig@8.3.6(typescript@5.4.3): + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + typescript: 5.4.3 + dev: true + /cosmiconfig@9.0.0(typescript@5.4.3): resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -6338,6 +6518,13 @@ packages: engines: {node: '>=0.4', npm: '>=1.2'} dev: true + /dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + dev: true + /dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -6470,6 +6657,11 @@ packages: strip-ansi: 6.0.1 dev: true + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: true + /env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -6889,6 +7081,10 @@ packages: engines: {node: '>=4.0'} dev: true + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + /estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} dependencies: @@ -8920,6 +9116,12 @@ packages: steno: 4.0.2 dev: false + /lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + dependencies: + tslib: 2.6.2 + dev: true + /lowercase-keys@1.0.0: resolution: {integrity: sha512-RPlX0+PHuvxVDZ7xX+EBVAp4RsVxP/TdDSN2mJYdiq1Lc4Hz7EUSjUI7RZrKKlmrIzVhf6Jo2stj7++gVarS0A==} engines: {node: '>=0.10.0'} @@ -9089,8 +9291,8 @@ packages: braces: 3.0.2 picomatch: 2.3.1 - /mikro-orm@6.2.0: - resolution: {integrity: sha512-/cjVKrpjtIG1bUm5BvumO6DRo0lkH52C4Waw5qzUqKrg8wl/rigXKYLoLVBPPi7bB0XNwyQbxeh5bRcWJD+y4Q==} + /mikro-orm@6.3.3: + resolution: {integrity: sha512-Hpm/LdpU8c0jNSJNnp6hJ+zwB13Db/fDCni95SJvuR3H4tFtixTrvxPRtz8iIWdLHXt/7kVdbBZdOLKj249r5A==} engines: {node: '>= 18.12.0'} /miller-rabin@4.0.1: @@ -9178,6 +9380,13 @@ packages: dependencies: brace-expansion: 2.0.1 + /minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist-options@3.0.2: resolution: {integrity: sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ==} engines: {node: '>= 4'} @@ -9342,6 +9551,13 @@ packages: - webpack-dev-server dev: true + /no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + dependencies: + lower-case: 2.0.2 + tslib: 2.6.2 + dev: true + /node-abi@3.51.0: resolution: {integrity: sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA==} engines: {node: '>=10'} @@ -11149,6 +11365,13 @@ packages: is-fullwidth-code-point: 5.0.0 dev: true + /snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.6.2 + dev: true + /sonic-boom@3.7.0: resolution: {integrity: sha512-IudtNvSqA/ObjN97tfgNmOKyDOs4dNcg4cUUsHDebqsgb8wGBBwb31LIgShNO8fye0dFI52X1+tFoKKI6Rq1Gg==} dependencies: @@ -11591,6 +11814,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + /svg-parser@2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + dev: true + /syntax-error@1.4.0: resolution: {integrity: sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==} dependencies: @@ -11960,10 +12187,10 @@ packages: webpack: 5.91.0(esbuild@0.21.5)(webpack-cli@5.1.4) dev: true - /ts-morph@22.0.0: - resolution: {integrity: sha512-M9MqFGZREyeb5fTl6gNHKZLqBQA0TjA1lea+CR48R8EBTDuWrNqW6ccC5QvjNR4s6wDumD3LTCjOFSp9iwlzaw==} + /ts-morph@23.0.0: + resolution: {integrity: sha512-FcvFx7a9E8TUe6T3ShihXJLiJOiqyafzFKUO4aqIHDUCIvADdGNShcbc2W5PMr3LerXRv7mafvFZ9lRENxJmug==} dependencies: - '@ts-morph/common': 0.23.0 + '@ts-morph/common': 0.24.0 code-block-writer: 13.0.1 dev: true @@ -12338,8 +12565,8 @@ packages: hasBin: true dev: true - /umzug@3.8.0(@types/node@20.11.1): - resolution: {integrity: sha512-FRBvdZxllW3eUzsqG3CIfgOVChUONrKNZozNOJfvmcfBn5pMKcJjICuMMEsDLHYa/aqd7a2NtXfYEG86XHe1lQ==} + /umzug@3.8.1(@types/node@20.11.1): + resolution: {integrity: sha512-k0HjOc3b/s8vH24BUTvnaFiKhfWI9UQAGpqHDG+3866CGlBTB83Xs5wZ1io1mwYLj/GHvQ34AxKhbpYnWtkRJg==} engines: {node: '>=12'} dependencies: '@rushstack/ts-command-line': 4.19.1(@types/node@20.11.1) @@ -12533,6 +12760,21 @@ packages: - terser dev: true + /vite-plugin-svgr@4.2.0(typescript@5.4.3)(vite@5.4.1): + resolution: {integrity: sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==} + peerDependencies: + vite: ^2.6.0 || 3 || 4 || 5 + dependencies: + '@rollup/pluginutils': 5.1.0 + '@svgr/core': 8.1.0(typescript@5.4.3) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0) + vite: 5.4.1(@types/node@20.11.1) + transitivePeerDependencies: + - rollup + - supports-color + - typescript + dev: true + /vite@5.0.11(@types/node@20.11.1): resolution: {integrity: sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==} engines: {node: ^18.0.0 || >=20.0.0} diff --git a/server/mikro-orm.base.config.ts b/server/mikro-orm.base.config.ts index 4a27b24d..d28c84f4 100644 --- a/server/mikro-orm.base.config.ts +++ b/server/mikro-orm.base.config.ts @@ -12,7 +12,7 @@ import { CustomShow } from './src/dao/entities/CustomShow.js'; import { CustomShowContent } from './src/dao/entities/CustomShowContent.js'; import { FillerListContent } from './src/dao/entities/FillerListContent.js'; import { FillerShow } from './src/dao/entities/FillerShow.js'; -import { PlexServerSettings } from './src/dao/entities/PlexServerSettings.js'; +import { MediaSource } from './src/dao/entities/MediaSource.js'; import { Program } from './src/dao/entities/Program.js'; import { DATABASE_LOCATION_ENV_VAR } from './src/util/constants.js'; import { Migration20240124115044 } from './src/migrations/Migration20240124115044.js'; @@ -29,6 +29,8 @@ import { Migration20240603204620 } from './src/migrations/Migration2024060320462 import { Migration20240603204638 } from './src/migrations/Migration20240603204638.js'; import { Migration20240618005544 } from './src/migrations/Migration20240618005544.js'; import { LoggerFactory } from './src/util/logging/LoggerFactory.js'; +import { Migration20240719145409 } from './src/migrations/Migration20240719145409.js'; +import { Migration20240805185042 } from './src/migrations/Migration20240805185042.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -49,7 +51,7 @@ export default defineConfig({ CustomShowContent, FillerListContent, FillerShow, - PlexServerSettings, + MediaSource, Program, ], flushMode: 'commit', @@ -127,6 +129,14 @@ export default defineConfig({ name: 'Force regenerate program_external_id table', class: Migration20240618005544, }, + { + name: 'rename_plex_server_settings_table', + class: Migration20240719145409, + }, + { + name: 'add_jellyfin_sources', + class: Migration20240805185042, + }, ], }, extensions: [Migrator], diff --git a/server/package.json b/server/package.json index 089f4f16..686a0609 100644 --- a/server/package.json +++ b/server/package.json @@ -36,9 +36,9 @@ "@fastify/swagger": "^8.12.1", "@fastify/swagger-ui": "^4.0.0", "@iptv/xmltv": "^1.0.1", - "@mikro-orm/better-sqlite": "^6.2.0", - "@mikro-orm/core": "^6.2.0", - "@mikro-orm/migrations": "6.2.0", + "@mikro-orm/better-sqlite": "^6.3.3", + "@mikro-orm/core": "^6.3.3", + "@mikro-orm/migrations": "6.3.3", "@tunarr/shared": "workspace:*", "@tunarr/types": "workspace:*", "archiver": "^7.0.1", @@ -74,8 +74,8 @@ "zod": "^3.22.4" }, "devDependencies": { - "@mikro-orm/cli": "^6.2.0", - "@mikro-orm/reflection": "^6.2.0", + "@mikro-orm/cli": "^6.3.3", + "@mikro-orm/reflection": "^6.3.3", "@types/archiver": "^6.0.2", "@types/async-retry": "^1.4.8", "@types/better-sqlite3": "^7.6.8", diff --git a/server/src/api/debug/debugJellyfinApi.ts b/server/src/api/debug/debugJellyfinApi.ts new file mode 100644 index 00000000..c6aef2ea --- /dev/null +++ b/server/src/api/debug/debugJellyfinApi.ts @@ -0,0 +1,65 @@ +import { isNil } from 'lodash-es'; +import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient'; +import { RouterPluginAsyncCallback } from '../../types/serverType'; +import { z } from 'zod'; +import { Nilable } from '../../types/util'; + +export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async ( + fastify, + // eslint-disable-next-line @typescript-eslint/require-await +) => { + fastify.get( + '/jellyfin/libraries', + { + schema: { + querystring: z.object({ + userId: z.string(), + uri: z.string().url(), + apiKey: z.string(), + }), + }, + }, + async (req, res) => { + const client = new JellyfinApiClient({ + url: req.query.uri, + apiKey: req.query.apiKey, + }); + + await res.send(await client.getUserLibraries(req.query.userId)); + }, + ); + + fastify.get( + '/jellyfin/library/items', + { + schema: { + querystring: z + .object({ + uri: z.string().url(), + parentId: z.string().nullable().optional(), + offset: z.coerce.number().nonnegative().optional(), + limit: z.coerce.number().positive().optional(), + apiKey: z.string(), + }) + .refine(({ offset, limit }) => { + return isNil(offset) === isNil(limit); + }, 'offset/limit must either both be defined, or neither'), + }, + }, + async (req, res) => { + const client = new JellyfinApiClient({ + url: req.query.uri, + apiKey: req.query.apiKey, + }); + + let pageParams: Nilable<{ offset: number; limit: number }> = null; + if (!isNil(req.query.limit) && !isNil(req.query.offset)) { + pageParams = { offset: req.query.offset, limit: req.query.limit }; + } + + await res.send( + await client.getItems(null, req.query.parentId, [], [], pageParams), + ); + }, + ); +}; diff --git a/server/src/api/debugApi.ts b/server/src/api/debugApi.ts index c4948c05..df5b42e7 100644 --- a/server/src/api/debugApi.ts +++ b/server/src/api/debugApi.ts @@ -4,28 +4,29 @@ import { ChannelLineupQuery } from '@tunarr/types/api'; import { ChannelLineupSchema } from '@tunarr/types/schemas'; import dayjs from 'dayjs'; import { FastifyRequest } from 'fastify'; -import { compact, first, isNil, isUndefined } from 'lodash-es'; +import { compact, isNil, reject, some, map } from 'lodash-es'; import os from 'node:os'; import z from 'zod'; import { ArchiveDatabaseBackup } from '../dao/backup/ArchiveDatabaseBackup.js'; import { getEm } from '../dao/dataSource.js'; -import { - StreamLineupItem, - isContentBackedLineupIteam, -} from '../dao/derived_types/StreamLineup.js'; +import { StreamLineupItem } from '../dao/derived_types/StreamLineup.js'; import { Channel } from '../dao/entities/Channel.js'; import { LineupCreator } from '../services/dynamic_channels/LineupCreator.js'; import { PlayerContext } from '../stream/Player.js'; import { generateChannelContext } from '../stream/StreamProgramCalculator.js'; import { PlexPlayer } from '../stream/plex/PlexPlayer.js'; -import { PlexTranscoder } from '../stream/plex/PlexTranscoder.js'; import { StreamContextChannel } from '../stream/types.js'; -import { SavePlexProgramExternalIdsTask } from '../tasks/SavePlexProgramExternalIdsTask.js'; +import { SavePlexProgramExternalIdsTask } from '../tasks/plex/SavePlexProgramExternalIdsTask.js'; import { PlexTaskQueue } from '../tasks/TaskQueue.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { Maybe } from '../types/util.js'; import { ifDefined, mapAsyncSeq } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; +import { DebugJellyfinApiRouter } from './debug/debugJellyfinApi.js'; +import { jsonArrayFrom } from 'kysely/helpers/sqlite'; +import { directDbAccess } from '../dao/direct/directDbAccess.js'; +import { MediaSourceType } from '../dao/entities/MediaSource.js'; +import { enumValues } from '../util/enumUtil.js'; const ChannelQuerySchema = { querystring: z.object({ @@ -33,10 +34,13 @@ const ChannelQuerySchema = { }), }; -// eslint-disable-next-line @typescript-eslint/require-await export const debugApi: RouterPluginAsyncCallback = async (fastify) => { const logger = LoggerFactory.child({ caller: import.meta }); + await fastify.register(DebugJellyfinApiRouter, { + prefix: '/debug', + }); + fastify.get( '/debug/plex', { schema: ChannelQuerySchema }, @@ -86,67 +90,6 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => { }, ); - fastify.get( - '/debug/plex-transcoder/video-stats', - { schema: ChannelQuerySchema }, - async (req, res) => { - const channel = await req.serverCtx.channelDB.getChannelAndProgramsSLOW( - req.query.channelId, - ); - - if (!channel) { - return res.status(404).send('No channel found'); - } - - const lineupItem = await getLineupItemForDebug( - req, - channel, - new Date().getTime(), - ); - - if (isUndefined(lineupItem)) { - return res - .status(500) - .send('Couldnt get a lineup item for this channel'); - } - - if (!isContentBackedLineupIteam(lineupItem)) { - return res - .status(500) - .send( - `Needed lineup item of type commercial or program, but got "${lineupItem.type}"`, - ); - } - - // TODO use plex server from item. - const plexServer = await req.serverCtx.plexServerDB.getAll().then(first); - - if (isNil(plexServer)) { - return res.status(404).send('Could not find plex server'); - } - - const plexSettings = req.serverCtx.settings.plexSettings(); - - const combinedChannel: StreamContextChannel = { - ...generateChannelContext(channel), - transcoding: channel?.transcoding, - }; - - const transcoder = new PlexTranscoder( - `debug-${new Date().getTime()}`, - plexServer, - plexSettings, - combinedChannel, - lineupItem, - ); - - transcoder.setTranscodingArgs(false, true, false, false); - await transcoder.getDecision(false); - - return res.send(transcoder.getVideoStats()); - }, - ); - async function getLineupItemForDebug( req: FastifyRequest, channel: Loaded, @@ -403,17 +346,54 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => { return res.send(); }); - fastify.get('/debug/db/test_direct_access', async (_req, res) => { - // const result = await directDbAccess() - // .selectFrom('channel_programs') - // .where('channel_uuid', '=', '0ff3ec64-1022-4afd-9178-3f27f1121d47') - // .innerJoin('program', 'channel_programs.program_uuid', 'program.uuid') - // .leftJoin('program_grouping', join => { - // join.onRef('') - // }) - // .select(['program']) - // .execute(); - // return res.send(result); - return res.send(); - }); + fastify.get( + '/debug/db/test_direct_access', + { + schema: { + querystring: z.object({ + id: z.string(), + }), + }, + }, + async (_req, res) => { + const mediaSource = (await _req.serverCtx.mediaSourceDB.getById( + _req.query.id, + ))!; + + const knownProgramIds = await directDbAccess() + .selectFrom('programExternalId as p1') + .where(({ eb, and }) => + and([ + eb('p1.externalSourceId', '=', mediaSource.name), + eb('p1.sourceType', '=', mediaSource.type), + ]), + ) + .selectAll('p1') + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('programExternalId as p2') + .whereRef('p2.programUuid', '=', 'p1.programUuid') + .whereRef('p2.uuid', '!=', 'p1.uuid') + .select([ + 'p2.sourceType', + 'p2.externalSourceId', + 'p2.externalKey', + ]), + ).as('otherExternalIds'), + ) + .groupBy('p1.uuid') + .execute(); + + const mediaSourceTypes = map(enumValues(MediaSourceType), (typ) => + typ.toString(), + ); + const danglingPrograms = reject(knownProgramIds, (program) => { + some(program.otherExternalIds, (eid) => + mediaSourceTypes.includes(eid.sourceType), + ); + }); + return res.send(danglingPrograms); + }, + ); }; diff --git a/server/src/api/index.ts b/server/src/api/index.ts index e4f2670f..96146a06 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -4,28 +4,14 @@ import { isError, isNil } from 'lodash-es'; import path from 'path'; import { pipeline } from 'stream/promises'; import { z } from 'zod'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; -import { Plex } from '../external/plex.js'; +import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js'; +import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js'; import { FFMPEGInfo } from '../ffmpeg/ffmpegInfo.js'; import { serverOptions } from '../globals.js'; import { GlobalScheduler } from '../services/scheduler.js'; import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { fileExists } from '../util/fsUtil.js'; -import { LoggerFactory } from '../util/logging/LoggerFactory.js'; -import { channelsApi } from './channelsApi.js'; -import { customShowsApiV2 } from './customShowsApi.js'; -import { debugApi } from './debugApi.js'; -import { fillerListsApi } from './fillerListsApi.js'; -import { metadataApiRouter } from './metadataApi.js'; -import { programmingApi } from './programmingApi.js'; -import { tasksApiRouter } from './tasksApi.js'; -import { ffmpegSettingsRouter } from './ffmpegSettingsApi.js'; -import { guideRouter } from './guideApi.js'; -import { hdhrSettingsRouter } from './hdhrSettingsApi.js'; -import { plexServersRouter } from './plexServersApi.js'; -import { plexSettingsRouter } from './plexSettingsApi.js'; -import { xmlTvSettingsRouter } from './xmltvSettingsApi.js'; import { isEdgeBuild, isNonEmptyString, @@ -33,7 +19,22 @@ import { run, tunarrBuild, } from '../util/index.js'; +import { LoggerFactory } from '../util/logging/LoggerFactory.js'; +import { channelsApi } from './channelsApi.js'; +import { customShowsApiV2 } from './customShowsApi.js'; +import { debugApi } from './debugApi.js'; +import { ffmpegSettingsRouter } from './ffmpegSettingsApi.js'; +import { fillerListsApi } from './fillerListsApi.js'; +import { guideRouter } from './guideApi.js'; +import { hdhrSettingsRouter } from './hdhrSettingsApi.js'; +import { jellyfinApiRouter } from './jellyfinApi.js'; +import { mediaSourceRouter } from './mediaSourceApi.js'; +import { metadataApiRouter } from './metadataApi.js'; +import { plexSettingsRouter } from './plexSettingsApi.js'; +import { programmingApi } from './programmingApi.js'; import { systemSettingsRouter } from './systemSettingsApi.js'; +import { tasksApiRouter } from './tasksApi.js'; +import { xmlTvSettingsRouter } from './xmltvSettingsApi.js'; import { getTunarrVersion } from '../util/version.js'; export const apiRouter: RouterPluginAsyncCallback = async (fastify) => { @@ -56,13 +57,14 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => { .register(programmingApi) .register(debugApi) .register(metadataApiRouter) - .register(plexServersRouter) + .register(mediaSourceRouter) .register(ffmpegSettingsRouter) .register(plexSettingsRouter) .register(xmlTvSettingsRouter) .register(hdhrSettingsRouter) .register(systemSettingsRouter) - .register(guideRouter); + .register(guideRouter) + .register(jellyfinApiRouter); fastify.get( '/version', @@ -214,21 +216,21 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => { '/plex', { schema: { - querystring: z.object({ name: z.string(), path: z.string() }), + querystring: z.object({ id: z.string(), path: z.string() }), }, }, async (req, res) => { const server = await req.entityManager - .repo(PlexServerSettings) - .findOne({ name: req.query.name }); + .repo(MediaSource) + .findOne({ uuid: req.query.id, type: MediaSourceType.Plex }); if (isNil(server)) { return res .status(404) - .send({ error: 'No server found with name: ' + req.query.name }); + .send({ error: 'No server found with id: ' + req.query.id }); } - const plex = new Plex(server); - return res.send(await plex.doGet(req.query.path)); + const plex = MediaSourceApiFactory().get(server); + return res.send(await plex.doGetPath(req.query.path)); }, ); }; diff --git a/server/src/api/jellyfinApi.ts b/server/src/api/jellyfinApi.ts new file mode 100644 index 00000000..d2fe2f9c --- /dev/null +++ b/server/src/api/jellyfinApi.ts @@ -0,0 +1,149 @@ +import { JellyfinLoginRequest } from '@tunarr/types/api'; +import { + RouterPluginCallback, + ZodFastifyRequest, +} from '../types/serverType.js'; +import { JellyfinApiClient } from '../external/jellyfin/JellyfinApiClient.js'; +import { isDefined, nullToUndefined } from '../util/index.js'; +import { z } from 'zod'; +import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js'; +import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js'; +import { isNull } from 'lodash-es'; +import { isQueryError } from '../external/BaseApiClient.js'; +import { + JellyfinItemKind, + JellyfinLibraryItemsResponse, +} from '@tunarr/types/jellyfin'; +import { FastifyReply } from 'fastify/types/reply.js'; + +const mediaSourceParams = z.object({ + mediaSourceId: z.string(), +}); + +export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => { + fastify.post( + '/jellyfin/login', + { + schema: { + body: JellyfinLoginRequest, + }, + }, + async (req, res) => { + const response = await JellyfinApiClient.login( + { url: req.body.url, name: 'Unknown' }, + req.body.username, + req.body.password, + ); + + return res.send({ accessToken: nullToUndefined(response.AccessToken) }); + }, + ); + + fastify.get( + '/jellyfin/:mediaSourceId/user_libraries', + { + schema: { + params: mediaSourceParams, + // querystring: z.object({}) + }, + }, + (req, res) => + withJellyfinMediaSource(req, res, async (mediaSource) => { + const api = MediaSourceApiFactory().getJellyfinClient({ + ...mediaSource, + url: mediaSource.uri, + apiKey: mediaSource.accessToken, + }); + + const response = await api.getUserViews(); + + if (isQueryError(response)) { + throw response; + } + + return res.send(response.data); + }), + ); + + fastify.get( + '/jellyfin/:mediaSourceId/libraries/:libraryId/items', + { + schema: { + params: mediaSourceParams.extend({ + libraryId: z.string(), + }), + querystring: z.object({ + offset: z.coerce.number().nonnegative().optional(), + limit: z.coerce.number().positive().optional(), + itemTypes: z + .string() + .optional() + .transform((s) => s?.split(',')) + .pipe(JellyfinItemKind.array().optional()), + }), + response: { + 200: JellyfinLibraryItemsResponse, + }, + }, + }, + (req, res) => + withJellyfinMediaSource(req, res, async (mediaSource) => { + console.log(req.query.itemTypes); + const api = MediaSourceApiFactory().getJellyfinClient({ + ...mediaSource, + url: mediaSource.uri, + apiKey: mediaSource.accessToken, + }); + + const pageParams = + isDefined(req.query.offset) && isDefined(req.query.limit) + ? { offset: req.query.offset, limit: req.query.limit } + : null; + const response = await api.getItems( + null, + req.params.libraryId, + req.query.itemTypes ?? [], + ['ChildCount', 'RecursiveItemCount'], + pageParams, + ); + + if (isQueryError(response)) { + throw response; + } + + return res.send(response.data); + }), + ); + + async function withJellyfinMediaSource< + Req extends ZodFastifyRequest<{ + params: typeof mediaSourceParams; + }>, + >( + req: Req, + res: FastifyReply, + cb: (m: MediaSource) => Promise, + ) { + const mediaSource = await req.serverCtx.mediaSourceDB.getById( + req.params.mediaSourceId, + ); + + if (isNull(mediaSource)) { + return res + .status(400) + .send(`No media source with ID ${req.params.mediaSourceId} found.`); + } + + if (mediaSource.type !== MediaSourceType.Jellyfin) { + return res + .status(400) + .send( + `Media source with ID = ${req.params.mediaSourceId} is not a Jellyfin server.`, + ); + } + + return cb(mediaSource); + } + + done(); +}; diff --git a/server/src/api/plexServersApi.ts b/server/src/api/mediaSourceApi.ts similarity index 61% rename from server/src/api/plexServersApi.ts rename to server/src/api/mediaSourceApi.ts index 313f0923..ce1ddf84 100644 --- a/server/src/api/plexServersApi.ts +++ b/server/src/api/mediaSourceApi.ts @@ -1,40 +1,41 @@ import { BaseErrorSchema, BasicIdParamSchema, - InsertPlexServerRequestSchema, - UpdatePlexServerRequestSchema, + InsertMediaSourceRequestSchema, + UpdateMediaSourceRequestSchema, } from '@tunarr/types/api'; -import { PlexServerSettingsSchema } from '@tunarr/types/schemas'; +import { MediaSourceSettingsSchema } from '@tunarr/types/schemas'; import { isError, isNil, isObject } from 'lodash-es'; import z from 'zod'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; -import { Plex } from '../external/plex.js'; -import { PlexApiFactory } from '../external/PlexApiFactory.js'; +import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js'; +import { PlexApiClient } from '../external/plex/PlexApiClient.js'; +import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js'; import { GlobalScheduler } from '../services/scheduler.js'; import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { firstDefined, wait } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; +import { JellyfinApiClient } from '../external/jellyfin/JellyfinApiClient.js'; -export const plexServersRouter: RouterPluginAsyncCallback = async ( +export const mediaSourceRouter: RouterPluginAsyncCallback = async ( fastify, // eslint-disable-next-line @typescript-eslint/require-await ) => { const logger = LoggerFactory.child({ caller: import.meta }); fastify.get( - '/plex-servers', + '/media-sources', { schema: { response: { - 200: z.array(PlexServerSettingsSchema), + 200: z.array(MediaSourceSettingsSchema), 500: z.string(), }, }, }, async (req, res) => { try { - const servers = await req.serverCtx.plexServerDB.getAll(); + const servers = await req.serverCtx.mediaSourceDB.getAll(); const dtos = servers.map((server) => server.toDTO()); return res.send(dtos); } catch (err) { @@ -45,7 +46,7 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( ); fastify.get( - '/plex-servers/:id/status', + '/media-sources/:id/status', { schema: { params: BasicIdParamSchema, @@ -60,27 +61,44 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }, async (req, res) => { try { - const server = await req.serverCtx.plexServerDB.getById(req.params.id); + const server = await req.serverCtx.mediaSourceDB.getById(req.params.id); if (isNil(server)) { return res.status(404).send(); } - const plex = new Plex(server); + let healthyPromise: Promise; + switch (server.type) { + case MediaSourceType.Plex: { + const plex = new PlexApiClient(server); + healthyPromise = plex.checkServerStatus(); + break; + } + case MediaSourceType.Jellyfin: { + const jellyfin = new JellyfinApiClient({ + url: server.uri, + apiKey: server.accessToken, + name: server.name, + }); + healthyPromise = jellyfin + .getSystemInfo() + .then(() => true) + .catch(() => false); + break; + } + } - const s: 1 | -1 = await Promise.race([ - (async () => { - return await plex.checkServerStatus(); - })(), - new Promise<-1>((resolve) => { + const status = await Promise.race([ + healthyPromise, + new Promise((resolve) => { setTimeout(() => { - resolve(-1); + resolve(false); }, 60000); }), ]); return res.send({ - healthy: s === 1, + healthy: status, }); } catch (err) { logger.error(err); @@ -90,13 +108,15 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( ); fastify.post( - '/plex-servers/foreignstatus', + '/media-sources/foreignstatus', { schema: { body: z.object({ name: z.string().optional(), accessToken: z.string(), uri: z.string(), + type: z.union([z.literal('plex'), z.literal('jellyfin')]), + username: z.string().optional(), }), response: { 200: z.object({ @@ -108,36 +128,46 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }, }, async (req, res) => { - try { - const plex = new Plex({ - ...req.body, - name: req.body.name ?? 'unknown', - }); + let healthyPromise: Promise; + switch (req.body.type) { + case 'plex': { + const plex = new PlexApiClient({ + ...req.body, + name: req.body.name ?? 'unknown', + }); - const s: boolean = await Promise.race([ - (async () => { - const res = await plex.checkServerStatus(); - return res === 1; - })(), - new Promise((resolve) => { - setTimeout(() => { - resolve(false); - }, 60000); - }), - ]); + healthyPromise = plex.checkServerStatus(); + break; + } + case 'jellyfin': { + const jellyfin = new JellyfinApiClient({ + url: req.body.uri, + name: req.body.name ?? 'unknown', + apiKey: req.body.accessToken, + }); - return res.send({ - healthy: s, - }); - } catch (err) { - logger.error('%O', err); - return res.status(500).send(); + healthyPromise = jellyfin.ping(); + break; + } } + + const healthy = await Promise.race([ + healthyPromise, + new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }, 60000); + }), + ]); + + return res.send({ + healthy, + }); }, ); fastify.delete( - '/plex-servers/:id', + '/media-sources/:id', { schema: { params: BasicIdParamSchema, @@ -149,15 +179,14 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }, async (req, res) => { try { - const { deletedServer } = await req.serverCtx.plexServerDB.deleteServer( - req.params.id, - ); + const { deletedServer } = + await req.serverCtx.mediaSourceDB.deleteMediaSource(req.params.id); // Are these useful? What do they even do? req.serverCtx.eventService.push({ type: 'settings-update', - message: `Plex server ${deletedServer.name} removed.`, - module: 'plex-server', + message: `Media source ${deletedServer.name} removed.`, + module: 'media-source', detail: { serverId: req.params.id, serverName: deletedServer.name, @@ -180,8 +209,8 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( logger.error('Error %O', err); req.serverCtx.eventService.push({ type: 'settings-update', - message: 'Error deleting plex server.', - module: 'plex-server', + message: 'Error deleting media-source.', + module: 'media-source', detail: { action: 'delete', serverId: req.params.id, @@ -196,11 +225,11 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( ); fastify.put( - '/plex-servers/:id', + '/media-sources/:id', { schema: { params: BasicIdParamSchema, - body: UpdatePlexServerRequestSchema, + body: UpdateMediaSourceRequestSchema, response: { 200: z.void(), 500: z.void(), @@ -209,7 +238,9 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }, async (req, res) => { try { - const report = await req.serverCtx.plexServerDB.updateServer(req.body); + const report = await req.serverCtx.mediaSourceDB.updateMediaSource( + req.body, + ); let modifiedPrograms = 0; let destroyedPrograms = 0; report.forEach((r) => { @@ -220,8 +251,8 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }); req.serverCtx.eventService.push({ type: 'settings-update', - message: `Plex server ${req.body.name} updated. ${modifiedPrograms} programs modified, ${destroyedPrograms} programs deleted`, - module: 'plex-server', + message: `Media source ${req.body.name} updated. ${modifiedPrograms} programs modified, ${destroyedPrograms} programs deleted`, + module: 'media-source', detail: { serverName: req.body.name, action: 'update', @@ -231,11 +262,11 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( return res.status(200).send(); } catch (err) { - logger.error('Could not update plex server. ', err); + logger.error(err, 'Could not update plex server. '); req.serverCtx.eventService.push({ type: 'settings-update', - message: 'Error updating plex server.', - module: 'plex-server', + message: 'Error updating media source.', + module: 'media-source', detail: { action: 'update', serverName: firstDefined(req, 'body', 'name'), @@ -249,10 +280,10 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( ); fastify.post( - '/plex-servers', + '/media-sources', { schema: { - body: InsertPlexServerRequestSchema, + body: InsertMediaSourceRequestSchema, response: { 201: z.object({ id: z.string(), @@ -264,13 +295,13 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }, async (req, res) => { try { - const newServerId = await req.serverCtx.plexServerDB.addServer( + const newServerId = await req.serverCtx.mediaSourceDB.addMediaSource( req.body, ); req.serverCtx.eventService.push({ type: 'settings-update', - message: `Plex server ${req.body.name} added.`, - module: 'plex-server', + message: `Media source "${req.body.name}" added.`, + module: 'media-source', detail: { serverId: newServerId, serverName: req.body.name, @@ -280,19 +311,19 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( }); return res.status(201).send({ id: newServerId }); } catch (err) { - logger.error('Could not add plex server.', err); + logger.error(err, 'Could not add media source'); req.serverCtx.eventService.push({ type: 'settings-update', - message: 'Error adding plex server.', + message: 'Error adding media source.', module: 'plex-server', detail: { action: 'add', - serverName: firstDefined(req, 'body', 'name'), + serverName: req.body.name, error: isError(err) ? firstDefined(err, 'message') : 'unknown', }, level: 'error', }); - return res.status(400).send('Could not add plex server.'); + return res.status(500).send('Could not add media source.'); } }, ); @@ -316,17 +347,17 @@ export const plexServersRouter: RouterPluginAsyncCallback = async ( async (req, res) => { try { const server = await req.entityManager - .repo(PlexServerSettings) + .repo(MediaSource) .findOne({ name: req.query.serverName }); if (isNil(server)) { return res.status(404).send({ message: 'Plex server not found.' }); } - const plex = PlexApiFactory().get(server); + const plex = MediaSourceApiFactory().get(server); const s = await Promise.race([ - plex.checkServerStatus().then((res) => res === 1), + plex.checkServerStatus(), wait(15000).then(() => false), ]); diff --git a/server/src/api/metadataApi.ts b/server/src/api/metadataApi.ts index e554eac9..b4531e9a 100644 --- a/server/src/api/metadataApi.ts +++ b/server/src/api/metadataApi.ts @@ -7,7 +7,7 @@ import { ProgramSourceType, programSourceTypeFromString, } from '../dao/custom_types/ProgramSourceType'; -import { PlexApiFactory } from '../external/PlexApiFactory'; +import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory'; import { RouterPluginAsyncCallback } from '../types/serverType'; const externalIdSchema = z @@ -70,6 +70,11 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => { switch (req.query.id.externalSourceType) { case ProgramSourceType.PLEX: { result = await handlePlexItem(req.query); + break; + } + case ProgramSourceType.JELLYFIN: { + result = await handleJellyfishItem(req.query); + break; } } @@ -113,7 +118,9 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => { ); async function handlePlexItem(query: ExternalMetadataQuery) { - const plexApi = await PlexApiFactory().getOrSet(query.id.externalSourceId); + const plexApi = await MediaSourceApiFactory().getOrSet( + query.id.externalSourceId, + ); if (isNil(plexApi)) { return null; @@ -130,4 +137,26 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => { return null; } + + async function handleJellyfishItem(query: ExternalMetadataQuery) { + const jellyfinClient = await MediaSourceApiFactory().getJellyfinByName( + query.id.externalSourceId, + ); + + if (isNil(jellyfinClient)) { + return null; + } + + if (query.asset === 'thumb') { + return jellyfinClient.getThumbUrl(query.id.externalItemId); + // return jellyfinClient.getThumbUrl({ + // itemKey: query.id.externalItemId, + // width: query.thumbOptions?.width, + // height: query.thumbOptions?.height, + // upscale: '1', + // }); + } + + return null; + } }; diff --git a/server/src/api/programmingApi.ts b/server/src/api/programmingApi.ts index 4d88d0eb..9b315d56 100644 --- a/server/src/api/programmingApi.ts +++ b/server/src/api/programmingApi.ts @@ -17,16 +17,23 @@ import z from 'zod'; import { ProgramSourceType, programSourceTypeFromString, + programSourceTypeToMediaSource, } from '../dao/custom_types/ProgramSourceType.js'; import { getEm } from '../dao/dataSource.js'; import { Program, ProgramType } from '../dao/entities/Program.js'; -import { Plex } from '../external/plex.js'; +import { PlexApiClient } from '../external/plex/PlexApiClient.js'; import { TruthyQueryParam } from '../types/schemas.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { ProgramGrouping } from '../dao/entities/ProgramGrouping.js'; -import { ifDefined } from '../util/index.js'; +import { ifDefined, isNonEmptyString } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; -import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js'; +import { + ProgramExternalIdType, + programExternalIdTypeFromSourceType, + programExternalIdTypeToMediaSourceType, +} from '../dao/custom_types/ProgramExternalIdType.js'; +import { MediaSource } from '../dao/entities/MediaSource.js'; +import { JellyfinApiClient } from '../external/jellyfin/JellyfinApiClient.js'; const LookupExternalProgrammingSchema = z.object({ externalId: z @@ -94,30 +101,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => { return res.status(404).send(); } - const handlePlexItem = async ( - externalKey: string | undefined, - externalSourceId: string, - ) => { - if (isNil(externalKey)) { - return res.status(500).send(); - } - - const server = - await req.serverCtx.plexServerDB.getByExternalid(externalSourceId); - - if (isNil(server)) { - return res.status(404).send(); - } - - const result = Plex.getThumbUrl({ - uri: server.uri, - itemKey: externalKey, - accessToken: server.accessToken, - height: req.query.height, - width: req.query.width, - upscale: req.query.upscale.toString(), - }); - + const handleResult = async (mediaSource: MediaSource, result: string) => { if (req.query.proxy) { try { const proxyRes = await axios.request({ @@ -141,8 +125,9 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => { } catch (e) { if (isAxiosError(e) && e.response?.status === 404) { logger.error( - 'Error retrieving thumb from Plex at url: %s. Status: 404', - result.replaceAll(server.accessToken, 'REDACTED_TOKEN'), + 'Error retrieving thumb from %s at url: %s. Status: 404', + mediaSource.type, + result.replaceAll(mediaSource.accessToken, 'REDACTED_TOKEN'), ); return res.status(404).send(); } @@ -154,37 +139,118 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => { }; if (!isNil(program)) { + const mediaSource = await req.serverCtx.mediaSourceDB.getByExternalId( + programSourceTypeToMediaSource(program.sourceType), + program.externalSourceId, + ); + + if (isNil(mediaSource)) { + return res.status(404).send(); + } + + let keyToUse = program.externalKey; + if (program.type === ProgramType.Track && !isNil(program.album)) { + ifDefined( + find( + program.album.$.externalRefs, + (ref) => + ref.sourceType === + programExternalIdTypeFromSourceType(program.sourceType) && + ref.externalSourceId === program.externalSourceId, + ), + (ref) => { + keyToUse = ref.externalKey; + }, + ); + } + + if (isNil(keyToUse)) { + return res.status(500).send(); + } + switch (program.sourceType) { case ProgramSourceType.PLEX: { - let keyToUse = program.externalKey; - if (program.type === ProgramType.Track && !isNil(program.album)) { - ifDefined( - find( - program.album.$.externalRefs, - (ref) => - ref.sourceType === ProgramExternalIdType.PLEX && - ref.externalSourceId === program.externalSourceId, - ), - (ref) => { - keyToUse = ref.externalKey; - }, - ); - } - return handlePlexItem(keyToUse, program.externalSourceId); + return handleResult( + mediaSource, + PlexApiClient.getThumbUrl({ + uri: mediaSource.uri, + itemKey: keyToUse, + accessToken: mediaSource.accessToken, + height: req.query.height, + width: req.query.width, + upscale: req.query.upscale.toString(), + }), + ); } + case ProgramSourceType.JELLYFIN: + return handleResult( + mediaSource, + JellyfinApiClient.getThumbUrl({ + uri: mediaSource.uri, + itemKey: keyToUse, + accessToken: mediaSource.accessToken, + height: req.query.height, + width: req.query.width, + upscale: req.query.upscale.toString(), + }), + ); default: return res.status(405).send(); } } else { // We can assume that we have a grouping here... // We only support Plex now - const source = find(grouping!.externalRefs, { - sourceType: ProgramExternalIdType.PLEX, - }); + const source = find( + grouping!.externalRefs, + (ref) => + ref.sourceType === ProgramExternalIdType.PLEX || + ref.sourceType === ProgramExternalIdType.JELLYFIN, + ); + if (isNil(source)) { return res.status(500).send(); + } else if (!isNonEmptyString(source.externalSourceId)) { + return res.status(500).send(); + } + + const mediaSource = await req.serverCtx.mediaSourceDB.getByExternalId( + programExternalIdTypeToMediaSourceType(source.sourceType)!, + source.externalSourceId, + ); + + if (isNil(mediaSource)) { + return res.status(404).send(); + } + + switch (source.sourceType) { + case ProgramExternalIdType.PLEX: + return handleResult( + mediaSource, + PlexApiClient.getThumbUrl({ + uri: mediaSource.uri, + itemKey: source.externalSourceId, + accessToken: mediaSource.accessToken, + height: req.query.height, + width: req.query.width, + upscale: req.query.upscale.toString(), + }), + ); + case ProgramExternalIdType.JELLYFIN: + return handleResult( + mediaSource, + JellyfinApiClient.getThumbUrl({ + uri: mediaSource.uri, + itemKey: source.externalSourceId, + accessToken: mediaSource.accessToken, + height: req.query.height, + width: req.query.width, + upscale: req.query.upscale.toString(), + }), + ); + default: + // Impossible + return res.status(500).send(); } - return handlePlexItem(source.externalKey, source.externalSourceId!); } }, ); @@ -206,21 +272,39 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => { }, async (req, res) => { const em = getEm(); - const program = await em.repo(Program).findOne({ uuid: req.params.id }); + const program = await em + .repo(Program) + .findOne({ uuid: req.params.id }, { populate: ['externalIds'] }); if (isNil(program)) { return res.status(404).send(); } - const plexServers = await req.serverCtx.plexServerDB.getAll(); + const mediaSources = await req.serverCtx.mediaSourceDB.getAll(); - switch (program.sourceType) { - case ProgramSourceType.PLEX: { - if (isNil(program.externalKey)) { - return res.status(500).send(); - } + const externalId = program.externalIds.$.find( + (eid) => + eid.sourceType === ProgramExternalIdType.JELLYFIN || + eid.sourceType === ProgramExternalIdType.PLEX, + ); - const server = find(plexServers, { name: program.externalSourceId }); - if (isNil(server) || isNil(server.clientIdentifier)) { + if (!externalId) { + return res.status(404).send(); + } + + const server = find( + mediaSources, + (source) => + source.uuid === externalId.externalSourceId || + source.name === externalId.externalSourceId, + ); + + if (isNil(server)) { + return res.status(404).send(); + } + + switch (externalId.sourceType) { + case ProgramExternalIdType.PLEX: { + if (isNil(server.clientIdentifier)) { return res.status(404).send(); } @@ -234,6 +318,14 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => { return res.send({ url }); } + return res.redirect(302, url).send(); + } + case ProgramExternalIdType.JELLYFIN: { + const url = `${server.uri}/web/#/details?id=${externalId.externalKey}`; + if (!req.query.forward) { + return res.send({ url }); + } + return res.redirect(302, url).send(); } } diff --git a/server/src/dao/LegacyProgramGroupingCalculator.ts b/server/src/dao/LegacyProgramGroupingCalculator.ts deleted file mode 100644 index a6cdd4c8..00000000 --- a/server/src/dao/LegacyProgramGroupingCalculator.ts +++ /dev/null @@ -1,622 +0,0 @@ -import { Loaded, ref } from '@mikro-orm/core'; -import { ContentProgram } from '@tunarr/types'; -import { - PlexEpisode, - PlexLibraryMusic, - PlexLibraryShows, - PlexMusicAlbumView, - PlexMusicTrack, - PlexSeasonView, - isPlexEpisode, - isPlexMusicTrack, -} from '@tunarr/types/plex'; -import * as ld from 'lodash-es'; -import { - chunk, - concat, - find, - flatten, - forEach, - groupBy, - has, - isEmpty, - isNil, - isUndefined, - keys, - map, - mapValues, - reduce, - reject, - round, - values, -} from 'lodash-es'; -import { PlexApiFactory } from '../external/PlexApiFactory.js'; -import { - flipMap, - groupByUniqFunc, - ifDefined, - isNonEmptyString, - mapAsyncSeq, - mapReduceAsyncSeq, -} from '../util/index.js'; -import { LoggerFactory } from '../util/logging/LoggerFactory.js'; -import { timeAsync } from '../util/perf.js'; -import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js'; -import { ProgramSourceType } from './custom_types/ProgramSourceType.js'; -import { getEm } from './dataSource.js'; -import { PlexServerSettings } from './entities/PlexServerSettings.js'; -import { Program, ProgramType } from './entities/Program.js'; -import { - ProgramGrouping, - ProgramGroupingType, -} from './entities/ProgramGrouping.js'; -import { ProgramGroupingExternalId } from './entities/ProgramGroupingExternalId.js'; - -type ProgramsBySource = Record< - NonNullable, - Record ->; - -type GroupingIdAndPlexInfo = { - uuid: string; - externalKey: string; - externalSourceId: string; -}; - -type ProgramGroupingsByType = Record< - ProgramGroupingType, - GroupingIdAndPlexInfo[] ->; - -function typedKeys< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - T extends Record, - KeyType = T extends Record ? K : never, ->(record: T): KeyType[] { - return keys(record) as KeyType[]; -} - -export class LegacyProgramGroupingCalculator { - private logger = LoggerFactory.child({ - className: LegacyProgramGroupingCalculator.name, - }); - - async calculateGroupings( - contentPrograms: ContentProgram[], - upsertedPrograms: Program[], - ) { - const em = getEm(); - - const programsBySource = ld - .chain(contentPrograms) - .filter((p) => p.subtype === 'episode' || p.subtype === 'track') - // TODO figure out a way to shim in a typed groupBy to lodash-es without - // breaking the whole world - .groupBy((cp) => cp.externalSourceType!) - .mapValues((programs) => groupBy(programs, (p) => p.externalSourceName!)) - .value() as ProgramsBySource; - - // This function potentially does a lot of work - // but we don't want to accidentally not do an upsert of a program. - // TODO: Probably want to do this step in the background... - const programGroupingsBySource = await timeAsync( - () => this.findAndUpdateProgramRelations(programsBySource), - { - onSuccess: (ms) => { - this.logger.debug( - 'findAndUpdateProgramRelations took %d (success)', - round(ms, 3), - ); - }, - onFailure: (ms) => { - this.logger.debug( - 'findAndUpdateProgramRelations took %d (failure)', - round(ms, 3), - ); - }, - }, - ); - - forEach(upsertedPrograms, (program) => { - if ( - program.type !== ProgramType.Episode && - program.type !== ProgramType.Track - ) { - return; - } - - const groupings = programGroupingsBySource[program.sourceType]; - if (groupings) { - switch (program.type) { - case ProgramType.Episode: { - if (program.grandparentExternalKey) { - ifDefined( - this.findMatchingGrouping( - groupings, - ProgramGroupingType.TvShow, - program.externalSourceId, - program.grandparentExternalKey, - ), - (show) => { - program.tvShow = ref( - em.getReference(ProgramGrouping, show.uuid), - ); - }, - ); - } - - if (program.parentExternalKey) { - ifDefined( - this.findMatchingGrouping( - groupings, - ProgramGroupingType.TvShowSeason, - program.externalSourceId, - program.parentExternalKey, - ), - (season) => { - program.season = ref( - em.getReference(ProgramGrouping, season.uuid), - ); - }, - ); - } - - break; - } - case ProgramType.Track: { - if (program.grandparentExternalKey) { - ifDefined( - this.findMatchingGrouping( - groupings, - ProgramGroupingType.MusicArtist, - program.externalSourceId, - program.grandparentExternalKey, - ), - (artist) => { - program.artist = ref( - em.getReference(ProgramGrouping, artist.uuid), - ); - }, - ); - } - - if (program.parentExternalKey) { - ifDefined( - this.findMatchingGrouping( - groupings, - ProgramGroupingType.MusicAlbum, - program.externalSourceId, - program.parentExternalKey, - ), - (album) => { - program.album = ref( - em.getReference(ProgramGrouping, album.uuid), - ); - }, - ); - } - break; - } - default: - return; - } - } - }); - } - - findMatchingGrouping( - mappings: ProgramGroupingsByType, - groupType: ProgramGroupingType, - sourceId: string, - externalKeyToMatch: string, - ) { - return find( - mappings[groupType], - (grouping) => - grouping.externalSourceId === sourceId && - grouping.externalKey === externalKeyToMatch, - ); - } - - // Consider making the UI pass this information in to make it potentially - // less error-prone. We could also do this asynchronously, but that's kinda - // mess as well - async findAndUpdateProgramRelations(programsBySource: ProgramsBySource) { - // Plex specific for now... - const ret: Record< - ProgramSourceType, - Record - > = { - plex: makeEmptyGroupMap(), - }; - - for (const source of typedKeys(programsBySource)) { - switch (source) { - case 'plex': - { - const programsByServer = programsBySource[source]; - for (const server of keys(programsByServer)) { - const result = await this.findAndUpdatePlexServerPrograms( - server, - programsByServer[server], - ); - if (result) { - ret[ProgramSourceType.PLEX] = mergeGroupings( - ret[ProgramSourceType.PLEX], - result, - ); - } - } - } - break; - } - } - - return ret; - } - - async findAndUpdatePlexServerPrograms( - plexServerName: string, - programs: ContentProgram[], - ) { - if (programs.length === 0) { - return; - } - - const logger = LoggerFactory.root; - const em = getEm().fork(); - - const plexServer = await em.findOne(PlexServerSettings, { - name: plexServerName, - }); - - if (isNil(plexServer)) { - // Rate limit this potentially noisy log - logger.warn( - 'Could not find server %s when attempting to update hierarchy', - plexServerName, - ); - return; - } - - const plexApi = PlexApiFactory().get(plexServer); - - const parentIdsByGrandparent = ld - .chain(programs) - .map('originalProgram') - .compact() - .map((p) => - (p.type === 'episode' || p.type === 'track') && - isNonEmptyString(p.grandparentRatingKey) && - isNonEmptyString(p.parentRatingKey) - ? ([p.grandparentRatingKey, p.parentRatingKey] as const) - : null, - ) - .compact() - .reduce( - (prev, [grandparent, parent]) => { - const last = prev[grandparent]; - if (last) { - return { ...prev, [grandparent]: last.add(parent) }; - } else { - return { ...prev, [grandparent]: new Set([parent]) }; - } - }, - {} as Record>, - ) - .value(); - - const grandparentsByParentId = flipMap(parentIdsByGrandparent); - - const allIds = ld - .chain(programs) - .map('originalProgram') - .filter( - (p): p is PlexEpisode | PlexMusicTrack => - isPlexEpisode(p) || isPlexMusicTrack(p), - ) - .flatMap((p) => [p.grandparentRatingKey, p.parentRatingKey]) - .uniq() - .compact() - .value(); - - const existingGroupings = flatten( - await mapAsyncSeq( - chunk(allIds, 25), - (idChunk) => { - const ors = map( - idChunk, - (id) => - ({ - sourceType: ProgramExternalIdType.PLEX, - externalKey: id, - externalSourceId: plexServerName, - }) as const, - ); - - return em.find( - ProgramGrouping, - { - externalRefs: { - $or: ors, - }, - }, - { - populate: ['externalRefs'], - fields: ['uuid', 'type'], - }, - ); - }, - { parallelism: 2 }, - ), - ); - - const existingGroupingsByType = reduce( - existingGroupings, - (prev, curr) => { - const existing = prev[curr.type] ?? []; - return { - ...prev, - [curr.type]: [...existing, curr], - }; - }, - makeEmptyGroupMap< - Loaded - >(), - ); - - const existingGroupingsByPlexIdByType = mapValues( - existingGroupingsByType, - (groupings) => - groupByUniqFunc( - groupings, - (eg) => - // This must exist because we just queried on it above - eg.externalRefs.find( - (er) => - er.sourceType === ProgramExternalIdType.PLEX && - er.externalSourceId === plexServerName, - )!.externalKey, - ), - ); - - const existingGroupingsByPlexId = reduce( - values(existingGroupingsByPlexIdByType), - (prev, curr) => ({ ...prev, ...curr }), - {} as Record< - string, - Loaded - >, - ); - - // 1. Accumulate different types of groupings - // 2. Check for dupes - // 3. Inter-relate them (shows<=>seasons, artist<=>album) - // 4. Persist them - // 5. Return mapping of the new or existing IDs to the previous function - // and update the mappings of the programs... - const newGroupings = await mapReduceAsyncSeq( - reject(allIds, (id) => has(existingGroupingsByPlexId, id) || isEmpty(id)), - async (id) => { - const metadata = await plexApi.doGet< - | PlexLibraryShows - | PlexSeasonView - | PlexLibraryMusic - | PlexMusicAlbumView - >(`/library/metadata/${id}`); - if (!isNil(metadata) && !isEmpty(metadata.Metadata)) { - const item = metadata.Metadata[0]; - - let grouping: ProgramGrouping; - const baseFields: Pick< - ProgramGrouping, - 'title' | 'summary' | 'icon' - > = { - title: item.title, - summary: item.summary, - icon: item.thumb, - }; - switch (item.type) { - // TODO Common function to mint a ProgramGrouping - case 'show': - grouping = em.create(ProgramGrouping, { - ...baseFields, - type: ProgramGroupingType.TvShow, - }); - break; - case 'season': - grouping = em.create(ProgramGrouping, { - ...baseFields, - type: ProgramGroupingType.TvShowSeason, - index: item.index, - }); - break; - case 'artist': - grouping = em.create(ProgramGrouping, { - ...baseFields, - type: ProgramGroupingType.MusicArtist, - index: item.index, - }); - break; - case 'album': - grouping = em.create(ProgramGrouping, { - ...baseFields, - type: ProgramGroupingType.MusicAlbum, - index: item.index, - }); - break; - } - - if (isUndefined(grouping)) { - return; - } - - const ref = em.create(ProgramGroupingExternalId, { - externalKey: item.ratingKey, - externalSourceId: plexServerName, - sourceType: ProgramExternalIdType.PLEX, - group: grouping, - }); - - grouping.externalRefs.add(ref); - em.persist([grouping, ref]); - - return grouping; - } - return; - }, - (prev, curr) => { - if (isUndefined(curr)) { - return prev; - } - - return { - ...prev, - [curr.type]: concat(prev[curr.type], curr), - }; - }, - makeEmptyGroupMap(), - { - parallelism: 2, - ms: 50, - }, - ); - - await em.flush(); - - // All new groupings will have exactly one externalRef already initialized - const newGroupingsByPlexIdByType = mapValues( - newGroupings, - (groupingsOfType) => - mapValues( - groupByUniqFunc( - groupingsOfType, - (grouping) => grouping.externalRefs[0].externalKey, - ), - (group) => group.uuid, - ), - ); - - function associateNewGroupings( - parentType: ProgramGroupingType, - childType: ProgramGroupingType, - relation: 'seasons' | 'albums', - ) { - forEach(newGroupings[parentType], (parentGrouping) => { - // New groupings will have exactly one externalKey right now - const plexId = parentGrouping.externalRefs[0].externalKey; - const parentIds = [...(parentIdsByGrandparent[plexId] ?? new Set())]; - const childGroupIds = map( - parentIds, - (id) => - existingGroupingsByPlexIdByType[childType][id]?.uuid ?? - newGroupingsByPlexIdByType[childType][id], - ); - parentGrouping[relation].set( - map(childGroupIds, (id) => em.getReference(ProgramGrouping, id)), - ); - }); - } - - function associateExistingGroupings( - parentType: ProgramGroupingType, - expectedGrandparent: ProgramGroupingType, - relation: 'show' | 'artist', - ) { - forEach(newGroupings[parentType], (grouping) => { - const grandparentId = - grandparentsByParentId[grouping.externalRefs[0].externalKey]; - if (isNonEmptyString(grandparentId)) { - ifDefined(existingGroupingsByPlexId[grandparentId], (gparent) => { - // Extra check just in case - if (gparent.type === expectedGrandparent) { - grouping[relation] = em.getReference( - ProgramGrouping, - gparent.uuid, - ); - } - }); - } - }); - } - - // Associate newly seen shows and artists to their - // season and album counterparts. We should never have a - // situation where we are seeing a show for the first time without - // any associated seasons. The opposite is not true though, we update - // new seasons/albums to their parents below. - associateNewGroupings( - ProgramGroupingType.TvShow, - ProgramGroupingType.TvShowSeason, - 'seasons', - ); - associateNewGroupings( - ProgramGroupingType.MusicArtist, - ProgramGroupingType.MusicAlbum, - 'albums', - ); - - associateExistingGroupings( - ProgramGroupingType.TvShowSeason, - ProgramGroupingType.TvShow, - 'show', - ); - associateExistingGroupings( - ProgramGroupingType.MusicAlbum, - ProgramGroupingType.MusicArtist, - 'artist', - ); - - await em.flush(); - - const finalMap: Record = - makeEmptyGroupMap(); - - forEach(existingGroupings, (grouping) => { - finalMap[grouping.type] = [ - ...finalMap[grouping.type], - { - uuid: grouping.uuid, - externalKey: grouping.externalRefs[0].externalKey, - externalSourceId: plexServerName, - }, - ]; - }); - - forEach(newGroupings, (groups, type) => { - finalMap[type] = [ - ...finalMap[type as ProgramGroupingType], - ...map(groups, (grouping) => ({ - uuid: grouping.uuid, - externalKey: grouping.externalRefs[0].externalKey, - externalSourceId: plexServerName, - })), - ]; - }); - - return finalMap; - } -} - -function makeEmptyGroupMap(): Record { - return { - [ProgramGroupingType.MusicAlbum]: [], - [ProgramGroupingType.MusicArtist]: [], - [ProgramGroupingType.TvShow]: [], - [ProgramGroupingType.TvShowSeason]: [], - }; -} - -function mergeGroupings( - l: Record, - r: Record, -): Record { - return reduce( - r, - (prev, curr, key) => ({ - ...prev, - [key]: [...prev[key as ProgramGroupingType], ...curr], - }), - l, - ); -} diff --git a/server/src/dao/ProgramGroupingCalculator.ts b/server/src/dao/ProgramGroupingCalculator.ts index f3d182e3..b6e6a81c 100644 --- a/server/src/dao/ProgramGroupingCalculator.ts +++ b/server/src/dao/ProgramGroupingCalculator.ts @@ -13,24 +13,31 @@ import { flatten, forEach, isEmpty, + isUndefined, map, partition, } from 'lodash-es'; -import { PlexQueryResult } from '../external/plex.js'; -import { PlexApiFactory } from '../external/PlexApiFactory.js'; +import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js'; import { Maybe } from '../types/util.js'; import { asyncPool } from '../util/asyncPool.js'; -import { groupByUniqAndMap, mapAsyncSeq } from '../util/index.js'; +import { + groupByUniqAndMap, + mapAsyncSeq, + nullToUndefined, +} from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js'; import { getEm } from './dataSource.js'; import { ProgramType } from './entities/Program.js'; import { ProgramGrouping, + programGroupingTypeForJellyfinType, programGroupingTypeForString, } from './entities/ProgramGrouping.js'; import { ProgramGroupingExternalId } from './entities/ProgramGroupingExternalId.js'; import { ProgramDB } from './programDB.js'; +import { QueryResult } from '../external/BaseApiClient.js'; +import { JellyfinItem, isJellyfinType } from '@tunarr/types/jellyfin'; export class ProgramGroupingCalculator { #logger = LoggerFactory.child({ className: ProgramGroupingCalculator.name }); @@ -134,7 +141,7 @@ export class ProgramGroupingCalculator { ); } - const plexApi = await PlexApiFactory().getOrSet(plexServerName); + const plexApi = await MediaSourceApiFactory().getOrSet(plexServerName); if (!plexApi) { return; @@ -245,6 +252,220 @@ export class ProgramGroupingCalculator { await em.flush(); } + async createHierarchyForManyFromJellyfin( + programType: ProgramType, + jellyfinServerName: string, + programIds: { + programId: string; + jellyfinItemId: string; + parentKey: string; + }[], + parentKeys: string[], + grandparentKey: string, + ) { + if ( + programType !== ProgramType.Episode && + programType !== ProgramType.Track + ) { + return; + } + + const parentKeyByProgramId = groupByUniqAndMap( + programIds, + 'programId', + ({ parentKey }) => parentKey, + ); + + const programs = await this.programDB.getProgramsByIds( + map(programIds, 'programId'), + ); + + const em = getEm(); + + const existingParents = flatten( + await mapAsyncSeq( + chunk([...parentKeys], 25), + (chunk) => { + const ors = map( + chunk, + (id) => + ({ + sourceType: ProgramExternalIdType.JELLYFIN, + externalKey: id, + externalSourceId: jellyfinServerName, + }) as const, + ); + + return em.find( + ProgramGrouping, + { + externalRefs: { + $or: ors, + }, + }, + { + populate: ['externalRefs'], + fields: ['uuid', 'type'], + }, + ); + }, + { parallelism: 2 }, + ), + ); + + const grandparent = await em.findOne( + ProgramGrouping, + { + externalRefs: { + sourceType: ProgramExternalIdType.JELLYFIN, + externalKey: grandparentKey, + externalSourceId: jellyfinServerName, + }, + }, + { + populate: [ + 'externalRefs', + 'seasons:ref', + 'showEpisodes:ref', + 'albums:ref', + 'albumTracks:ref', + ], + fields: ['uuid', 'type'], + }, + ); + + if (isEmpty(programs)) { + return; + } + + const [validPrograms, invalidPrograms] = partition( + programs, + (p) => p.type === programType, + ); + + if (isEmpty(validPrograms)) { + return; + } else if (invalidPrograms.length > 0) { + this.#logger.debug( + "Found %d programs that don't have the correct type: %O", + invalidPrograms.length, + invalidPrograms, + ); + } + + const jellyfinApi = + await MediaSourceApiFactory().getJellyfinByName(jellyfinServerName); + + if (!jellyfinApi) { + return; + } + + const maybeGrandparentAndRef = this.handleJellyfinGrouping( + grandparent, + await jellyfinApi.getItem(grandparentKey), + jellyfinServerName, + ); + let newOrUpdatedGrandparent: Maybe; + if (maybeGrandparentAndRef) { + newOrUpdatedGrandparent = maybeGrandparentAndRef[0]; + } + + const newOrUpdatedParents: ProgramGrouping[] = []; + const parentGroupingsByRef: Record = {}; + // TODO Jellyfin supports getting items by ID in batch + for await (const jellyfinResult of asyncPool( + parentKeys, + (key) => jellyfinApi.getItem(key), + { concurrency: 2 }, + )) { + if (jellyfinResult.type === 'error') { + this.#logger.error( + jellyfinResult.error, + 'Error querying Plex for item: %s', + jellyfinResult.input, + ); + continue; + } + + const existing = find(existingParents, (parent) => + parent.externalRefs.exists( + (ref) => + ref.externalKey === jellyfinResult.input && + ref.sourceType === ProgramExternalIdType.JELLYFIN && + ref.externalSourceId === jellyfinServerName, + ), + ); + const daos = this.handleJellyfinGrouping( + existing ?? null, + jellyfinResult.result, + jellyfinServerName, + ); + if (daos) { + const [grouping, ref] = daos; + newOrUpdatedParents.push(grouping); + parentGroupingsByRef[ref.toExternalIdString()] = grouping.uuid; + } + } + + const entities = [ + ...(newOrUpdatedGrandparent ? [newOrUpdatedGrandparent] : []), + ...newOrUpdatedParents, + ]; + + await em.transactional((em) => + em.upsertMany(ProgramGrouping, entities, { + batchSize: 50, + onConflictFields: ['uuid'], + onConflictAction: 'merge', + onConflictExcludeFields: ['uuid'], + }), + ); + + // Create the relations... + forEach(programs, (program) => { + switch (program.type) { + case ProgramType.Movie: + break; + case ProgramType.Episode: { + if (newOrUpdatedGrandparent) { + program.tvShow = ref(newOrUpdatedGrandparent); + } + const parentKey = parentKeyByProgramId[program.uuid]; + if (parentKey) { + const grouping = + parentGroupingsByRef[ + createExternalId('jellyfin', jellyfinServerName, parentKey) + ]; + if (grouping) { + program.season = ref(em.getReference(ProgramGrouping, grouping)); + } + } + break; + } + case ProgramType.Track: { + if (newOrUpdatedGrandparent) { + program.artist = ref(newOrUpdatedGrandparent); + } + const parentKey = parentKeyByProgramId[program.uuid]; + if (parentKey) { + const grouping = + parentGroupingsByRef[ + createExternalId('jellyfin', jellyfinServerName, parentKey) + ]; + if (grouping) { + program.album = ref(em.getReference(ProgramGrouping, grouping)); + } + } + break; + } + default: + break; + } + }); + + await em.flush(); + } + private handlePlexGrouping( existing: Loaded< ProgramGrouping, @@ -252,7 +473,7 @@ export class ProgramGroupingCalculator { 'uuid' | 'type', never > | null, - queryResult: PlexQueryResult, + queryResult: QueryResult, plexServerName: string, ): Maybe<[ProgramGrouping, ProgramGroupingExternalId]> { if (queryResult.type === 'error') { @@ -338,4 +559,94 @@ export class ProgramGroupingCalculator { return [entity, ref] as const; } + + private handleJellyfinGrouping( + existing: Loaded< + ProgramGrouping, + 'externalRefs', + 'uuid' | 'type', + never + > | null, + queryResult: QueryResult>, + jellyfinServerName: string, + ): Maybe<[ProgramGrouping, ProgramGroupingExternalId]> { + if (queryResult.type === 'error') { + this.#logger.error( + 'Error requesting item from Jellyfin: %O %s', + queryResult.code, + queryResult.message ?? '', + ); + return; + } else if (isUndefined(queryResult.data)) { + this.#logger.error('Item not found in Jellyfin'); + } + + const item = queryResult.data!; + if (!isJellyfinType(item, ['Audio', 'Season', 'Series', 'MusicAlbum'])) { + this.#logger.error( + 'Requested Jellyfin item was not a valid grouping type. Got: %s for key %s', + item.Type, + item.Id, + ); + return; + } + + if ( + existing && + existing.type !== programGroupingTypeForJellyfinType(item.Type) + ) { + this.#logger.error( + 'Program grouping type mismatch: %s existing and %s incoming. Logic error', + existing.type, + item.Type, + ); + return; + } + + const baseFields: Pick = { + title: item.Name ?? '', + summary: nullToUndefined(item.Overview), + }; + + const em = getEm(); + const entity = em.create( + ProgramGrouping, + { + ...baseFields, + type: programGroupingTypeForJellyfinType(item.Type)!, + index: item.IndexNumber, + year: item.ProductionYear, + }, + { persist: false }, + ); + + const ref = em.create( + ProgramGroupingExternalId, + { + externalKey: item.Id, + externalSourceId: jellyfinServerName, + sourceType: ProgramExternalIdType.JELLYFIN, + group: entity, + }, + { persist: false }, + ); + + if (existing) { + entity.uuid = existing.uuid; + if ( + !existing.externalRefs.exists( + (er) => + er.externalKey === item.Id && + er.externalSourceId === jellyfinServerName && + er.sourceType === ProgramExternalIdType.JELLYFIN, + ) + ) { + entity.externalRefs.add(ref); + } + } else { + entity.externalRefs.add(ref); + } + + return [entity, ref] as const; + } } diff --git a/server/src/dao/custom_types/ProgramExternalIdType.ts b/server/src/dao/custom_types/ProgramExternalIdType.ts index 575a5c07..b0de6da2 100644 --- a/server/src/dao/custom_types/ProgramExternalIdType.ts +++ b/server/src/dao/custom_types/ProgramExternalIdType.ts @@ -1,5 +1,7 @@ import { ExternalIdType } from '@tunarr/types/schemas'; import { enumKeys } from '../../util/enumUtil.js'; +import { ProgramSourceType } from './ProgramSourceType.js'; +import { MediaSourceType } from '../entities/MediaSource.js'; export enum ProgramExternalIdType { PLEX = 'plex', @@ -7,6 +9,7 @@ export enum ProgramExternalIdType { TMDB = 'tmdb', IMDB = 'imdb', TVDB = 'tvdb', + JELLYFIN = 'jellyfin', } export function programExternalIdTypeFromExternalIdType( @@ -15,6 +18,30 @@ export function programExternalIdTypeFromExternalIdType( return programExternalIdTypeFromString(str)!; } +export function programExternalIdTypeFromSourceType( + src: ProgramSourceType, +): ProgramExternalIdType { + switch (src) { + case ProgramSourceType.PLEX: + return ProgramExternalIdType.PLEX; + case ProgramSourceType.JELLYFIN: + return ProgramExternalIdType.JELLYFIN; + } +} + +export function programExternalIdTypeToMediaSourceType( + src: ProgramExternalIdType, +) { + switch (src) { + case ProgramExternalIdType.PLEX: + return MediaSourceType.Plex; + case ProgramExternalIdType.JELLYFIN: + return MediaSourceType.Jellyfin; + default: + return; + } +} + export function programExternalIdTypeFromString( str: string, ): ProgramExternalIdType | undefined { @@ -26,3 +53,16 @@ export function programExternalIdTypeFromString( } return; } + +export function programExternalIdTypeFromJellyfinProvider(provider: string) { + switch (provider.toLowerCase()) { + case 'tmdb': + return ProgramExternalIdType.TMDB; + case 'imdb': + return ProgramExternalIdType.IMDB; + case 'tvdb': + return ProgramExternalIdType.TVDB; + default: + return null; + } +} diff --git a/server/src/dao/custom_types/ProgramSourceType.ts b/server/src/dao/custom_types/ProgramSourceType.ts index 5c22e1eb..53f06beb 100644 --- a/server/src/dao/custom_types/ProgramSourceType.ts +++ b/server/src/dao/custom_types/ProgramSourceType.ts @@ -1,7 +1,9 @@ import { enumKeys } from '../../util/enumUtil.js'; +import { MediaSourceType } from '../entities/MediaSource.js'; export enum ProgramSourceType { PLEX = 'plex', + JELLYFIN = 'jellyfin', } export function programSourceTypeFromString( @@ -15,3 +17,21 @@ export function programSourceTypeFromString( } return; } + +export function programSourceTypeToMediaSource(src: ProgramSourceType) { + switch (src) { + case ProgramSourceType.PLEX: + return MediaSourceType.Plex; + case ProgramSourceType.JELLYFIN: + return MediaSourceType.Jellyfin; + } +} + +export function programSourceTypeFromMediaSource(src: MediaSourceType) { + switch (src) { + case MediaSourceType.Plex: + return ProgramSourceType.PLEX; + case MediaSourceType.Jellyfin: + return ProgramSourceType.JELLYFIN; + } +} diff --git a/server/src/dao/derived_types/StreamLineup.ts b/server/src/dao/derived_types/StreamLineup.ts index 23458ca7..9b227d0d 100644 --- a/server/src/dao/derived_types/StreamLineup.ts +++ b/server/src/dao/derived_types/StreamLineup.ts @@ -3,6 +3,7 @@ // active streaming session import { z } from 'zod'; +import { MediaSourceType } from '../entities/MediaSource.js'; const baseStreamLineupItemSchema = z.object({ originalTimestamp: z.number().nonnegative().optional(), @@ -63,6 +64,7 @@ const ProgramTypeEnum = z.enum(['movie', 'episode', 'track']); const BaseContentBackedStreamLineupItemSchema = baseStreamLineupItemSchema.extend({ + // ID in the program DB table programId: z.string().uuid(), // These are taken from the Program DB entity plexFilePath: z.string().optional(), @@ -70,6 +72,7 @@ const BaseContentBackedStreamLineupItemSchema = filePath: z.string().optional(), externalKey: z.string(), programType: ProgramTypeEnum, + externalSource: z.nativeEnum(MediaSourceType), }); const CommercialStreamLineupItemSchema = diff --git a/server/src/dao/direct/programQueryHelpers.ts b/server/src/dao/direct/programQueryHelpers.ts index c2109a13..10c887e3 100644 --- a/server/src/dao/direct/programQueryHelpers.ts +++ b/server/src/dao/direct/programQueryHelpers.ts @@ -153,7 +153,7 @@ const defaultProgramJoins: ProgramJoins = { type ProgramFields = readonly `program.${keyof RawProgram}`[]; -const AllProgramFields: ProgramFields = [ +export const AllProgramFields: ProgramFields = [ 'program.albumName', 'program.albumUuid', 'program.artistName', diff --git a/server/src/dao/entities/MediaSource.ts b/server/src/dao/entities/MediaSource.ts new file mode 100644 index 00000000..95f60c76 --- /dev/null +++ b/server/src/dao/entities/MediaSource.ts @@ -0,0 +1,60 @@ +import { Entity, Enum, Property, Unique } from '@mikro-orm/core'; +import { MediaSourceSettings, tag } from '@tunarr/types'; +import { BaseEntity } from './BaseEntity.js'; + +export enum MediaSourceType { + Plex = 'plex', + Jellyfin = 'jellyfin', +} + +@Entity() +@Unique({ properties: ['type', 'name', 'uri'] }) +export class MediaSource extends BaseEntity { + @Enum({ items: () => MediaSourceType, default: MediaSourceType.Plex }) + type!: MediaSourceType; + + @Property() + name!: string; + + @Property() + uri!: string; + + @Property() + accessToken!: string; + + @Property({ default: true }) + sendGuideUpdates!: boolean; + + @Property({ default: true }) + sendChannelUpdates!: boolean; + + @Property() + index: number; + + // Nullable for now! + @Property({ nullable: true }) + clientIdentifier?: string; + + toDTO(): MediaSourceSettings { + return { + id: tag(this.uuid), + name: this.name, + uri: this.uri, + accessToken: this.accessToken, + sendChannelUpdates: this.sendChannelUpdates, + sendGuideUpdates: this.sendGuideUpdates, + index: this.index, + clientIdentifier: this.clientIdentifier, + type: this.type, + }; + } +} + +export function mediaSourceTypeFromApi(f: MediaSourceSettings['type']) { + switch (f) { + case 'plex': + return MediaSourceType.Plex; + case 'jellyfin': + return MediaSourceType.Jellyfin; + } +} diff --git a/server/src/dao/entities/PlexServerSettings.ts b/server/src/dao/entities/PlexServerSettings.ts deleted file mode 100644 index 7b920de0..00000000 --- a/server/src/dao/entities/PlexServerSettings.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Entity, Property, Unique } from '@mikro-orm/core'; -import { PlexServerSettings as PlexServerSettingsDTO } from '@tunarr/types'; -import { BaseEntity } from './BaseEntity.js'; - -@Entity() -@Unique({ properties: ['name', 'uri'] }) -export class PlexServerSettings extends BaseEntity { - @Property() - name!: string; - - @Property() - uri!: string; - - @Property() - accessToken!: string; - - @Property({ default: true }) - sendGuideUpdates!: boolean; - - @Property({ default: true }) - sendChannelUpdates!: boolean; - - @Property() - index: number; - - // Nullable for now! - @Property({ nullable: true }) - clientIdentifier?: string; - - toDTO(): PlexServerSettingsDTO { - return { - id: this.uuid, - name: this.name, - uri: this.uri, - accessToken: this.accessToken, - sendChannelUpdates: this.sendChannelUpdates, - sendGuideUpdates: this.sendGuideUpdates, - index: this.index, - clientIdentifier: this.clientIdentifier, - }; - } -} diff --git a/server/src/dao/entities/Program.ts b/server/src/dao/entities/Program.ts index 19a41c62..2e59caad 100644 --- a/server/src/dao/entities/Program.ts +++ b/server/src/dao/entities/Program.ts @@ -22,6 +22,7 @@ import { CustomShow } from './CustomShow.js'; import { FillerShow } from './FillerShow.js'; import { ProgramExternalId } from './ProgramExternalId.js'; import { ProgramGrouping } from './ProgramGrouping.js'; +import { JellyfinItemKind } from '@tunarr/types/jellyfin'; /** * Program represents a 'playable' entity. A movie, episode, or music track @@ -259,3 +260,17 @@ export function programTypeFromString(str: string): ProgramType | undefined { } return; } + +export function programTypeFromJellyfinType( + kind: JellyfinItemKind, +): ProgramType | undefined { + switch (kind) { + case 'Audio': + return ProgramType.Track; + case 'Episode': + return ProgramType.Episode; + case 'Movie': + return ProgramType.Movie; + } + return; +} diff --git a/server/src/dao/entities/ProgramExternalId.ts b/server/src/dao/entities/ProgramExternalId.ts index 7bfeffb0..390195ad 100644 --- a/server/src/dao/entities/ProgramExternalId.ts +++ b/server/src/dao/entities/ProgramExternalId.ts @@ -9,6 +9,7 @@ import { isNonEmptyString } from '../../util/index.js'; import { ProgramExternalIdType } from '../custom_types/ProgramExternalIdType.js'; import { BaseEntity } from './BaseEntity.js'; import { Program } from './Program.js'; +import { createExternalId } from '@tunarr/shared'; /** * References to external sources for a {@link Program} @@ -30,7 +31,7 @@ import { Program } from './Program.js'; name: 'unique_program_multi_external_id', properties: ['program', 'sourceType', 'externalSourceId'], expression: - 'create unique index `unique_program_multiple_external_id` on `program_external_id` (`program_uuid`, `source_type`) WHERE `external_source_id` IS NOT NULL', + 'create unique index `unique_program_multiple_external_id` on `program_external_id` (`program_uuid`, `source_type`, `external_source_id`) WHERE `external_source_id` IS NOT NULL', }) export class ProgramExternalId extends BaseEntity { @Enum(() => ProgramExternalIdType) @@ -88,9 +89,11 @@ export class ProgramExternalId extends BaseEntity { } toExternalIdString(): string { - return `${this.sourceType.toString()}|${this.externalSourceId ?? ''}|${ - this.externalKey - }`; + return createExternalId( + this.sourceType, + this.externalSourceId ?? '', + this.externalKey, + ); } toKnexInsertData() { diff --git a/server/src/dao/entities/ProgramGrouping.ts b/server/src/dao/entities/ProgramGrouping.ts index abb8a44b..7d118be8 100644 --- a/server/src/dao/entities/ProgramGrouping.ts +++ b/server/src/dao/entities/ProgramGrouping.ts @@ -11,6 +11,7 @@ import { ProgramGroupingExternalId } from './ProgramGroupingExternalId.js'; import { Maybe } from '../../types/util.js'; import { find } from 'lodash-es'; import { enumKeys } from '../../util/enumUtil.js'; +import { JellyfinItemKind } from '@tunarr/types/jellyfin'; /** * A ProgramGrouping represents some logical collection of Programs. @@ -89,3 +90,20 @@ export function programGroupingTypeForString( } return; } + +export function programGroupingTypeForJellyfinType( + t: JellyfinItemKind, +): Maybe { + switch (t) { + case 'Season': + return ProgramGroupingType.TvShowSeason; + case 'Series': + return ProgramGroupingType.TvShow; + case 'MusicArtist': + return ProgramGroupingType.MusicArtist; + case 'MusicAlbum': + return ProgramGroupingType.MusicAlbum; + default: + return; + } +} diff --git a/server/src/dao/fillerDb.ts b/server/src/dao/fillerDB.ts similarity index 100% rename from server/src/dao/fillerDb.ts rename to server/src/dao/fillerDB.ts diff --git a/server/src/dao/legacy_migration/legacyDbMigration.ts b/server/src/dao/legacy_migration/legacyDbMigration.ts index 5e42985d..1e1be852 100644 --- a/server/src/dao/legacy_migration/legacyDbMigration.ts +++ b/server/src/dao/legacy_migration/legacyDbMigration.ts @@ -31,7 +31,7 @@ import { sortBy, } from 'lodash-es'; import path from 'path'; -import { PlexApiFactory } from '../../external/PlexApiFactory.js'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js'; import { globalOptions } from '../../globals.js'; import { serverContext } from '../../serverContext.js'; import { GlobalScheduler } from '../../services/scheduler.js'; @@ -41,7 +41,7 @@ import { attempt } from '../../util/index.js'; import { LoggerFactory } from '../../util/logging/LoggerFactory.js'; import { EntityManager, withDb } from '../dataSource.js'; import { CachedImage } from '../entities/CachedImage.js'; -import { PlexServerSettings as PlexServerSettingsEntity } from '../entities/PlexServerSettings.js'; +import { MediaSource as PlexServerSettingsEntity } from '../entities/MediaSource.js'; import { Settings, SettingsDB, defaultXmlTvSettings } from '../settings.js'; import { LegacyChannelMigrator, @@ -320,9 +320,9 @@ export class LegacyDbMigrator { // will take care of that -- we may want to do it here if we want // to remove the fixer eventually, though. for (const entity of entities) { - const plexApi = PlexApiFactory().get(entity); - const status = await plexApi.checkServerStatus(); - if (status === 1) { + const plexApi = MediaSourceApiFactory().get(entity); + const healthy = await plexApi.checkServerStatus(); + if (healthy) { this.logger.debug( 'Plex server name: %s url: %s healthy', entity.name, diff --git a/server/src/dao/legacy_migration/metadataBackfill.ts b/server/src/dao/legacy_migration/metadataBackfill.ts index 06e40c00..5cc7efae 100644 --- a/server/src/dao/legacy_migration/metadataBackfill.ts +++ b/server/src/dao/legacy_migration/metadataBackfill.ts @@ -12,13 +12,13 @@ import { PlexTvShow, } from '@tunarr/types/plex'; import { first, groupBy, isNil, isNull, isUndefined, keys } from 'lodash-es'; -import { Plex } from '../../external/plex'; -import { PlexApiFactory } from '../../external/PlexApiFactory'; +import { PlexApiClient } from '../../external/plex/PlexApiClient'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory'; import { isNonEmptyString, wait } from '../../util'; import { LoggerFactory } from '../../util/logging/LoggerFactory'; import { ProgramSourceType } from '../custom_types/ProgramSourceType'; import { getEm } from '../dataSource'; -import { PlexServerSettings } from '../entities/PlexServerSettings'; +import { MediaSource } from '../entities/MediaSource'; import { Program, ProgramType } from '../entities/Program'; import { ProgramGrouping, @@ -84,7 +84,7 @@ export class LegacyMetadataBackfiller { programs: Program[], ) { const em = getEm(); - const server = await em.findOne(PlexServerSettings, { name: serverName }); + const server = await em.findOne(MediaSource, { name: serverName }); if (isNil(server)) { this.logger.warn( 'Could not find plex server details for server %s', @@ -163,14 +163,14 @@ export class LegacyMetadataBackfiller { } // Otherwise, we need to go and find details... - const plex = PlexApiFactory().get(server); + const plex = MediaSourceApiFactory().get(server); // This where the types have to diverge, because the Plex // API types differ. if (type === ProgramType.Episode) { // Lookup the episode in Plex - const plexResult = await plex.doGet( + const plexResult = await plex.doGetPath( '/library/metadata/' + externalKey, ); @@ -197,10 +197,10 @@ export class LegacyMetadataBackfiller { // These will be used on subsequent iterations to identify matches // without hitting Plex. if (!isUndefined(show)) { - const seasons = await plex.doGet(show.key); + const seasons = await plex.doGetPath(show.key); if (!isUndefined(seasons?.Metadata)) { for (const season of seasons.Metadata) { - const seasonEpisodes = await plex.doGet( + const seasonEpisodes = await plex.doGetPath( season.key, ); if (!isUndefined(seasonEpisodes?.Metadata)) { @@ -302,7 +302,7 @@ export class LegacyMetadataBackfiller { } } else { // Lookup the episode in Plex - const plexResult = await plex.doGet( + const plexResult = await plex.doGetPath( '/library/metadata/' + externalKey, ); @@ -329,10 +329,10 @@ export class LegacyMetadataBackfiller { // These will be used on subsequent iterations to identify matches // without hitting Plex. if (!isUndefined(artist)) { - const albums = await plex.doGet(artist.key); + const albums = await plex.doGetPath(artist.key); if (!isUndefined(albums?.Metadata)) { for (const album of albums.Metadata) { - const albumTracks = await plex.doGet( + const albumTracks = await plex.doGetPath( album.key, ); if (!isUndefined(albumTracks?.Metadata)) { @@ -443,7 +443,7 @@ export class LegacyMetadataBackfiller { Metadata: InferredMetadataType[]; }, >( - plex: Plex, + plex: PlexApiClient, ratingKey: string, cb: (item: InferredMetadataType) => ProgramGrouping | undefined, ) { @@ -485,8 +485,11 @@ export class LegacyMetadataBackfiller { InferredPlexType extends { Metadata: InferredMetadataType[] } = { Metadata: InferredMetadataType[]; }, - >(plex: Plex, ratingKey: string): Promise { - const plexResult = await plex.doGet( + >( + plex: PlexApiClient, + ratingKey: string, + ): Promise { + const plexResult = await plex.doGetPath( '/library/metadata/' + ratingKey, ); diff --git a/server/src/dao/plexServerDb.ts b/server/src/dao/mediaSourceDB.ts similarity index 60% rename from server/src/dao/plexServerDb.ts rename to server/src/dao/mediaSourceDB.ts index c310c29a..3840b430 100644 --- a/server/src/dao/plexServerDb.ts +++ b/server/src/dao/mediaSourceDB.ts @@ -1,13 +1,20 @@ import { - InsertPlexServerRequest, - UpdatePlexServerRequest, + InsertMediaSourceRequest, + UpdateMediaSourceRequest, } from '@tunarr/types/api'; import ld, { isNil, isUndefined, keys, map, mapValues } from 'lodash-es'; import { groupByUniq } from '../util/index.js'; import { ChannelDB } from './channelDb.js'; -import { ProgramSourceType } from './custom_types/ProgramSourceType.js'; +import { + ProgramSourceType, + programSourceTypeFromMediaSource, +} from './custom_types/ProgramSourceType.js'; import { getEm } from './dataSource.js'; -import { PlexServerSettings as PlexServerSettingsEntity } from './entities/PlexServerSettings.js'; +import { + MediaSource, + MediaSourceType, + mediaSourceTypeFromApi, +} from './entities/MediaSource.js'; import { Program } from './entities/Program.js'; //hmnn this is more of a "PlexServerService"... @@ -22,7 +29,8 @@ type Report = { destroyedPrograms: number; modifiedPrograms: number; }; -export class PlexServerDB { + +export class MediaSourceDB { #channelDb: ChannelDB; constructor(channelDb: ChannelDB) { @@ -31,25 +39,33 @@ export class PlexServerDB { async getAll() { const em = getEm(); - return em.repo(PlexServerSettingsEntity).findAll(); + return em.repo(MediaSource).findAll(); } async getById(id: string) { - return getEm().repo(PlexServerSettingsEntity).findOne({ uuid: id }); + return getEm().repo(MediaSource).findOne({ uuid: id }); } - async getByExternalid(nameOrClientId: string) { + async getByExternalId(sourceType: MediaSourceType, nameOrClientId: string) { return getEm() - .repo(PlexServerSettingsEntity) + .repo(MediaSource) .findOne({ - $or: [{ name: nameOrClientId }, { clientIdentifier: nameOrClientId }], + $and: [ + { + $or: [ + { name: nameOrClientId }, + { clientIdentifier: nameOrClientId }, + ], + }, + { type: sourceType }, + ], }); } - async deleteServer(id: string, removePrograms: boolean = true) { + async deleteMediaSource(id: string, removePrograms: boolean = true) { const deletedServer = await getEm().transactional(async (em) => { - const ref = em.getReference(PlexServerSettingsEntity, id); - const existing = await em.findOneOrFail(PlexServerSettingsEntity, ref, { + const ref = em.getReference(MediaSource, id); + const existing = await em.findOneOrFail(MediaSource, ref, { populate: ['uuid', 'name'], }); em.remove(ref); @@ -60,39 +76,51 @@ export class PlexServerDB { if (!removePrograms) { reports = []; } else { - reports = await this.fixupProgramReferences(deletedServer.name); + reports = await this.fixupProgramReferences( + deletedServer.name, + programSourceTypeFromMediaSource(deletedServer.type), + ); } return { deletedServer, reports }; } - async updateServer(server: UpdatePlexServerRequest) { + async updateMediaSource(server: UpdateMediaSourceRequest) { const em = getEm(); - const repo = em.repo(PlexServerSettingsEntity); + const repo = em.repo(MediaSource); const id = server.id; if (isNil(id)) { throw Error('Missing server id from request'); } - const s = await repo.findOne(id); + const s = await repo.findOne({ uuid: id }); if (isNil(s)) { throw Error("Server doesn't exist."); } + const sendGuideUpdates = + server.type === 'plex' ? server.sendGuideUpdates ?? false : false; + const sendChannelUpdates = + server.type === 'plex' ? server.sendChannelUpdates ?? false : false; + em.assign(s, { name: server.name, uri: server.uri, accessToken: server.accessToken, - sendGuideUpdates: server.sendGuideUpdates ?? false, - sendChannelUpdates: server.sendChannelUpdates ?? false, + sendGuideUpdates, + sendChannelUpdates, updatedAt: new Date(), }); this.normalizeServer(s); - const report = await this.fixupProgramReferences(id, s); + const report = await this.fixupProgramReferences( + id, + programSourceTypeFromMediaSource(s.type), + s, + ); await repo.upsert(s); await em.flush(); @@ -100,45 +128,77 @@ export class PlexServerDB { return report; } - async addServer(server: InsertPlexServerRequest): Promise { + async addMediaSource(server: InsertMediaSourceRequest): Promise { const em = getEm(); - const repo = em.repo(PlexServerSettingsEntity); + const repo = em.repo(MediaSource); const name = isUndefined(server.name) ? 'plex' : server.name; - // let i = 2; - // const prefix = name; - // let resultName = name; - // while (this.doesNameExist(resultName)) { - // resultName = `${prefix}${i}`; - // i += 1; - // } - // name = resultName; - - const sendGuideUpdates = server.sendGuideUpdates ?? false; - const sendChannelUpdates = server.sendChannelUpdates ?? false; + const sendGuideUpdates = + server.type === 'plex' ? server.sendGuideUpdates ?? false : false; + const sendChannelUpdates = + server.type === 'plex' ? server.sendChannelUpdates ?? false : false; const index = await repo.count(); - const newServer = em.create(PlexServerSettingsEntity, { + const newServer = em.create(MediaSource, { ...server, name, sendGuideUpdates, sendChannelUpdates, index, + type: mediaSourceTypeFromApi(server.type), }); this.normalizeServer(newServer); - return await em.insert(PlexServerSettingsEntity, newServer); + return await em.insert(MediaSource, newServer); } + // private async removeDanglingPrograms(mediaSource: MediaSource) { + // const knownProgramIds = await directDbAccess() + // .selectFrom('programExternalId as p1') + // .where(({ eb, and }) => + // and([ + // eb('p1.externalSourceId', '=', mediaSource.name), + // eb('p1.sourceType', '=', mediaSource.type), + // ]), + // ) + // .selectAll('p1') + // .select((eb) => + // jsonArrayFrom( + // eb + // .selectFrom('programExternalId as p2') + // .whereRef('p2.programUuid', '=', 'p1.programUuid') + // .whereRef('p2.uuid', '!=', 'p1.uuid') + // .select(['p2.sourceType', 'p2.externalSourceId', 'p2.externalKey']), + // ).as('otherExternalIds'), + // ) + // .groupBy('p1.uuid') + // .execute(); + + // const mediaSourceTypes = map(enumValues(MediaSourceType), (typ) => + // typ.toString(), + // ); + // const danglingPrograms = reject(knownProgramIds, (program) => { + // some(program.otherExternalIds, (eid) => + // mediaSourceTypes.includes(eid.sourceType), + // ); + // }); + // } + private async fixupProgramReferences( serverName: string, - newServer?: PlexServerSettingsEntity, + serverType: ProgramSourceType, + newServer?: MediaSource, ) { + // TODO: We need to update this to: + // 1. handle different source types + // 2. use program_external_id table + // 3. not delete programs if they still have another reference via + // the external id table (program that exists on 2 servers) const em = getEm(); const allPrograms = await em .repo(Program) .find( - { sourceType: ProgramSourceType.PLEX, externalSourceId: serverName }, + { sourceType: serverType, externalSourceId: serverName }, { populate: ['fillerShows', 'channels', 'customShows'] }, ); @@ -234,7 +294,7 @@ export class PlexServerDB { return [...channelReports, ...fillerReports, ...customShowReports]; } - private fixupProgram(program: Program, newServer: PlexServerSettingsEntity) { + private fixupProgram(program: Program, newServer: MediaSource) { let modified = false; const fixIcon = (icon: string | undefined) => { if ( @@ -261,7 +321,7 @@ export class PlexServerDB { return modified; } - private normalizeServer(server: PlexServerSettingsEntity) { + private normalizeServer(server: MediaSource) { while (server.uri.endsWith('/')) { server.uri = server.uri.slice(0, -1); } diff --git a/server/src/dao/programHelpers.ts b/server/src/dao/programHelpers.ts index 287cb095..8ac51a0d 100644 --- a/server/src/dao/programHelpers.ts +++ b/server/src/dao/programHelpers.ts @@ -1,4 +1,5 @@ import { ref } from '@mikro-orm/core'; +import { createExternalId } from '@tunarr/shared'; import { ChannelProgram, ContentProgram, @@ -6,13 +7,15 @@ import { isContentProgram, isCustomProgram, } from '@tunarr/types'; -import { PlexEpisode, PlexMedia, PlexMusicTrack } from '@tunarr/types/plex'; +import { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex'; import dayjs from 'dayjs'; import ld, { compact, filter, + find, forEach, isNil, + isUndefined, map, partition, reduce, @@ -21,8 +24,8 @@ import ld, { import { performance } from 'perf_hooks'; import { GlobalScheduler } from '../services/scheduler.js'; import { ReconcileProgramDurationsTask } from '../tasks/ReconcileProgramDurationsTask.js'; -import { SavePlexProgramExternalIdsTask } from '../tasks/SavePlexProgramExternalIdsTask.js'; -import { PlexTaskQueue } from '../tasks/TaskQueue.js'; +import { SavePlexProgramExternalIdsTask } from '../tasks/plex/SavePlexProgramExternalIdsTask.js'; +import { JellyfinTaskQueue, PlexTaskQueue } from '../tasks/TaskQueue.js'; import { SavePlexProgramGroupingsTask } from '../tasks/plex/SavePlexProgramGroupingsTask.js'; import { ProgramMinterFactory } from '../util/ProgramMinter.js'; import { groupByUniqFunc, isNonEmptyString } from '../util/index.js'; @@ -30,10 +33,19 @@ import { LoggerFactory } from '../util/logging/LoggerFactory.js'; import { time, timeNamedAsync } from '../util/perf.js'; import { ProgramExternalIdType } from './custom_types/ProgramExternalIdType.js'; import { getEm } from './dataSource.js'; -import { Program, programTypeFromString } from './entities/Program.js'; +import { + Program, + programTypeFromJellyfinType, + programTypeFromString, +} from './entities/Program.js'; import { ProgramExternalId } from './entities/ProgramExternalId.js'; import { upsertProgramExternalIds_deprecated } from './programExternalIdHelpers.js'; -import { createExternalId } from '@tunarr/shared'; +import { ContentProgramOriginalProgram } from '@tunarr/types/schemas'; +import { seq } from '@tunarr/shared/util'; +import { JellyfinItem } from '@tunarr/types/jellyfin'; +import { SaveJellyfinProgramGroupingsTask } from '../tasks/jellyfin/SaveJellyfinProgramGroupingsTask.js'; +import { ProgramSourceType } from './custom_types/ProgramSourceType.js'; +import { SaveJellyfinProgramExternalIdsTask } from '../tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.js'; export async function upsertContentPrograms( programs: ChannelProgram[], @@ -58,7 +70,102 @@ export async function upsertContentPrograms( ) .value(); - // TODO handle custom shows + // This code dedupes incoming programs using their external (IMDB, TMDB, etc) IDs. + // Eventually, it could be used to save source-agnostic programs, but it's unclear + // if that gives us benefit yet. + // const pMap = reduce( + // contentPrograms, + // (acc, program) => { + // const externalIds: { + // type: ProgramExternalIdType; + // id: string; + // program: ContentProgram; + // }[] = []; + // switch (program.originalProgram!.sourceType) { + // case 'plex': { + // const x = ld + // .chain(program.originalProgram!.program.Guid ?? []) + // .map((guid) => parsePlexExternalGuid(guid.id)) + // .thru(removeErrors) + // .map((eid) => ({ + // type: eid.sourceType, + // id: eid.externalKey, + // program, + // })) + // .value(); + // externalIds.push(...x); + // break; + // } + // case 'jellyfin': { + // const p = compact( + // map(program.originalProgram!.program.ProviderIds, (value, key) => { + // const typ = programExternalIdTypeFromString(key.toLowerCase()); + // return isNil(value) || isUndefined(typ) + // ? null + // : { type: typ, id: value, program }; + // }), + // ); + // externalIds.push(...p); + // break; + // } + // } + + // forEach(externalIds, ({ type, id, program }) => { + // if (!isValidSingleExternalIdType(type)) { + // return; + // } + + // const key = createGlobalExternalIdString(type, id); + // const last = acc[key]; + // if (last) { + // acc[key] = { type, id, programs: [...last.programs, program] }; + // } else { + // acc[key] = { type, id, programs: [program] }; + // } + // }); + + // return acc; + // }, + // {} as Record< + // `${string}|${string}`, + // { + // type: ProgramExternalIdType; + // id: string; + // programs: ContentProgram[]; + // } + // >, + // ); + + // const existingPrograms = flatten( + // await mapAsyncSeq(chunk(values(pMap), 500), (items) => { + // return directDbAccess() + // .selectFrom('programExternalId') + // .where(({ or, eb }) => { + // const clauses = map(items, (item) => + // eb('programExternalId.sourceType', '=', item.type).and( + // 'programExternalId.externalKey', + // '=', + // item.id, + // ), + // ); + // return or(clauses); + // }) + // .selectAll('programExternalId') + // .select((eb) => + // jsonArrayFrom( + // eb + // .selectFrom('program') + // .whereRef('programExternalId.programUuid', '=', 'program.uuid') + // .select(AllProgramFields), + // ).as('program'), + // ) + // .groupBy('programExternalId.programUuid') + // .execute(); + // }), + // ); + // console.log('results!!!!', existingPrograms); + + // TODO: handle custom shows const programsToPersist = ld .chain(contentPrograms) .map((p) => { @@ -72,31 +179,6 @@ export async function upsertContentPrograms( }) .value(); - const plexRatingExternalIdToMedia = ld - .chain(programsToPersist) - .reduce( - (acc, { apiProgram, externalIds }) => { - const flattened = filter(externalIds, { - sourceType: ProgramExternalIdType.PLEX, - externalSourceId: apiProgram.externalSourceName!, - }); - - return { - ...acc, - ...reduce( - flattened, - (acc2, eid) => ({ - ...acc2, - [eid.toExternalIdString()]: apiProgram.originalProgram!, - }), - {} as Record, - ), - }; - }, - {} as Record, - ) - .value(); - const programInfoByUniqueId = groupByUniqFunc( programsToPersist, ({ program }) => program.uniqueId(), @@ -125,9 +207,6 @@ export async function upsertContentPrograms( ), ); - // We're dealing specifically with Plex items right now. We want to treat - // _at least_ the rating key / GUID as invariants in the program_external_id - // table for each program. const programExternalIds = ld .chain(upsertedPrograms) .flatMap((program) => { @@ -139,19 +218,113 @@ export async function upsertContentPrograms( }) .value(); - const externalIdsByGrandparentId = ld + const sourceExternalIdToOriginalProgram: Record< + string, + ContentProgramOriginalProgram + > = ld + .chain(programsToPersist) + .reduce((acc, { apiProgram, externalIds }) => { + if (isUndefined(apiProgram.originalProgram)) { + return acc; + } + + const itemId = find( + externalIds, + (eid) => + eid.sourceType === apiProgram.originalProgram!.sourceType && + eid.externalSourceId === apiProgram.externalSourceName!, + ); + + if (!itemId) { + return acc; + } + + return { + ...acc, + [itemId.toExternalIdString()]: apiProgram.originalProgram, + }; + }, {}) + .value(); + + schedulePlexProgramGroupingTasks( + programExternalIds, + sourceExternalIdToOriginalProgram, + ); + + scheduleJellyfinProgramGroupingTasks( + programExternalIds, + sourceExternalIdToOriginalProgram, + ); + + const [requiredExternalIds, backgroundExternalIds] = partition( + programExternalIds, + (p) => + p.sourceType === ProgramExternalIdType.PLEX || + p.sourceType === ProgramExternalIdType.PLEX_GUID || + p.sourceType === ProgramExternalIdType.JELLYFIN, + ); + + // Fail hard on not saving Plex external IDs. We need them for streaming + // TODO: We could optimize further here by only saving IDs necessary for streaming + await timeNamedAsync('upsert external ids', logger, () => + upsertProgramExternalIds_deprecated(requiredExternalIds), + ); + + setImmediate(() => { + upsertProgramExternalIds_deprecated(backgroundExternalIds).catch((e) => { + logger.error( + e, + 'Error saving non-essential external IDs. A fixer will run for these', + ); + }); + }); + + await em.flush(); + + schedulePlexExternalIdsTask(upsertedPrograms); + scheduleJellyfinExternalIdsTask(upsertedPrograms); + + setImmediate(() => { + GlobalScheduler.scheduleOneOffTask( + ReconcileProgramDurationsTask.name, + dayjs().add(500, 'ms'), + new ReconcileProgramDurationsTask(), + ); + PlexTaskQueue.resume(); + JellyfinTaskQueue.resume(); + }); + + const end = performance.now(); + logger.debug( + 'upsertContentPrograms to %d millis. %d upsertedPrograms', + round(end - start, 3), + upsertedPrograms.length, + ); + + return upsertedPrograms; +} + +function schedulePlexProgramGroupingTasks( + programExternalIds: ProgramExternalId[], + sourceExternalIdToOriginalProgram: Record< + string, + ContentProgramOriginalProgram + >, +) { + const plexExternalIdsByGrandparentId = ld .chain(programExternalIds) .map((externalId) => { - const plexMedia = - plexRatingExternalIdToMedia[externalId.toExternalIdString()]; + const media = + sourceExternalIdToOriginalProgram[externalId.toExternalIdString()]; if ( - plexMedia && - (plexMedia.type === 'track' || plexMedia.type === 'episode') + media && + media.sourceType === 'plex' && + (media.program.type === 'track' || media.program.type === 'episode') ) { return { externalId, - plexMedia, + plexMedia: media.program, }; } @@ -180,8 +353,9 @@ export async function upsertContentPrograms( ) .value(); + // TODO Need to implement this for Jellyfin setImmediate(() => { - forEach(externalIdsByGrandparentId, (externalIds, grandparentId) => { + forEach(plexExternalIdsByGrandparentId, (externalIds, grandparentId) => { const parentIds = map( externalIds, (eid) => eid.plexMedia.parentRatingKey, @@ -203,81 +377,162 @@ export async function upsertContentPrograms( ).catch((e) => console.error(e)); }); }); +} - const [requiredExternalIds, backgroundExternalIds] = partition( - programExternalIds, - (p) => - p.sourceType === ProgramExternalIdType.PLEX || - p.sourceType === ProgramExternalIdType.PLEX_GUID, - ); +function scheduleJellyfinProgramGroupingTasks( + programExternalIds: ProgramExternalId[], + sourceExternalIdToOriginalProgram: Record< + string, + ContentProgramOriginalProgram + >, +) { + const externalIdsByGrandparentId = ld + .chain(programExternalIds) + .map((externalId) => { + const media = + sourceExternalIdToOriginalProgram[externalId.toExternalIdString()]; - // Fail hard on not saving Plex external IDs. We need them for streaming - // TODO: We could optimize further here by only saving IDs necessary for streaming - await timeNamedAsync('upsert external ids', logger, () => - upsertProgramExternalIds_deprecated(requiredExternalIds), - ); + if ( + media && + media.sourceType === 'jellyfin' && + (media.program.Type === 'Audio' || media.program.Type === 'Episode') + ) { + return { + externalId, + item: media.program, + }; + } + + return; + }) + .compact() + .reduce( + (acc, { item, externalId }) => { + const grandparentKey = item.SeriesId ?? item.AlbumArtist; + if (isNonEmptyString(grandparentKey)) { + const existing = acc[grandparentKey] ?? []; + return { + ...acc, + [grandparentKey]: [...existing, { externalId, item }], + }; + } + return acc; + }, + {} as Record< + string, + { + externalId: ProgramExternalId; + item: JellyfinItem; + }[] + >, + ) + .value(); + + console.log('SCHEDULING a bunchhhh of things: ', externalIdsByGrandparentId); setImmediate(() => { - upsertProgramExternalIds_deprecated(backgroundExternalIds).catch((e) => { - logger.error( - e, - 'Error saving non-essential external IDs. A fixer will run for these', + forEach(externalIdsByGrandparentId, (externalIds, grandparentId) => { + const parentIds = compact( + map(externalIds, ({ item }) => + item.Type === 'Audio' + ? item.AlbumId + : item.Type === 'Episode' + ? item.SeasonId + : null, + ), ); + + const programAndParentIds = seq.collect(externalIds, (eid) => { + const parentKey = eid.item.AlbumId ?? eid.item.SeasonId; + if (!isNonEmptyString(parentKey)) { + return; + } + + return { + jellyfinItemId: eid.item.Id, + programId: eid.externalId.program.uuid, + parentKey, + }; + }); + + JellyfinTaskQueue.add( + new SaveJellyfinProgramGroupingsTask({ + grandparentKey: grandparentId, + parentKeys: compact(parentIds), + programAndJellyfinIds: programAndParentIds, + programType: programTypeFromJellyfinType(externalIds[0].item.Type)!, + jellyfinServerName: externalIds[0].externalId.externalSourceId!, + }), + ).catch((e) => console.error(e)); }); }); +} - await em.flush(); +function schedulePlexExternalIdsTask(upsertedPrograms: Program[]) { + const logger = LoggerFactory.root; PlexTaskQueue.pause(); const [, pQueueTime] = time(() => { - forEach(upsertedPrograms, (program) => { - try { - const task = new SavePlexProgramExternalIdsTask(program.uuid); - task.logLevel = 'trace'; - PlexTaskQueue.add(task).catch((e) => { - logger.error(e, 'Error saving external IDs for program %s', program); - }); - } catch (e) { - logger.error( - e, - 'Failed to schedule external IDs task for persisted program: %O', - program, - ); - } - }); + forEach( + filter(upsertedPrograms, (p) => p.sourceType === ProgramSourceType.PLEX), + (program) => { + try { + const task = new SavePlexProgramExternalIdsTask(program.uuid); + task.logLevel = 'trace'; + PlexTaskQueue.add(task).catch((e) => { + logger.error( + e, + 'Error saving external IDs for program %s', + program, + ); + }); + } catch (e) { + logger.error( + e, + 'Failed to schedule external IDs task for persisted program: %O', + program, + ); + } + }, + ); }); logger.debug('Took %d ms to schedule tasks', pQueueTime); - - setImmediate(() => { - GlobalScheduler.scheduleOneOffTask( - ReconcileProgramDurationsTask.name, - dayjs().add(500, 'ms'), - new ReconcileProgramDurationsTask(), - ); - PlexTaskQueue.resume(); - }); - - const end = performance.now(); - logger.debug( - 'upsertContentPrograms to %d millis. %d upsertedPrograms', - round(end - start, 3), - upsertedPrograms.length, - ); - - return upsertedPrograms; } -// Creates a unique ID that matches the output of the entity Program#uniqueId -// function. Useful to matching non-persisted API programs with persisted programs -export function contentProgramUniqueId(p: ContentProgram) { - // ID should always be defined in the persistent case - if (p.persisted) { - return p.id!; - } +function scheduleJellyfinExternalIdsTask(upsertedPrograms: Program[]) { + const logger = LoggerFactory.root; - // These should always be defined for the non-persisted case - return `${p.externalSourceType}|${p.externalSourceName}|${p.originalProgram?.key}`; + JellyfinTaskQueue.pause(); + const [, pQueueTime] = time(() => { + forEach( + filter( + upsertedPrograms, + (p) => p.sourceType === ProgramSourceType.JELLYFIN, + ), + (program) => { + try { + const task = new SaveJellyfinProgramExternalIdsTask(program.uuid); + task.logLevel = 'trace'; + JellyfinTaskQueue.add(task).catch((e) => { + logger.error( + e, + 'Error saving external IDs for program %s', + program, + ); + }); + } catch (e) { + logger.error( + e, + 'Failed to schedule external IDs task for persisted program: %O', + program, + ); + } + }, + ); + }); + + logger.debug('Took %d ms to schedule tasks', pQueueTime); } // Takes a listing of programs and makes a mapping of a unique identifier, diff --git a/server/src/external/BaseApiClient.ts b/server/src/external/BaseApiClient.ts new file mode 100644 index 00000000..f790fbf1 --- /dev/null +++ b/server/src/external/BaseApiClient.ts @@ -0,0 +1,216 @@ +import axios, { + AxiosHeaderValue, + AxiosInstance, + AxiosRequestConfig, + isAxiosError, +} from 'axios'; +import { isError, isString } from 'lodash-es'; +import { z } from 'zod'; +import { Maybe, Try } from '../types/util.js'; +import { configureAxiosLogging } from '../util/axios.js'; +import { isDefined } from '../util/index.js'; +import { Logger, LoggerFactory } from '../util/logging/LoggerFactory.js'; + +export type ApiClientOptions = { + name?: string; + url: string; + extraHeaders?: { + [key: string]: AxiosHeaderValue; + }; +}; + +export type RemoteMediaSourceOptions = ApiClientOptions & { + apiKey: string; + type: 'plex' | 'jellyfin'; +}; + +export type QuerySuccessResult = { + type: 'success'; + data: T; +}; + +type QueryErrorCode = + | 'not_found' + | 'no_access_token' + | 'parse_error' + | 'generic_request_error'; + +export type QueryErrorResult = { + type: 'error'; + code: QueryErrorCode; + message?: string; +}; + +export type QueryResult = QuerySuccessResult | QueryErrorResult; + +export function isQueryError(x: QueryResult): x is QueryErrorResult { + return x.type === 'error'; +} + +export function isQuerySuccess( + x: QueryResult, +): x is QuerySuccessResult { + return x.type === 'success'; +} + +export abstract class BaseApiClient< + OptionsType extends ApiClientOptions = ApiClientOptions, +> { + protected logger: Logger; + protected axiosInstance: AxiosInstance; + + constructor(protected options: OptionsType) { + this.logger = LoggerFactory.child({ + className: this.constructor.name, + serverName: options.name, + }); + + const url = options.url.endsWith('/') + ? options.url.slice(0, options.url.length - 1) + : options.url; + + this.axiosInstance = axios.create({ + baseURL: url, + headers: { + Accept: 'application/json', + ...(options.extraHeaders ?? {}), + }, + }); + + configureAxiosLogging(this.axiosInstance, this.logger); + } + + async doTypeCheckedGet>( + path: string, + schema: T, + extraConfig: Partial = {}, + ): Promise> { + const getter = async () => { + const req: AxiosRequestConfig = { + ...extraConfig, + method: 'get', + url: path, + }; + + const response = await this.doRequest(req); + + if (isError(response)) { + if (isAxiosError(response) && response.response?.status === 404) { + return this.makeErrorResult('not_found'); + } + return this.makeErrorResult('generic_request_error', response.message); + } + + const parsed = await schema.safeParseAsync(response); + + if (parsed.success) { + return this.makeSuccessResult(parsed.data as Out); + } + + this.logger.error( + parsed.error, + 'Unable to parse schema from Plex response. Path: %s', + path, + ); + + return this.makeErrorResult('parse_error'); + }; + + // return this.opts.enableRequestCache + // ? await PlexCache.getOrSetPlexResult(this.opts.name, path, getter) + // : await getter(); + return await getter(); + } + + protected preRequestValidate( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _req: AxiosRequestConfig, + ): Maybe { + return; + } + + protected makeErrorResult( + code: QueryErrorCode, + message?: string, + ): QueryErrorResult { + return { + type: 'error', + code, + message, + }; + } + + protected makeSuccessResult(data: T): QuerySuccessResult { + return { + type: 'success', + data, + }; + } + + doGet(req: Omit) { + return this.doRequest({ method: 'get', ...req }); + } + + doPost(req: Omit) { + return this.doRequest({ method: 'post', ...req }); + } + + doPut(req: Omit) { + return this.doRequest({ method: 'put', ...req }); + } + + doHead(req: Omit) { + return this.doRequest({ method: 'head', ...req }); + } + + getFullUrl(path: string): string { + const sanitizedPath = path.startsWith('/') ? path : `/${path}`; + const url = new URL(`${this.options.url}${sanitizedPath}`); + return url.toString(); + } + + protected async doRequest(req: AxiosRequestConfig): Promise> { + try { + const response = await this.axiosInstance.request(req); + return response.data; + } catch (error) { + if (isAxiosError(error)) { + if (error.response?.status === 404) { + this.logger.warn( + `Not found: ${this.axiosInstance.defaults.baseURL}${req.url}`, + ); + } + if (isDefined(error.response)) { + const { status, headers } = error.response; + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + this.logger.warn( + 'Plex response error: status %d, data: %O, headers: %O', + status, + error.response.data, + headers, + ); + } else if (error.request) { + this.logger.error(error, 'Plex request error: %s', error.message); + } else { + this.logger.error(error, 'Error requesting Plex: %s', error.message); + } + return error; + } else if (isError(error)) { + this.logger.error(error); + return error; + } else if (isString(error)) { + // Wrap it + const err = new Error(error); + this.logger.error(err); + return err; + } else { + // At this point we have no idea what the object is... attempt to log + // and just return a generic error. Something is probably fatally wrong + // at this point. + this.logger.error('Unknown error type thrown: %O', error); + return new Error('Unknown error when requesting Plex'); + } + } + } +} diff --git a/server/src/external/MediaSourceApiFactory.ts b/server/src/external/MediaSourceApiFactory.ts new file mode 100644 index 00000000..b07955e2 --- /dev/null +++ b/server/src/external/MediaSourceApiFactory.ts @@ -0,0 +1,144 @@ +import { forEach, isBoolean, isNull, isUndefined } from 'lodash-es'; +import NodeCache from 'node-cache'; +import { getEm } from '../dao/dataSource.js'; +import { MediaSource, MediaSourceType } from '../dao/entities/MediaSource.js'; +import { PlexApiClient, PlexApiOptions } from './plex/PlexApiClient.js'; +import { SettingsDB, getSettings } from '../dao/settings.js'; +import { isDefined } from '../util/index.js'; +import { JellyfinApiClient } from './jellyfin/JellyfinApiClient.js'; +import { FindChild } from '@tunarr/types'; +import { RemoteMediaSourceOptions } from './BaseApiClient.js'; +import { Maybe } from '../types/util.js'; + +type TypeToClient = [ + [MediaSourceType.Plex, PlexApiClient], + [MediaSourceType.Jellyfin, JellyfinApiClient], +]; + +let instance: MediaSourceApiFactoryImpl; + +export class MediaSourceApiFactoryImpl { + #cache: NodeCache; + #requestCacheEnabled: boolean | Record = false; + + constructor(private settings: SettingsDB = getSettings()) { + this.#cache = new NodeCache({ + useClones: false, + deleteOnExpire: true, + checkperiod: 60, + }); + + this.#requestCacheEnabled = + settings.systemSettings().cache?.enablePlexRequestCache ?? false; + + this.settings.addListener('change', () => { + this.#requestCacheEnabled = + settings.systemSettings().cache?.enablePlexRequestCache ?? false; + forEach(this.#cache.data, ({ v: value }, key) => { + if (isDefined(value) && value instanceof PlexApiClient) { + value.setEnableRequestCache(this.requestCacheEnabledForServer(key)); + } + }); + }); + } + + getTyped>( + typ: X, + opts: RemoteMediaSourceOptions, + factory: (opts: RemoteMediaSourceOptions) => ApiClient, + ): ApiClient { + const key = `${typ}|${opts.url}|${opts.apiKey}`; + let client = this.#cache.get(key); + if (!client) { + client = factory(opts); + this.#cache.set(key, client); + } + + return client; + } + + getJellyfinClient(opts: RemoteMediaSourceOptions) { + return this.getTyped( + MediaSourceType.Jellyfin, + opts, + (opts) => new JellyfinApiClient(opts), + ); + } + + async getOrSet(name: string) { + let client = this.#cache.get(name); + if (isUndefined(client)) { + const em = getEm(); + const server = await em.repo(MediaSource).findOne({ name }); + if (!isNull(server)) { + client = new PlexApiClient({ + ...server, + enableRequestCache: this.requestCacheEnabledForServer(server.name), + }); + this.#cache.set(server.name, client); + } + } + return client; + } + + async getTypedByName< + X extends MediaSourceType, + ApiClient = FindChild, + >( + type: X, + name: string, + factory: (opts: RemoteMediaSourceOptions) => ApiClient, + ): Promise> { + const key = `${type}|${name}`; + let client = this.#cache.get(key); + if (isUndefined(client)) { + const em = getEm(); + const server = await em.repo(MediaSource).findOne({ name, type }); + if (!isNull(server)) { + client = factory({ + apiKey: server.accessToken, + url: server.uri, + name: server.name, + type: type, + }); + this.#cache.set(server.name, client); + } + } + return client; + } + + async getJellyfinByName(name: string) { + return this.getTypedByName( + MediaSourceType.Jellyfin, + name, + (opts) => new JellyfinApiClient(opts), + ); + } + + get(opts: PlexApiOptions) { + const key = `${opts.uri}|${opts.accessToken}`; + let client = this.#cache.get(key); + if (!client) { + client = new PlexApiClient({ + ...opts, + enableRequestCache: this.requestCacheEnabledForServer(opts.name), + }); + this.#cache.set(key, client); + } + + return client; + } + + private requestCacheEnabledForServer(id: string) { + return isBoolean(this.#requestCacheEnabled) + ? this.#requestCacheEnabled + : this.#requestCacheEnabled[id]; + } +} + +export const MediaSourceApiFactory = () => { + if (!instance) { + instance = new MediaSourceApiFactoryImpl(); + } + return instance; +}; diff --git a/server/src/external/PlexApiFactory.ts b/server/src/external/PlexApiFactory.ts deleted file mode 100644 index 02302ef0..00000000 --- a/server/src/external/PlexApiFactory.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { forEach, isBoolean, isNull, isUndefined } from 'lodash-es'; -import NodeCache from 'node-cache'; -import { getEm } from '../dao/dataSource.js'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; -import { Plex, PlexApiOptions } from './plex.js'; -import { SettingsDB, getSettings } from '../dao/settings.js'; -import { isDefined } from '../util/index.js'; - -let instance: PlexApiFactoryImpl; - -export class PlexApiFactoryImpl { - #cache: NodeCache; - #requestCacheEnabled: boolean | Record = false; - - constructor(private settings: SettingsDB = getSettings()) { - this.#cache = new NodeCache({ - useClones: false, - deleteOnExpire: true, - checkperiod: 60, - }); - - this.#requestCacheEnabled = - settings.systemSettings().cache?.enablePlexRequestCache ?? false; - - this.settings.addListener('change', () => { - this.#requestCacheEnabled = - settings.systemSettings().cache?.enablePlexRequestCache ?? false; - forEach(this.#cache.data, (data, key) => { - const plex = data.v as Plex; - if (isDefined(plex)) { - plex.setEnableRequestCache(this.requestCacheEnabledForServer(key)); - } - }); - }); - } - - async getOrSet(name: string) { - let client = this.#cache.get(name); - if (isUndefined(client)) { - const em = getEm(); - const server = await em.repo(PlexServerSettings).findOne({ name }); - if (!isNull(server)) { - client = new Plex({ - ...server, - enableRequestCache: this.requestCacheEnabledForServer(server.name), - }); - this.#cache.set(server.name, client); - } - } - return client; - } - - get(opts: PlexApiOptions) { - const key = `${opts.uri}|${opts.accessToken}`; - let client = this.#cache.get(key); - if (!client) { - client = new Plex({ - ...opts, - enableRequestCache: this.requestCacheEnabledForServer(opts.name), - }); - this.#cache.set(key, client); - } - - return client; - } - - private requestCacheEnabledForServer(id: string) { - return isBoolean(this.#requestCacheEnabled) - ? this.#requestCacheEnabled - : this.#requestCacheEnabled[id]; - } -} - -export const PlexApiFactory = () => { - if (!instance) { - instance = new PlexApiFactoryImpl(); - } - return instance; -}; diff --git a/server/src/external/jellyfin/JellyfinApiClient.ts b/server/src/external/jellyfin/JellyfinApiClient.ts new file mode 100644 index 00000000..f2b9ec3a --- /dev/null +++ b/server/src/external/jellyfin/JellyfinApiClient.ts @@ -0,0 +1,191 @@ +import { + JellyfinAuthenticationResult, + JellyfinItem, + JellyfinItemFields, + JellyfinItemKind, + JellyfinLibraryItemsResponse, + JellyfinLibraryResponse, + JellyfinSystemInfo, +} from '@tunarr/types/jellyfin'; +import axios, { AxiosRequestConfig } from 'axios'; +import { first, isEmpty, union } from 'lodash-es'; +import { v4 } from 'uuid'; +import { Maybe, Nilable } from '../../types/util'; +import { LoggerFactory } from '../../util/logging/LoggerFactory'; +import { + BaseApiClient, + QueryErrorResult, + QueryResult, + RemoteMediaSourceOptions, + isQueryError, +} from '../BaseApiClient.js'; +import { getTunarrVersion } from '../../util/version.js'; + +const RequiredLibraryFields = [ + 'Path', + 'Genres', + 'Tags', + 'DateCreated', + 'Etag', + 'Overview', + 'Taglines', + 'Studios', + 'People', + 'OfficialRating', + 'ProviderIds', + 'Chapters', + 'MediaStreams', + 'MediaSources', +]; + +export class JellyfinApiClient extends BaseApiClient< + Omit +> { + constructor(options: Omit) { + super({ + ...options, + extraHeaders: { + ...options.extraHeaders, + Accept: 'application/json', + 'X-Emby-Token': options.apiKey, + }, + }); + } + + static async login( + server: Omit, + username: string, + password: string, + ) { + try { + const response = await axios.post( + `${server.url}/Users/AuthenticateByName`, + { + Username: username, + Pw: password, + }, + { + headers: { + Authorization: `MediaBrowser Client="Tunarr", Device="Web Browser", DeviceId=${v4()}, Version=${getTunarrVersion()}`, + }, + }, + ); + + return await JellyfinAuthenticationResult.parseAsync(response.data); + } catch (e) { + LoggerFactory.root.error(e, 'Error logging into Jellyfin', { + className: JellyfinApiClient.name, + }); + throw e; + } + } + + async ping() { + try { + await this.doGet({ + url: '/System/Ping', + }); + return true; + } catch (e) { + this.logger.error(e); + return false; + } + } + + async getSystemInfo() { + return this.doTypeCheckedGet('/System/Info', JellyfinSystemInfo); + } + + async getUserLibraries(userId?: string) { + return this.doTypeCheckedGet( + '/Library/VirtualFolders', + JellyfinLibraryResponse, + { params: { userId } }, + ); + } + + async getUserViews(userId?: string) { + return this.doTypeCheckedGet('/UserViews', JellyfinLibraryItemsResponse, { + params: { + userId, + includeExternalContent: false, + presetViews: ['movies', 'tvshows', 'music', 'playlists', 'folders'], + }, + }); + } + + async getItem( + itemId: string, + extraFields: JellyfinItemFields[] = [], + ): Promise>> { + const result = await this.getItems( + null, + null, + null, + ['MediaStreams', ...extraFields], + null, + { + ids: itemId, + }, + ); + + if (isQueryError(result)) { + return result; + } + + return this.makeSuccessResult(first(result.data.Items)); + } + + async getItems( + userId: Nilable, // Not required if we are using an access token + libraryId: Nilable, + itemTypes: Nilable = null, + extraFields: JellyfinItemFields[] = [], + pageParams: Nilable<{ offset: number; limit: number }> = null, + extraParams: object = {}, + ): Promise> { + return this.doTypeCheckedGet('/Items', JellyfinLibraryItemsResponse, { + params: { + userId, + parentId: libraryId, + fields: union(extraFields, RequiredLibraryFields).join(','), + startIndex: pageParams?.offset, + limit: pageParams?.limit, + // These will be configurable eventually + sortOrder: 'Ascending', + sortBy: 'SortName,ProductionYear', + recursive: true, + includeItemTypes: itemTypes ? itemTypes.join(',') : undefined, + ...extraParams, + }, + }); + } + + getThumbUrl(id: string) { + // Naive impl for now... + return `${this.options.url}/Items/${id}/Images/Primary`; + } + + static getThumbUrl(opts: { + uri: string; + accessToken: string; + itemKey: string; + width?: number; + height?: number; + upscale?: string; + }): string { + return `${opts.uri}/Items/${opts.itemKey}/Images/Primary`; + } + + protected override preRequestValidate( + req: AxiosRequestConfig, + ): Maybe { + if (isEmpty(this.options.apiKey)) { + return this.makeErrorResult( + 'no_access_token', + 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', + ); + } + return super.preRequestValidate(req); + } +} diff --git a/server/src/external/plex.ts b/server/src/external/plex.ts deleted file mode 100644 index 2a0b2711..00000000 --- a/server/src/external/plex.ts +++ /dev/null @@ -1,595 +0,0 @@ -import { EntityDTO } from '@mikro-orm/core'; -import { DefaultPlexHeaders } from '@tunarr/shared/constants'; -import { - PlexDvr, - PlexDvrsResponse, - PlexGenericMediaContainerResponseSchema, - PlexMedia, - PlexMediaContainerResponseSchema, - PlexResource, -} from '@tunarr/types/plex'; -import axios, { - AxiosInstance, - AxiosRequestConfig, - InternalAxiosRequestConfig, - RawAxiosRequestHeaders, - isAxiosError, -} from 'axios'; -import { XMLParser } from 'fast-xml-parser'; -import { - first, - flatMap, - forEach, - isEmpty, - isError, - isString, - isUndefined, - map, -} from 'lodash-es'; -import NodeCache from 'node-cache'; -import querystring, { ParsedUrlQueryInput } from 'querystring'; -import { MarkOptional } from 'ts-essentials'; -import { z } from 'zod'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; -import { - PlexMediaContainer, - PlexMediaContainerResponse, -} from '../types/plexApiTypes.js'; -import { Maybe, Try } from '../types/util.js'; -import { isDefined, isSuccess } from '../util/index.js'; -import { Logger, LoggerFactory } from '../util/logging/LoggerFactory.js'; - -type AxiosConfigWithMetadata = InternalAxiosRequestConfig & { - metadata: { - startTime: number; - }; -}; - -export type PlexApiOptions = MarkOptional< - Pick< - EntityDTO, - 'accessToken' | 'uri' | 'name' | 'clientIdentifier' - >, - 'clientIdentifier' -> & { - enableRequestCache?: boolean; -}; - -type PlexQuerySuccessResult = { - type: 'success'; - data: T; -}; - -type PlexQueryErrorCode = - | 'not_found' - | 'no_access_token' - | 'parse_error' - | 'generic_request_error'; - -type PlexQueryErrorResult = { - type: 'error'; - code: PlexQueryErrorCode; - message?: string; -}; - -export type PlexQueryResult = - | PlexQuerySuccessResult - | PlexQueryErrorResult; - -export function isPlexQueryError( - x: PlexQueryResult, -): x is PlexQueryErrorResult { - return x.type === 'error'; -} - -export function isPlexQuerySuccess( - x: PlexQueryResult, -): x is PlexQuerySuccessResult { - return x.type === 'success'; -} - -function makeErrorResult( - code: PlexQueryErrorCode, - message?: string, -): PlexQueryErrorResult { - return { - type: 'error', - code, - message, - }; -} - -function makeSuccessResult(data: T): PlexQuerySuccessResult { - return { - type: 'success', - data, - }; -} - -class PlexQueryCache { - #cache: NodeCache; - constructor() { - this.#cache = new NodeCache({ - useClones: false, - deleteOnExpire: true, - checkperiod: 60, - maxKeys: 2500, - stdTTL: 5 * 60 * 1000, - }); - } - - async getOrSet( - serverName: string, - path: string, - getter: () => Promise, - ): Promise { - const key = this.getCacheKey(serverName, path); - const existing = this.#cache.get(key); - if (isDefined(existing)) { - return existing; - } - - const value = await getter(); - this.#cache.set(key, value); - return value; - } - - async getOrSetPlexResult( - serverName: string, - path: string, - getter: () => Promise>, - opts?: { setOnError: boolean }, - ): Promise> { - const key = this.getCacheKey(serverName, path); - const existing = this.#cache.get>(key); - if (isDefined(existing)) { - return existing; - } - - const value = await getter(); - if ( - isPlexQuerySuccess(value) || - (isPlexQueryError(value) && opts?.setOnError) - ) { - this.#cache.set(key, value); - } - - return value; - } - - private getCacheKey(serverName: string, path: string) { - return `${serverName}|${path}`; - } -} - -const PlexCache = new PlexQueryCache(); - -export class Plex { - private logger: Logger; - private opts: PlexApiOptions; - private axiosInstance: AxiosInstance; - private accessToken: string; - - constructor(opts: PlexApiOptions) { - this.opts = opts; - this.accessToken = opts.accessToken; - this.logger = LoggerFactory.child({ caller: import.meta }); - const uri = opts.uri.endsWith('/') - ? opts.uri.slice(0, opts.uri.length - 1) - : opts.uri; - - this.axiosInstance = axios.create({ - baseURL: uri, - headers: { - ...DefaultPlexHeaders, - 'X-Plex-Token': this.accessToken, - }, - }); - - this.axiosInstance.interceptors.request.use((req) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - (req as AxiosConfigWithMetadata).metadata = { - startTime: new Date().getTime(), - }; - return req; - }); - - const logAxiosRequest = (req: AxiosConfigWithMetadata, status: number) => { - const query = req.params - ? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - `?${querystring.stringify(req.params)}` - : ''; - const elapsedTime = new Date().getTime() - req.metadata.startTime; - this.logger.http( - `[Axios Request]: ${req.method?.toUpperCase()} ${req.baseURL}${ - req.url - }${query} - (${status}) ${elapsedTime}ms`, - ); - }; - - this.axiosInstance.interceptors.response.use( - (resp) => { - logAxiosRequest(resp.config as AxiosConfigWithMetadata, resp.status); - return resp; - }, - (err) => { - if (isAxiosError(err) && err.config) { - logAxiosRequest( - err.config as AxiosConfigWithMetadata, - err.status ?? -1, - ); - } - throw err; - }, - ); - } - - get serverName() { - return this.opts.name; - } - - getFullUrl(path: string): string { - const sanitizedPath = path.startsWith('/') ? path : `/${path}`; - const url = new URL(`${this.opts.uri}${sanitizedPath}`); - url.searchParams.set('X-Plex-Token', this.opts.accessToken); - return url.toString(); - } - - private async doRequest(req: AxiosRequestConfig): Promise> { - try { - const response = await this.axiosInstance.request(req); - return response.data; - } catch (error) { - if (isAxiosError(error)) { - if (error.response?.status === 404) { - this.logger.warn( - `Not found: ${this.axiosInstance.defaults.baseURL}${req.url}`, - ); - } - if (!isUndefined(error.response)) { - const { status, headers } = error.response; - // The request was made and the server responded with a status code - // that falls out of the range of 2xx - this.logger.warn( - 'Plex response error: status %d, data: %O, headers: %O', - status, - error.response.data, - headers, - ); - } else if (error.request) { - this.logger.error(error, 'Plex request error: %s', error.message); - } else { - this.logger.error(error, 'Error requesting Plex: %s', error.message); - } - return error; - } else if (isError(error)) { - this.logger.error(error); - return error; - } else if (isString(error)) { - // Wrap it - const err = new Error(error); - this.logger.error(err); - return err; - } else { - // At this point we have no idea what the object is... attempt to log - // and just return a generic error. Something is probably fatally wrong - // at this point. - this.logger.error('Unknown error type thrown: %O', error); - return new Error('Unknown error when requesting Plex'); - } - } - } - - async doHead(path: string, optionalHeaders: RawAxiosRequestHeaders = {}) { - return await this.doRequest({ - method: 'head', - url: path, - headers: optionalHeaders, - }); - } - - // TODO: make all callers use this - async doGetResult( - path: string, - optionalHeaders: RawAxiosRequestHeaders = {}, - skipCache: boolean = false, - ): Promise>> { - const getter = async () => { - const req: AxiosRequestConfig = { - method: 'get', - url: path, - headers: optionalHeaders, - }; - - if (this.accessToken === '') { - throw new Error( - 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', - ); - } - - const res = await this.doRequest>(req); - if (isSuccess(res)) { - if (isUndefined(res?.MediaContainer)) { - this.logger.error(res, 'Expected MediaContainer, got %O', res); - return makeErrorResult('parse_error'); - } - - return makeSuccessResult(res?.MediaContainer); - } - - if (isAxiosError(res) && res.response?.status === 404) { - return makeErrorResult('not_found'); - } - - return makeErrorResult('generic_request_error', res.message); - }; - - return this.opts.enableRequestCache && !skipCache - ? await PlexCache.getOrSetPlexResult(this.opts.name, path, getter) - : await getter(); - } - - // We're just keeping the old contract here right now... - async doGet( - path: string, - optionalHeaders: RawAxiosRequestHeaders = {}, - skipCache: boolean = false, - ): Promise>> { - const result = await this.doGetResult>( - path, - optionalHeaders, - skipCache, - ); - if (isPlexQuerySuccess(result)) { - return result.data; - } else { - return; - } - } - - async doTypeCheckedGet>( - path: string, - schema: T, - extraConfig: Partial = {}, - ): Promise> { - const getter = async () => { - const req: AxiosRequestConfig = { - ...extraConfig, - method: 'get', - url: path, - }; - - if (isEmpty(this.accessToken)) { - return makeErrorResult( - 'no_access_token', - 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', - ); - } - - const response = await this.doRequest(req); - - if (isError(response)) { - if (isAxiosError(response) && response.response?.status === 404) { - return makeErrorResult('not_found'); - } - return makeErrorResult('generic_request_error', response.message); - } - - const parsed = await schema.safeParseAsync(response); - - if (parsed.success) { - return makeSuccessResult(parsed.data as Out); - } - - this.logger.error( - parsed.error, - 'Unable to parse schema from Plex response. Path: %s', - path, - ); - - return makeErrorResult('parse_error'); - }; - - return this.opts.enableRequestCache - ? await PlexCache.getOrSetPlexResult(this.opts.name, path, getter) - : await getter(); - } - - async getItemMetadata(key: string): Promise> { - const parsedResponse = await this.doTypeCheckedGet( - `/library/metadata/${key}`, - PlexMediaContainerResponseSchema, - ); - - if (isPlexQuerySuccess(parsedResponse)) { - const media = first(parsedResponse.data.MediaContainer.Metadata); - if (!isUndefined(media)) { - return makeSuccessResult(media); - } - this.logger.error( - 'Could not extract Metadata object for Plex media, key = %s', - key, - ); - return makeErrorResult('parse_error'); - } - - return parsedResponse; - } - - doPut( - path: string, - query: ParsedUrlQueryInput | URLSearchParams = {}, - optionalHeaders: RawAxiosRequestHeaders = {}, - ) { - const req: AxiosRequestConfig = { - method: 'put', - url: path, - params: query, - headers: optionalHeaders, - }; - - if (this.accessToken === '') { - throw Error( - 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', - ); - } - - return this.doRequest(req); - } - - doPost( - path: string, - query: ParsedUrlQueryInput | URLSearchParams = {}, - optionalHeaders: RawAxiosRequestHeaders = {}, - ) { - const req: AxiosRequestConfig = { - method: 'post', - url: path, - headers: optionalHeaders, - params: query, - }; - - if (this.accessToken === '') { - return makeErrorResult( - 'no_access_token', - 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', - ); - } - - return this.doRequest(req); - } - - async checkServerStatus() { - try { - const result = await this.doTypeCheckedGet( - '/', - PlexGenericMediaContainerResponseSchema, - ); - if (isPlexQueryError(result)) { - throw result; - } else if (isUndefined(result)) { - // Parse error - indicates that the URL is probably not a Plex server - return -1; - } - return 1; - } catch (err) { - this.logger.error(err, 'Error getting Plex server status'); - return -1; - } - } - - async getDvrs() { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await this.doGet('/livetv/dvrs'); - return isUndefined(result?.Dvr) ? [] : result?.Dvr; - } catch (err) { - this.logger.error(err, 'GET /livetv/drs failed'); - throw err; - } - } - - async getResources() {} - - async refreshGuide(_dvrs?: PlexDvr[]) { - const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.getDvrs(); - if (!dvrs) { - throw new Error('Could not retrieve Plex DVRs'); - } - - for (const dvr of dvrs) { - await this.doPost(`/livetv/dvrs/${dvr.key}/reloadGuide`); - } - } - - async refreshChannels(channels: { number: number }[], _dvrs?: PlexDvr[]) { - const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.getDvrs(); - if (!dvrs) throw new Error('Could not retrieve Plex DVRs'); - - const qs: Record = { - channelsEnabled: map(channels, 'number').join(','), - }; - - forEach(channels, ({ number }) => { - qs[`channelMapping[${number}]`] = number; - qs[`channelMappingByKey[${number}]`] = number; - }); - - const keys = map( - flatMap(dvrs, ({ Device }) => Device), - (device) => device.key, - ); - for (const key of keys) { - await this.doPut(`/media/grabbers/devices/${key}/channelmap`, qs); - } - } - - async getDevices(): Promise> { - const response = await this.doRequest({ - method: 'get', - baseURL: 'https://plex.tv', - url: '/devices.xml', - }); - - if (isError(response)) { - this.logger.error(response); - return; - } - - const parsed = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: '', - }).parse(response) as PlexTvDevicesResponse; - return parsed; - } - - getThumbUrl(opts: { - itemKey: string; - width?: number; - height?: number; - upscale?: string; - }) { - return Plex.getThumbUrl({ - uri: this.opts.uri, - accessToken: this.opts.accessToken, - itemKey: opts.itemKey, - width: opts.width, - height: opts.height, - upscale: opts.upscale, - }); - } - - setEnableRequestCache(enable: boolean) { - this.opts.enableRequestCache = enable; - } - - static getThumbUrl(opts: { - uri: string; - accessToken: string; - itemKey: string; - width?: number; - height?: number; - upscale?: string; - }): string { - const { uri, accessToken, itemKey, width, height, upscale } = opts; - const cleanKey = itemKey.replaceAll(/\/library\/metadata\//g, ''); - - let thumbUrl: URL; - const key = `/library/metadata/${cleanKey}/thumb?X-Plex-Token=${accessToken}`; - if (isUndefined(height) || isUndefined(width)) { - thumbUrl = new URL(`${uri}${key}`); - } else { - thumbUrl = new URL(`${uri}/photo/:/transcode`); - thumbUrl.searchParams.append('url', key); - thumbUrl.searchParams.append('X-Plex-Token', accessToken); - thumbUrl.searchParams.append('width', width.toString()); - thumbUrl.searchParams.append('height', height.toString()); - thumbUrl.searchParams.append('upscale', (upscale ?? '1').toString()); - } - return thumbUrl.toString(); - } -} - -type PlexTvDevicesResponse = { - MediaContainer: { Device: PlexResource[] }; -}; diff --git a/server/src/external/plex/PlexApiClient.ts b/server/src/external/plex/PlexApiClient.ts new file mode 100644 index 00000000..9e20ebd9 --- /dev/null +++ b/server/src/external/plex/PlexApiClient.ts @@ -0,0 +1,310 @@ +import { EntityDTO } from '@mikro-orm/core'; +import { + PlexDvr, + PlexDvrsResponse, + PlexGenericMediaContainerResponseSchema, + PlexMedia, + PlexMediaContainerResponseSchema, + PlexResource, +} from '@tunarr/types/plex'; +import { + AxiosRequestConfig, + RawAxiosRequestHeaders, + isAxiosError, +} from 'axios'; +import { XMLParser } from 'fast-xml-parser'; +import { + first, + flatMap, + forEach, + isEmpty, + isError, + isUndefined, + map, +} from 'lodash-es'; +import { MarkOptional } from 'ts-essentials'; +import { MediaSource } from '../../dao/entities/MediaSource.js'; +import { + PlexMediaContainer, + PlexMediaContainerResponse, +} from '../../types/plexApiTypes.js'; +import { Maybe } from '../../types/util.js'; +import { isSuccess } from '../../util/index.js'; +import { + BaseApiClient, + QueryErrorResult, + QueryResult, + isQueryError, + isQuerySuccess, +} from '../BaseApiClient.js'; +import { PlexQueryCache } from './PlexQueryCache.js'; + +export type PlexApiOptions = MarkOptional< + Pick< + EntityDTO, + 'accessToken' | 'uri' | 'name' | 'clientIdentifier' + >, + 'clientIdentifier' +> & { + enableRequestCache?: boolean; +}; + +const PlexCache = new PlexQueryCache(); + +export class PlexApiClient extends BaseApiClient { + private opts: PlexApiOptions; + private accessToken: string; + + constructor(opts: PlexApiOptions) { + super({ + url: opts.uri, + name: opts.name, + extraHeaders: { + 'X-Plex-Token': opts.accessToken, + }, + }); + this.opts = opts; + this.accessToken = opts.accessToken; + } + + get serverName() { + return this.opts.name; + } + + getFullUrl(path: string): string { + const url = super.getFullUrl(path); + const parsed = new URL(url); + parsed.searchParams.set('X-Plex-Token', this.opts.accessToken); + return parsed.toString(); + } + + // TODO: make all callers use this + private async doGetResult( + path: string, + optionalHeaders: RawAxiosRequestHeaders = {}, + skipCache: boolean = false, + ): Promise>> { + const getter = async () => { + const req: AxiosRequestConfig = { + method: 'get', + url: path, + headers: optionalHeaders, + }; + + if (this.accessToken === '') { + throw new Error( + 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', + ); + } + + const res = await this.doRequest>(req); + if (isSuccess(res)) { + if (isUndefined(res?.MediaContainer)) { + this.logger.error(res, 'Expected MediaContainer, got %O', res); + return this.makeErrorResult('parse_error'); + } + + return this.makeSuccessResult(res?.MediaContainer); + } + + if (isAxiosError(res) && res.response?.status === 404) { + return this.makeErrorResult('not_found'); + } + + return this.makeErrorResult('generic_request_error', res.message); + }; + + return this.opts.enableRequestCache && !skipCache + ? await PlexCache.getOrSetPlexResult(this.opts.name, path, getter) + : await getter(); + } + + // We're just keeping the old contract here right now... + async doGetPath( + path: string, + optionalHeaders: RawAxiosRequestHeaders = {}, + skipCache: boolean = false, + ): Promise>> { + const result = await this.doGetResult>( + path, + optionalHeaders, + skipCache, + ); + if (isQuerySuccess(result)) { + return result.data; + } else { + return; + } + } + + async getItemMetadata(key: string): Promise> { + const parsedResponse = await this.doTypeCheckedGet( + `/library/metadata/${key}`, + PlexMediaContainerResponseSchema, + ); + + if (isQuerySuccess(parsedResponse)) { + const media = first(parsedResponse.data.MediaContainer.Metadata); + if (!isUndefined(media)) { + return this.makeSuccessResult(media); + } + this.logger.error( + 'Could not extract Metadata object for Plex media, key = %s', + key, + ); + return this.makeErrorResult('parse_error'); + } + + return parsedResponse; + } + + async checkServerStatus() { + try { + const result = await this.doTypeCheckedGet( + '/', + PlexGenericMediaContainerResponseSchema, + ); + if (isQueryError(result)) { + throw result; + } else if (isUndefined(result)) { + // Parse error - indicates that the URL is probably not a Plex server + return false; + } + return true; + } catch (err) { + this.logger.error(err, 'Error getting Plex server status'); + return false; + } + } + + async getDvrs() { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await this.doGetPath('/livetv/dvrs'); + return isUndefined(result?.Dvr) ? [] : result?.Dvr; + } catch (err) { + this.logger.error(err, 'GET /livetv/drs failed'); + throw err; + } + } + + async getResources() {} + + async refreshGuide(_dvrs?: PlexDvr[]) { + const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.getDvrs(); + if (!dvrs) { + throw new Error('Could not retrieve Plex DVRs'); + } + + for (const dvr of dvrs) { + await this.doPost({ url: `/livetv/dvrs/${dvr.key}/reloadGuide` }); + } + } + + async refreshChannels(channels: { number: number }[], _dvrs?: PlexDvr[]) { + const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.getDvrs(); + if (!dvrs) throw new Error('Could not retrieve Plex DVRs'); + + const qs: Record = { + channelsEnabled: map(channels, 'number').join(','), + }; + + forEach(channels, ({ number }) => { + qs[`channelMapping[${number}]`] = number; + qs[`channelMappingByKey[${number}]`] = number; + }); + + const keys = map( + flatMap(dvrs, ({ Device }) => Device), + (device) => device.key, + ); + + for (const key of keys) { + await this.doPut({ + url: `/media/grabbers/devices/${key}/channelmap`, + params: qs, + }); + } + } + + async getDevices(): Promise> { + const response = await this.doRequest({ + method: 'get', + baseURL: 'https://plex.tv', + url: '/devices.xml', + }); + + if (isError(response)) { + this.logger.error(response); + return; + } + + const parsed = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '', + }).parse(response) as PlexTvDevicesResponse; + return parsed; + } + + getThumbUrl(opts: { + itemKey: string; + width?: number; + height?: number; + upscale?: string; + }) { + return PlexApiClient.getThumbUrl({ + uri: this.opts.uri, + accessToken: this.opts.accessToken, + itemKey: opts.itemKey, + width: opts.width, + height: opts.height, + upscale: opts.upscale, + }); + } + + setEnableRequestCache(enable: boolean) { + this.opts.enableRequestCache = enable; + } + + protected override preRequestValidate( + req: AxiosRequestConfig, + ): Maybe { + if (isEmpty(this.accessToken)) { + return this.makeErrorResult( + 'no_access_token', + 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', + ); + } + return super.preRequestValidate(req); + } + + static getThumbUrl(opts: { + uri: string; + accessToken: string; + itemKey: string; + width?: number; + height?: number; + upscale?: string; + }): string { + const { uri, accessToken, itemKey, width, height, upscale } = opts; + const cleanKey = itemKey.replaceAll(/\/library\/metadata\//g, ''); + + let thumbUrl: URL; + const key = `/library/metadata/${cleanKey}/thumb?X-Plex-Token=${accessToken}`; + if (isUndefined(height) || isUndefined(width)) { + thumbUrl = new URL(`${uri}${key}`); + } else { + thumbUrl = new URL(`${uri}/photo/:/transcode`); + thumbUrl.searchParams.append('url', key); + thumbUrl.searchParams.append('X-Plex-Token', accessToken); + thumbUrl.searchParams.append('width', width.toString()); + thumbUrl.searchParams.append('height', height.toString()); + thumbUrl.searchParams.append('upscale', (upscale ?? '1').toString()); + } + return thumbUrl.toString(); + } +} + +type PlexTvDevicesResponse = { + MediaContainer: { Device: PlexResource[] }; +}; diff --git a/server/src/external/plex/PlexQueryCache.ts b/server/src/external/plex/PlexQueryCache.ts new file mode 100644 index 00000000..541687a8 --- /dev/null +++ b/server/src/external/plex/PlexQueryCache.ts @@ -0,0 +1,56 @@ +import NodeCache from 'node-cache'; +import { isDefined } from '../../util/index.js'; +import { QueryResult, isQueryError, isQuerySuccess } from '../BaseApiClient.js'; + +export class PlexQueryCache { + #cache: NodeCache; + constructor() { + this.#cache = new NodeCache({ + useClones: false, + deleteOnExpire: true, + checkperiod: 60, + maxKeys: 2500, + stdTTL: 5 * 60 * 1000, + }); + } + + async getOrSet( + serverName: string, + path: string, + getter: () => Promise, + ): Promise { + const key = this.getCacheKey(serverName, path); + const existing = this.#cache.get(key); + if (isDefined(existing)) { + return existing; + } + + const value = await getter(); + this.#cache.set(key, value); + return value; + } + + async getOrSetPlexResult( + serverName: string, + path: string, + getter: () => Promise>, + opts?: { setOnError: boolean }, + ): Promise> { + const key = this.getCacheKey(serverName, path); + const existing = this.#cache.get>(key); + if (isDefined(existing)) { + return existing; + } + + const value = await getter(); + if (isQuerySuccess(value) || (isQueryError(value) && opts?.setOnError)) { + this.#cache.set(key, value); + } + + return value; + } + + private getCacheKey(serverName: string, path: string) { + return `${serverName}|${path}`; + } +} diff --git a/server/src/ffmpeg/ffmpeg.ts b/server/src/ffmpeg/ffmpeg.ts index 90b2c0b7..a79f05b3 100644 --- a/server/src/ffmpeg/ffmpeg.ts +++ b/server/src/ffmpeg/ffmpeg.ts @@ -13,7 +13,7 @@ import { import path from 'path'; import { DeepReadonly, DeepRequired } from 'ts-essentials'; import { serverOptions } from '../globals.js'; -import { StreamDetails } from '../stream/plex/PlexTranscoder.js'; +import { StreamDetails } from '../stream/types.js'; import { StreamContextChannel } from '../stream/types.js'; import { Maybe } from '../types/util.js'; import { TypedEventEmitter } from '../types/eventEmitter.js'; @@ -365,6 +365,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< startTime: number, duration: Maybe, enableIcon: Maybe, + extraInnputHeaders: Record = {}, ) { return this.spawn( streamUrl, @@ -373,6 +374,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< duration, true, enableIcon, + extraInnputHeaders, ); } @@ -430,6 +432,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< duration: Maybe, limitRead: boolean, watermark: Maybe, + extraInnputHeaders: Record = {}, ) { const ffmpegArgs: string[] = [ '-hide_banner', @@ -461,6 +464,9 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< let videoFile = -1; let overlayFile = -1; if (isNonEmptyString(streamUrl)) { + for (const [key, value] of Object.entries(extraInnputHeaders)) { + ffmpegArgs.push('-headers', `'${key}: ${value}'`); + } ffmpegArgs.push(`-i`, streamUrl); videoFile = inputFiles++; audioFile = videoFile; @@ -880,7 +886,8 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< const argsWithTokenRedacted = ffmpegArgs .join(' ') - .replaceAll(/(.*X-Plex-Token=)([A-z0-9_\\-]+)(.*)/g, '$1REDACTED$3'); + .replaceAll(/(.*X-Plex-Token=)([A-z0-9_\\-]+)(.*)/g, '$1REDACTED$3') + .replaceAll(/(.*X-Emby-Token:\s)([A-z0-9_\\-]+)(.*)/g, '$1REDACTED$3'); this.logger.debug(`Starting ffmpeg with args: "%s"`, argsWithTokenRedacted); this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { diff --git a/server/src/migrations/.snapshot-db.db.json b/server/src/migrations/.snapshot-db.db.json index aa33e5c3..7a3368d4 100644 --- a/server/src/migrations/.snapshot-db.db.json +++ b/server/src/migrations/.snapshot-db.db.json @@ -10,6 +10,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "url": { @@ -19,6 +20,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "mime_type": { @@ -28,6 +30,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" } }, @@ -35,7 +38,9 @@ "indexes": [ { "keyName": "primary", - "columnNames": ["hash"], + "columnNames": [ + "hash" + ], "composite": false, "constraint": true, "primary": true, @@ -55,6 +60,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -63,8 +69,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -73,8 +79,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "number": { @@ -84,6 +90,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "icon": { @@ -93,6 +100,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "json" }, "guide_minimum_duration": { @@ -102,6 +110,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "disable_filler_overlay": { @@ -111,6 +120,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "default": "false", "mappedType": "integer" }, @@ -121,6 +131,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "duration": { @@ -130,6 +141,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "stealth": { @@ -139,6 +151,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "default": "false", "mappedType": "integer" }, @@ -149,6 +162,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "start_time": { @@ -158,6 +172,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "guide_flex_title": { @@ -167,6 +182,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "offline": { @@ -176,6 +192,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "default": "'{\"mode\":\"clip\"}'", "mappedType": "json" }, @@ -186,6 +203,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "watermark": { @@ -195,6 +213,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "json" }, "transcoding": { @@ -204,6 +223,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "json" } }, @@ -211,7 +231,9 @@ "indexes": [ { "keyName": "channel_number_unique", - "columnNames": ["number"], + "columnNames": [ + "number" + ], "composite": false, "constraint": true, "primary": false, @@ -219,7 +241,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -239,6 +263,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -247,8 +272,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -257,8 +282,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "name": { @@ -268,6 +293,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, @@ -275,7 +301,9 @@ "indexes": [ { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -295,6 +323,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "custom_show_uuid": { @@ -304,13 +333,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, "name": "channel_custom_shows", "indexes": [ { - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "composite": false, "keyName": "channel_custom_shows_channel_uuid_index", "constraint": false, @@ -318,7 +350,9 @@ "unique": false }, { - "columnNames": ["custom_show_uuid"], + "columnNames": [ + "custom_show_uuid" + ], "composite": false, "keyName": "channel_custom_shows_custom_show_uuid_index", "constraint": false, @@ -327,7 +361,10 @@ }, { "keyName": "primary", - "columnNames": ["channel_uuid", "custom_show_uuid"], + "columnNames": [ + "channel_uuid", + "custom_show_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -338,18 +375,26 @@ "foreignKeys": { "channel_custom_shows_channel_uuid_foreign": { "constraintName": "channel_custom_shows_channel_uuid_foreign", - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "localTableName": "channel_custom_shows", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "channel", "deleteRule": "cascade", "updateRule": "cascade" }, "channel_custom_shows_custom_show_uuid_foreign": { "constraintName": "channel_custom_shows_custom_show_uuid_foreign", - "columnNames": ["custom_show_uuid"], + "columnNames": [ + "custom_show_uuid" + ], "localTableName": "channel_custom_shows", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "custom_show", "deleteRule": "cascade", "updateRule": "cascade" @@ -366,6 +411,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -375,7 +421,7 @@ "autoincrement": false, "primary": false, "nullable": true, - "length": 0, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -385,7 +431,7 @@ "autoincrement": false, "primary": false, "nullable": true, - "length": 0, + "length": null, "mappedType": "datetime" }, "name": { @@ -395,6 +441,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, @@ -402,7 +449,9 @@ "indexes": [ { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -422,6 +471,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "filler_show_uuid": { @@ -431,6 +481,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "weight": { @@ -440,6 +491,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "cooldown": { @@ -449,13 +501,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" } }, "name": "channel_filler_show", "indexes": [ { - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "composite": false, "keyName": "channel_filler_show_channel_uuid_index", "constraint": false, @@ -463,7 +518,9 @@ "unique": false }, { - "columnNames": ["filler_show_uuid"], + "columnNames": [ + "filler_show_uuid" + ], "composite": false, "keyName": "channel_filler_show_filler_show_uuid_index", "constraint": false, @@ -472,7 +529,10 @@ }, { "keyName": "primary", - "columnNames": ["channel_uuid", "filler_show_uuid"], + "columnNames": [ + "channel_uuid", + "filler_show_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -483,17 +543,25 @@ "foreignKeys": { "channel_filler_show_channel_uuid_foreign": { "constraintName": "channel_filler_show_channel_uuid_foreign", - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "localTableName": "channel_filler_show", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "channel", "updateRule": "cascade" }, "channel_filler_show_filler_show_uuid_foreign": { "constraintName": "channel_filler_show_filler_show_uuid_foreign", - "columnNames": ["filler_show_uuid"], + "columnNames": [ + "filler_show_uuid" + ], "localTableName": "channel_filler_show", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "filler_show", "updateRule": "cascade" } @@ -509,6 +577,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -517,8 +586,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -527,10 +596,25 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, + "type": { + "name": "type", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "default": "'plex'", + "enumItems": [ + "plex", + "jellyfin" + ], + "mappedType": "enum" + }, "name": { "name": "name", "type": "text", @@ -538,6 +622,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "uri": { @@ -547,6 +632,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "access_token": { @@ -556,6 +642,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "send_guide_updates": { @@ -565,6 +652,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "default": "true", "mappedType": "integer" }, @@ -575,6 +663,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "default": "true", "mappedType": "integer" }, @@ -585,6 +674,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "client_identifier": { @@ -594,14 +684,19 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" } }, - "name": "plex_server_settings", + "name": "media_source", "indexes": [ { - "keyName": "plex_server_settings_name_uri_unique", - "columnNames": ["name", "uri"], + "keyName": "media_source_type_name_uri_unique", + "columnNames": [ + "type", + "name", + "uri" + ], "composite": true, "constraint": true, "primary": false, @@ -609,7 +704,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -629,6 +726,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -637,8 +735,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -647,18 +745,19 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "type": { "name": "type", - "type": "text", + "type": "ProgramGroupingType", "unsigned": false, "autoincrement": false, "primary": false, "nullable": false, - "mappedType": "text" + "length": null, + "mappedType": "unknown" }, "title": { "name": "title", @@ -667,6 +766,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "summary": { @@ -676,6 +776,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "icon": { @@ -685,6 +786,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "year": { @@ -694,6 +796,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "index": { @@ -703,6 +806,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "show_uuid": { @@ -712,6 +816,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "artist_uuid": { @@ -721,13 +826,16 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" } }, "name": "program_grouping", "indexes": [ { - "columnNames": ["show_uuid"], + "columnNames": [ + "show_uuid" + ], "composite": false, "keyName": "program_grouping_show_uuid_index", "constraint": false, @@ -735,7 +843,9 @@ "unique": false }, { - "columnNames": ["artist_uuid"], + "columnNames": [ + "artist_uuid" + ], "composite": false, "keyName": "program_grouping_artist_uuid_index", "constraint": false, @@ -744,7 +854,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -755,18 +867,26 @@ "foreignKeys": { "program_grouping_show_uuid_foreign": { "constraintName": "program_grouping_show_uuid_foreign", - "columnNames": ["show_uuid"], + "columnNames": [ + "show_uuid" + ], "localTableName": "program_grouping", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", "deleteRule": "set null", "updateRule": "cascade" }, "program_grouping_artist_uuid_foreign": { "constraintName": "program_grouping_artist_uuid_foreign", - "columnNames": ["artist_uuid"], + "columnNames": [ + "artist_uuid" + ], "localTableName": "program_grouping", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", "deleteRule": "set null", "updateRule": "cascade" @@ -783,6 +903,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -791,8 +912,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -801,8 +922,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "source_type": { @@ -812,7 +933,11 @@ "autoincrement": false, "primary": false, "nullable": false, - "enumItems": ["plex"], + "length": null, + "enumItems": [ + "plex", + "jellyfin" + ], "mappedType": "enum" }, "original_air_date": { @@ -822,6 +947,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "duration": { @@ -831,6 +957,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" }, "episode": { @@ -840,6 +967,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "episode_icon": { @@ -849,6 +977,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "file_path": { @@ -858,6 +987,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "icon": { @@ -867,6 +997,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "external_source_id": { @@ -876,6 +1007,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "external_key": { @@ -885,6 +1017,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "plex_rating_key": { @@ -894,6 +1027,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "plex_file_path": { @@ -903,6 +1037,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "parent_external_key": { @@ -912,6 +1047,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "grandparent_external_key": { @@ -921,6 +1057,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "rating": { @@ -930,6 +1067,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "season_number": { @@ -939,6 +1077,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "season_icon": { @@ -948,6 +1087,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "show_icon": { @@ -957,6 +1097,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "show_title": { @@ -966,6 +1107,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "summary": { @@ -975,6 +1117,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "title": { @@ -984,6 +1127,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "type": { @@ -993,7 +1137,12 @@ "autoincrement": false, "primary": false, "nullable": false, - "enumItems": ["movie", "episode", "track"], + "length": null, + "enumItems": [ + "movie", + "episode", + "track" + ], "mappedType": "enum" }, "year": { @@ -1003,6 +1152,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "integer" }, "artist_name": { @@ -1012,6 +1162,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "album_name": { @@ -1021,6 +1172,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "season_uuid": { @@ -1030,6 +1182,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "tv_show_uuid": { @@ -1039,6 +1192,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "album_uuid": { @@ -1048,6 +1202,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "artist_uuid": { @@ -1057,13 +1212,16 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" } }, "name": "program", "indexes": [ { - "columnNames": ["season_uuid"], + "columnNames": [ + "season_uuid" + ], "composite": false, "keyName": "program_season_uuid_index", "constraint": false, @@ -1071,7 +1229,9 @@ "unique": false }, { - "columnNames": ["tv_show_uuid"], + "columnNames": [ + "tv_show_uuid" + ], "composite": false, "keyName": "program_tv_show_uuid_index", "constraint": false, @@ -1079,7 +1239,9 @@ "unique": false }, { - "columnNames": ["album_uuid"], + "columnNames": [ + "album_uuid" + ], "composite": false, "keyName": "program_album_uuid_index", "constraint": false, @@ -1087,7 +1249,9 @@ "unique": false }, { - "columnNames": ["artist_uuid"], + "columnNames": [ + "artist_uuid" + ], "composite": false, "keyName": "program_artist_uuid_index", "constraint": false, @@ -1108,7 +1272,11 @@ }, { "keyName": "program_source_type_external_source_id_external_key_unique", - "columnNames": ["source_type", "external_source_id", "external_key"], + "columnNames": [ + "source_type", + "external_source_id", + "external_key" + ], "composite": true, "constraint": true, "primary": false, @@ -1116,7 +1284,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -1127,39 +1297,51 @@ "foreignKeys": { "program_season_uuid_foreign": { "constraintName": "program_season_uuid_foreign", - "columnNames": ["season_uuid"], + "columnNames": [ + "season_uuid" + ], "localTableName": "program", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", - "deleteRule": "set null", - "updateRule": "cascade" + "deleteRule": "set null" }, "program_tv_show_uuid_foreign": { "constraintName": "program_tv_show_uuid_foreign", - "columnNames": ["tv_show_uuid"], + "columnNames": [ + "tv_show_uuid" + ], "localTableName": "program", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", - "deleteRule": "set null", - "updateRule": "cascade" + "deleteRule": "set null" }, "program_album_uuid_foreign": { "constraintName": "program_album_uuid_foreign", - "columnNames": ["album_uuid"], + "columnNames": [ + "album_uuid" + ], "localTableName": "program", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", - "deleteRule": "set null", - "updateRule": "cascade" + "deleteRule": "set null" }, "program_artist_uuid_foreign": { "constraintName": "program_artist_uuid_foreign", - "columnNames": ["artist_uuid"], + "columnNames": [ + "artist_uuid" + ], "localTableName": "program", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", - "deleteRule": "set null", - "updateRule": "cascade" + "deleteRule": "set null" } }, "nativeEnums": {} @@ -1173,6 +1355,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -1181,8 +1364,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -1191,8 +1374,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "source_type": { @@ -1202,7 +1385,15 @@ "autoincrement": false, "primary": false, "nullable": false, - "enumItems": ["plex", "plex-guid", "tmdb", "imdb", "tvdb"], + "length": null, + "enumItems": [ + "plex", + "plex-guid", + "tmdb", + "imdb", + "tvdb", + "jellyfin" + ], "mappedType": "enum" }, "external_source_id": { @@ -1212,6 +1403,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "external_key": { @@ -1221,6 +1413,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "external_file_path": { @@ -1230,6 +1423,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "direct_file_path": { @@ -1239,6 +1433,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "program_uuid": { @@ -1248,13 +1443,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, "name": "program_external_id", "indexes": [ { - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "composite": false, "keyName": "program_external_id_program_uuid_index", "constraint": false, @@ -1263,7 +1461,11 @@ }, { "keyName": "unique_program_multi_external_id", - "columnNames": ["program_uuid", "source_type", "external_source_id"], + "columnNames": [ + "program_uuid", + "source_type", + "external_source_id" + ], "composite": true, "constraint": false, "primary": false, @@ -1272,7 +1474,10 @@ }, { "keyName": "unique_program_single_external_id", - "columnNames": ["program_uuid", "source_type"], + "columnNames": [ + "program_uuid", + "source_type" + ], "composite": true, "constraint": false, "primary": false, @@ -1281,7 +1486,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -1292,9 +1499,13 @@ "foreignKeys": { "program_external_id_program_uuid_foreign": { "constraintName": "program_external_id_program_uuid_foreign", - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "localTableName": "program_external_id", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program", "updateRule": "cascade" } @@ -1310,6 +1521,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "program_uuid": { @@ -1319,6 +1531,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "index": { @@ -1328,13 +1541,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" } }, "name": "filler_show_content", "indexes": [ { - "columnNames": ["filler_show_uuid"], + "columnNames": [ + "filler_show_uuid" + ], "composite": false, "keyName": "filler_show_content_filler_show_uuid_index", "constraint": false, @@ -1342,7 +1558,9 @@ "unique": false }, { - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "composite": false, "keyName": "filler_show_content_program_uuid_index", "constraint": false, @@ -1351,7 +1569,11 @@ }, { "keyName": "filler_show_content_filler_show_uuid_program_uuid_index_unique", - "columnNames": ["filler_show_uuid", "program_uuid", "index"], + "columnNames": [ + "filler_show_uuid", + "program_uuid", + "index" + ], "composite": true, "constraint": true, "primary": false, @@ -1359,7 +1581,10 @@ }, { "keyName": "primary", - "columnNames": ["filler_show_uuid", "program_uuid"], + "columnNames": [ + "filler_show_uuid", + "program_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -1370,17 +1595,25 @@ "foreignKeys": { "filler_show_content_filler_show_uuid_foreign": { "constraintName": "filler_show_content_filler_show_uuid_foreign", - "columnNames": ["filler_show_uuid"], + "columnNames": [ + "filler_show_uuid" + ], "localTableName": "filler_show_content", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "filler_show", "updateRule": "cascade" }, "filler_show_content_program_uuid_foreign": { "constraintName": "filler_show_content_program_uuid_foreign", - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "localTableName": "filler_show_content", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program", "updateRule": "cascade" } @@ -1396,6 +1629,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "content_uuid": { @@ -1405,6 +1639,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "index": { @@ -1414,13 +1649,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "integer" } }, "name": "custom_show_content", "indexes": [ { - "columnNames": ["custom_show_uuid"], + "columnNames": [ + "custom_show_uuid" + ], "composite": false, "keyName": "custom_show_content_custom_show_uuid_index", "constraint": false, @@ -1428,7 +1666,9 @@ "unique": false }, { - "columnNames": ["content_uuid"], + "columnNames": [ + "content_uuid" + ], "composite": false, "keyName": "custom_show_content_content_uuid_index", "constraint": false, @@ -1437,7 +1677,11 @@ }, { "keyName": "custom_show_content_custom_show_uuid_content_uuid_index_unique", - "columnNames": ["custom_show_uuid", "content_uuid", "index"], + "columnNames": [ + "custom_show_uuid", + "content_uuid", + "index" + ], "composite": true, "constraint": true, "primary": false, @@ -1445,7 +1689,10 @@ }, { "keyName": "primary", - "columnNames": ["custom_show_uuid", "content_uuid"], + "columnNames": [ + "custom_show_uuid", + "content_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -1456,17 +1703,25 @@ "foreignKeys": { "custom_show_content_custom_show_uuid_foreign": { "constraintName": "custom_show_content_custom_show_uuid_foreign", - "columnNames": ["custom_show_uuid"], + "columnNames": [ + "custom_show_uuid" + ], "localTableName": "custom_show_content", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "custom_show", "updateRule": "cascade" }, "custom_show_content_content_uuid_foreign": { "constraintName": "custom_show_content_content_uuid_foreign", - "columnNames": ["content_uuid"], + "columnNames": [ + "content_uuid" + ], "localTableName": "custom_show_content", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program", "updateRule": "cascade" } @@ -1482,6 +1737,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "program_uuid": { @@ -1491,13 +1747,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, "name": "channel_programs", "indexes": [ { - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "composite": false, "keyName": "channel_programs_channel_uuid_index", "constraint": false, @@ -1505,7 +1764,9 @@ "unique": false }, { - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "composite": false, "keyName": "channel_programs_program_uuid_index", "constraint": false, @@ -1514,7 +1775,10 @@ }, { "keyName": "primary", - "columnNames": ["channel_uuid", "program_uuid"], + "columnNames": [ + "channel_uuid", + "program_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -1525,18 +1789,26 @@ "foreignKeys": { "channel_programs_channel_uuid_foreign": { "constraintName": "channel_programs_channel_uuid_foreign", - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "localTableName": "channel_programs", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "channel", "deleteRule": "cascade", "updateRule": "cascade" }, "channel_programs_program_uuid_foreign": { "constraintName": "channel_programs_program_uuid_foreign", - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "localTableName": "channel_programs", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program", "deleteRule": "cascade", "updateRule": "cascade" @@ -1553,6 +1825,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "program_uuid": { @@ -1562,13 +1835,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, "name": "channel_fallback", "indexes": [ { - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "composite": false, "keyName": "channel_fallback_channel_uuid_index", "constraint": false, @@ -1576,7 +1852,9 @@ "unique": false }, { - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "composite": false, "keyName": "channel_fallback_program_uuid_index", "constraint": false, @@ -1585,7 +1863,10 @@ }, { "keyName": "primary", - "columnNames": ["channel_uuid", "program_uuid"], + "columnNames": [ + "channel_uuid", + "program_uuid" + ], "composite": true, "constraint": true, "primary": true, @@ -1596,18 +1877,26 @@ "foreignKeys": { "channel_fallback_channel_uuid_foreign": { "constraintName": "channel_fallback_channel_uuid_foreign", - "columnNames": ["channel_uuid"], + "columnNames": [ + "channel_uuid" + ], "localTableName": "channel_fallback", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "channel", "deleteRule": "cascade", "updateRule": "cascade" }, "channel_fallback_program_uuid_foreign": { "constraintName": "channel_fallback_program_uuid_foreign", - "columnNames": ["program_uuid"], + "columnNames": [ + "program_uuid" + ], "localTableName": "channel_fallback", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program", "deleteRule": "cascade", "updateRule": "cascade" @@ -1624,6 +1913,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "created_at": { @@ -1632,8 +1922,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "updated_at": { @@ -1642,8 +1932,8 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, - "length": 0, + "nullable": false, + "length": null, "mappedType": "datetime" }, "source_type": { @@ -1653,7 +1943,15 @@ "autoincrement": false, "primary": false, "nullable": false, - "enumItems": ["plex", "plex-guid", "tmdb", "imdb", "tvdb"], + "length": null, + "enumItems": [ + "plex", + "plex-guid", + "tmdb", + "imdb", + "tvdb", + "jellyfin" + ], "mappedType": "enum" }, "external_source_id": { @@ -1663,6 +1961,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "external_key": { @@ -1672,6 +1971,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" }, "external_file_path": { @@ -1681,6 +1981,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "length": null, "mappedType": "text" }, "group_uuid": { @@ -1690,13 +1991,16 @@ "autoincrement": false, "primary": false, "nullable": false, + "length": null, "mappedType": "text" } }, "name": "program_grouping_external_id", "indexes": [ { - "columnNames": ["group_uuid"], + "columnNames": [ + "group_uuid" + ], "composite": false, "keyName": "program_grouping_external_id_group_uuid_index", "constraint": false, @@ -1705,7 +2009,10 @@ }, { "keyName": "program_grouping_external_id_uuid_source_type_unique", - "columnNames": ["uuid", "source_type"], + "columnNames": [ + "uuid", + "source_type" + ], "composite": true, "constraint": true, "primary": false, @@ -1713,7 +2020,9 @@ }, { "keyName": "primary", - "columnNames": ["uuid"], + "columnNames": [ + "uuid" + ], "composite": false, "constraint": true, "primary": true, @@ -1724,9 +2033,13 @@ "foreignKeys": { "program_grouping_external_id_group_uuid_foreign": { "constraintName": "program_grouping_external_id_group_uuid_foreign", - "columnNames": ["group_uuid"], + "columnNames": [ + "group_uuid" + ], "localTableName": "program_grouping_external_id", - "referencedColumnNames": ["uuid"], + "referencedColumnNames": [ + "uuid" + ], "referencedTableName": "program_grouping", "updateRule": "cascade" } diff --git a/server/src/migrations/Migration20240719145409.ts b/server/src/migrations/Migration20240719145409.ts new file mode 100644 index 00000000..b2c1fc0b --- /dev/null +++ b/server/src/migrations/Migration20240719145409.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240719145409 extends Migration { + async up(): Promise { + this.addSql('alter table `plex_server_settings` rename to `media_source`;'); + this.addSql( + "alter table `media_source` add column `type` text check (`type` in ('plex', 'jellyfin')) not null default 'plex';", + ); + this.addSql('drop index if exists `plex_server_settings_name_uri_unique`;'); + this.addSql( + 'create unique index `media_source_type_name_uri_unique` on `media_source` (`type`, `name`, `uri`);', + ); + } + + async down() { + this.addSql('alter table `media_source` drop column `type`'); + this.addSql('alter table `media_source` rename to `plex_server_settings`;'); + this.addSql('drop index if exists `media_source_type_name_uri_unique`;'); + this.addSql( + 'CREATE UNIQUE INDEX `plex_server_settings_name_uri_unique` on `plex_server_settings` (`name`, `uri`);', + ); + } +} diff --git a/server/src/migrations/Migration20240805185042.ts b/server/src/migrations/Migration20240805185042.ts new file mode 100644 index 00000000..b5df3168 --- /dev/null +++ b/server/src/migrations/Migration20240805185042.ts @@ -0,0 +1,89 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240805185042 extends Migration { + async up(): Promise { + this.addSql('pragma foreign_keys = off;'); + this.addSql( + 'create table `program_grouping__temp_alter` (`uuid` text not null, `created_at` datetime not null, `updated_at` datetime not null, `type` ProgramGroupingType not null, `title` text not null, `summary` text null, `icon` text null, `year` integer null, `index` integer null, `show_uuid` text null, `artist_uuid` text null, constraint `program_grouping_show_uuid_foreign` foreign key(`show_uuid`) references `program_grouping`(`uuid`) on delete set null on update cascade, constraint `program_grouping_artist_uuid_foreign` foreign key(`artist_uuid`) references `program_grouping`(`uuid`) on delete set null on update cascade, primary key (`uuid`));', + ); + this.addSql( + 'insert into `program_grouping__temp_alter` select * from `program_grouping`;', + ); + this.addSql('drop table `program_grouping`;'); + this.addSql( + 'alter table `program_grouping__temp_alter` rename to `program_grouping`;', + ); + this.addSql( + 'create index `program_grouping_show_uuid_index` on `program_grouping` (`show_uuid`);', + ); + this.addSql( + 'create index `program_grouping_artist_uuid_index` on `program_grouping` (`artist_uuid`);', + ); + this.addSql('pragma foreign_keys = on;'); + this.addSql('pragma foreign_keys = off;'); + this.addSql( + "create table `program__temp_alter` (`uuid` text not null, `created_at` datetime not null, `updated_at` datetime not null, `source_type` text check (`source_type` in ('plex', 'jellyfin')) not null, `original_air_date` text null, `duration` integer not null, `episode` integer null, `episode_icon` text null, `file_path` text null, `icon` text null, `external_source_id` text not null, `external_key` text not null, `plex_rating_key` text null, `plex_file_path` text null, `parent_external_key` text null, `grandparent_external_key` text null, `rating` text null, `season_number` integer null, `season_icon` text null, `show_icon` text null, `show_title` text null, `summary` text null, `title` text not null, `type` text check (`type` in ('movie', 'episode', 'track')) not null, `year` integer null, `artist_name` text null, `album_name` text null, `season_uuid` text null, `tv_show_uuid` text null, `album_uuid` text null, `artist_uuid` text null, constraint `program_season_uuid_foreign` foreign key(`season_uuid`) references `program_grouping`(`uuid`) on delete set null, constraint `program_tv_show_uuid_foreign` foreign key(`tv_show_uuid`) references `program_grouping`(`uuid`) on delete set null, constraint `program_album_uuid_foreign` foreign key(`album_uuid`) references `program_grouping`(`uuid`) on delete set null, constraint `program_artist_uuid_foreign` foreign key(`artist_uuid`) references `program_grouping`(`uuid`) on delete set null, primary key (`uuid`));", + ); + this.addSql('insert into `program__temp_alter` select * from `program`;'); + this.addSql('drop table `program`;'); + this.addSql('alter table `program__temp_alter` rename to `program`;'); + this.addSql( + 'create index `program_season_uuid_index` on `program` (`season_uuid`);', + ); + this.addSql( + 'create index `program_tv_show_uuid_index` on `program` (`tv_show_uuid`);', + ); + this.addSql( + 'create index `program_album_uuid_index` on `program` (`album_uuid`);', + ); + this.addSql( + 'create index `program_artist_uuid_index` on `program` (`artist_uuid`);', + ); + this.addSql( + 'create index `program_source_type_external_source_id_plex_rating_key_index` on `program` (`source_type`, `external_source_id`, `plex_rating_key`);', + ); + this.addSql( + 'create unique index `program_source_type_external_source_id_external_key_unique` on `program` (`source_type`, `external_source_id`, `external_key`);', + ); + this.addSql('pragma foreign_keys = on;'); + this.addSql('pragma foreign_keys = off;'); + this.addSql( + "create table `program_external_id__temp_alter` (`uuid` text not null, `created_at` datetime not null, `updated_at` datetime not null, `source_type` text check (`source_type` in ('plex', 'plex-guid', 'tmdb', 'imdb', 'tvdb', 'jellyfin')) not null, `external_source_id` text null, `external_key` text not null, `external_file_path` text null, `direct_file_path` text null, `program_uuid` text not null, constraint `program_external_id_program_uuid_foreign` foreign key(`program_uuid`) references `program`(`uuid`) on update cascade, primary key (`uuid`));", + ); + this.addSql( + 'insert into `program_external_id__temp_alter` select * from `program_external_id`;', + ); + this.addSql('drop table `program_external_id`;'); + this.addSql( + 'alter table `program_external_id__temp_alter` rename to `program_external_id`;', + ); + this.addSql( + 'create index `program_external_id_program_uuid_index` on `program_external_id` (`program_uuid`);', + ); + this.addSql( + 'create unique index `unique_program_multiple_external_id` on `program_external_id` (`program_uuid`, `source_type`, `external_source_id`) WHERE `external_source_id` IS NOT NULL;', + ); + this.addSql( + 'create unique index `unique_program_single_external_id` on `program_external_id` (`program_uuid`, `source_type`) WHERE `external_source_id` IS NULL;', + ); + this.addSql('pragma foreign_keys = on;'); + this.addSql('pragma foreign_keys = off;'); + this.addSql( + "create table `program_grouping_external_id__temp_alter` (`uuid` text not null, `created_at` datetime not null, `updated_at` datetime not null, `source_type` text check (`source_type` in ('plex', 'plex-guid', 'tmdb', 'imdb', 'tvdb', 'jellyfin')) not null, `external_source_id` text null, `external_key` text not null, `external_file_path` text null, `group_uuid` text not null, constraint `program_grouping_external_id_group_uuid_foreign` foreign key(`group_uuid`) references `program_grouping`(`uuid`) on update cascade, primary key (`uuid`));", + ); + this.addSql( + 'insert into `program_grouping_external_id__temp_alter` select * from `program_grouping_external_id`;', + ); + this.addSql('drop table `program_grouping_external_id`;'); + this.addSql( + 'alter table `program_grouping_external_id__temp_alter` rename to `program_grouping_external_id`;', + ); + this.addSql( + 'create index `program_grouping_external_id_group_uuid_index` on `program_grouping_external_id` (`group_uuid`);', + ); + this.addSql( + 'create unique index `program_grouping_external_id_uuid_source_type_unique` on `program_grouping_external_id` (`uuid`, `source_type`);', + ); + this.addSql('pragma foreign_keys = on;'); + } +} diff --git a/server/src/serverContext.ts b/server/src/serverContext.ts index 25567506..e6205eb4 100644 --- a/server/src/serverContext.ts +++ b/server/src/serverContext.ts @@ -4,8 +4,8 @@ import path from 'path'; import { XmlTvWriter } from './XmlTvWriter.js'; import { ChannelDB } from './dao/channelDb.js'; import { CustomShowDB } from './dao/customShowDb.js'; -import { FillerDB } from './dao/fillerDb.js'; -import { PlexServerDB } from './dao/plexServerDb.js'; +import { FillerDB } from './dao/fillerDB.js'; +import { MediaSourceDB } from './dao/mediaSourceDB.js'; import { ProgramDB } from './dao/programDB.js'; import { SettingsDB, getSettings } from './dao/settings.js'; import { serverOptions } from './globals.js'; @@ -33,7 +33,7 @@ export class ServerContext { public hdhrService: HdhrService, public customShowDB: CustomShowDB, public channelCache: ChannelCache, - public plexServerDB: PlexServerDB, + public mediaSourceDB: MediaSourceDB, public settings: SettingsDB, public programDB: ProgramDB, ) {} @@ -77,7 +77,7 @@ export const serverContext: () => ServerContext = once(() => { new HdhrService(settings), customShowDB, channelCache, - new PlexServerDB(channelDB), + new MediaSourceDB(channelDB), settings, new ProgramDB(), ); diff --git a/server/src/services/PlexItemEnumerator.ts b/server/src/services/PlexItemEnumerator.ts index 4df47ded..faaebf2b 100644 --- a/server/src/services/PlexItemEnumerator.ts +++ b/server/src/services/PlexItemEnumerator.ts @@ -10,7 +10,7 @@ import { import { flatten, isNil, uniqBy } from 'lodash-es'; import map from 'lodash-es/map'; import { ProgramDB } from '../dao/programDB'; -import { Plex } from '../external/plex'; +import { PlexApiClient } from '../external/plex/PlexApiClient'; import { typedProperty } from '../types/path'; import { flatMapAsyncSeq, wait } from '../util/index.js'; import { Logger, LoggerFactory } from '../util/logging/LoggerFactory'; @@ -24,10 +24,10 @@ export type EnrichedPlexTerminalMedia = PlexTerminalMedia & { export class PlexItemEnumerator { #logger: Logger = LoggerFactory.child({ className: PlexItemEnumerator.name }); #timer = new Timer(this.#logger); - #plex: Plex; + #plex: PlexApiClient; #programDB: ProgramDB; - constructor(plex: Plex, programDB: ProgramDB) { + constructor(plex: PlexApiClient, programDB: ProgramDB) { this.#plex = plex; this.#programDB = programDB; } @@ -55,7 +55,7 @@ export class PlexItemEnumerator { } else if (isPlexDirectory(item)) { return []; } else { - const plexResult = await this.#plex.doGet( + const plexResult = await this.#plex.doGetPath( item.key, ); diff --git a/server/src/services/dynamic_channels/PlexContentSourceUpdater.ts b/server/src/services/dynamic_channels/PlexContentSourceUpdater.ts index 90894d67..7ac48916 100644 --- a/server/src/services/dynamic_channels/PlexContentSourceUpdater.ts +++ b/server/src/services/dynamic_channels/PlexContentSourceUpdater.ts @@ -7,9 +7,9 @@ import { isNil, map } from 'lodash-es'; import { ChannelDB } from '../../dao/channelDb.js'; import { EntityManager } from '../../dao/dataSource.js'; import { Channel } from '../../dao/entities/Channel.js'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; +import { MediaSource } from '../../dao/entities/MediaSource.js'; import { ProgramDB } from '../../dao/programDB.js'; -import { Plex } from '../../external/plex.js'; +import { PlexApiClient } from '../../external/plex/PlexApiClient.js'; import { EnrichedPlexTerminalMedia, PlexItemEnumerator, @@ -26,7 +26,7 @@ export class PlexContentSourceUpdater extends ContentSourceUpdater - this.#plex.doGet( + this.#plex.doGetPath( `/library/sections/${this.config.plexLibraryKey}/all?${filter.join( '&', )}`, @@ -100,7 +100,7 @@ const plexMediaToContentProgram = ( return { id: media.id ?? uniqueId, persisted: !isNil(media.id), - originalProgram: media, + originalProgram: { sourceType: 'plex', program: media }, duration: media.duration, externalSourceName: serverName, externalSourceType: 'plex', diff --git a/server/src/stream/ProgramPlayer.ts b/server/src/stream/ProgramPlayer.ts index 7179d60c..daec4412 100644 --- a/server/src/stream/ProgramPlayer.ts +++ b/server/src/stream/ProgramPlayer.ts @@ -22,16 +22,18 @@ import { FfmpegSettings, Watermark } from '@tunarr/types'; import { isError, isString, isUndefined } from 'lodash-es'; import { Writable } from 'stream'; import { isContentBackedLineupIteam } from '../dao/derived_types/StreamLineup.js'; +import { MediaSourceType } from '../dao/entities/MediaSource.js'; import { FfmpegEvents } from '../ffmpeg/ffmpeg.js'; import { TypedEventEmitter } from '../types/eventEmitter.js'; import { Maybe } from '../types/util.js'; import { isNonEmptyString } from '../util/index.js'; +import { LoggerFactory } from '../util/logging/LoggerFactory.js'; +import { makeLocalUrl } from '../util/serverUtil.js'; import { OfflinePlayer } from './OfflinePlayer.js'; import { Player, PlayerContext } from './Player.js'; +import { JellyfinPlayer } from './jellyfin/JellyfinPlayer.js'; import { PlexPlayer } from './plex/PlexPlayer.js'; import { StreamContextChannel } from './types.js'; -import { LoggerFactory } from '../util/logging/LoggerFactory.js'; -import { serverOptions } from '../globals.js'; export class ProgramPlayer extends Player { private logger = LoggerFactory.child({ caller: import.meta }); @@ -53,17 +55,22 @@ export class ProgramPlayer extends Player { this.delegate = new OfflinePlayer(true, context); } else if (program.type === 'loading') { this.logger.debug('About to play loading stream'); - /* loading */ context.isLoading = true; this.delegate = new OfflinePlayer(false, context); } else if (program.type === 'offline') { this.logger.debug('About to play offline stream'); - /* offline */ this.delegate = new OfflinePlayer(false, context); - } else if (isContentBackedLineupIteam(program) && program) { - this.logger.debug('About to play plex stream'); - /* plex */ - this.delegate = new PlexPlayer(context); + } else if (isContentBackedLineupIteam(program)) { + switch (program.externalSource) { + case MediaSourceType.Plex: + this.logger.debug('About to play plex stream'); + this.delegate = new PlexPlayer(context); + break; + case MediaSourceType.Jellyfin: + this.logger.debug('About to play plex stream'); + this.delegate = new JellyfinPlayer(context); + break; + } } this.context.watermark = this.getWatermark( context.ffmpegSettings, @@ -137,8 +144,8 @@ export class ProgramPlayer extends Player { ); } this.logger.error( - 'Error when attempting to play video. Fallback to error stream: ' + - actualError.stack, + actualError, + 'Error when attempting to play video. Fallback to error stream', ); //Retry once with an error stream: this.context.lineupItem = { @@ -182,7 +189,7 @@ export class ProgramPlayer extends Player { } else if (isNonEmptyString(channel.icon?.path)) { icon = channel.icon.path; } else { - icon = `http://localhost:${serverOptions().port}/images/tunarr.png`; + icon = makeLocalUrl('/images/tunarr.png'); } console.log(watermark); diff --git a/server/src/stream/StreamProgramCalculator.ts b/server/src/stream/StreamProgramCalculator.ts index eb953e98..cfd34806 100644 --- a/server/src/stream/StreamProgramCalculator.ts +++ b/server/src/stream/StreamProgramCalculator.ts @@ -1,14 +1,6 @@ import { Loaded } from '@mikro-orm/core'; import constants from '@tunarr/shared/constants'; -import { - find, - first, - isEmpty, - isNil, - isNull, - isUndefined, - pick, -} from 'lodash-es'; +import { first, isEmpty, isNil, isNull, isUndefined, pick } from 'lodash-es'; import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js'; import { getEm } from '../dao/dataSource.js'; import { @@ -34,8 +26,10 @@ import { binarySearchRange } from '../util/binarySearch.js'; import { isNonEmptyString, zipWithIndex } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; import { STREAM_CHANNEL_CONTEXT_KEYS, StreamContextChannel } from './types.js'; -import { FillerDB } from '../dao/fillerDb.js'; +import { FillerDB } from '../dao/fillerDB.js'; import { ChannelDB } from '../dao/channelDb.js'; +import { MediaSourceType } from '../dao/entities/MediaSource.js'; +import { ProgramExternalId } from '../dao/entities/ProgramExternalId.js'; const SLACK = constants.SLACK; @@ -164,7 +158,12 @@ export class StreamProgramCalculator { populate: ['externalIds'], populateWhere: { externalIds: { - sourceType: ProgramExternalIdType.PLEX, + sourceType: { + $in: [ + ProgramExternalIdType.PLEX, + ProgramExternalIdType.JELLYFIN, + ], + }, }, }, }, @@ -178,21 +177,26 @@ export class StreamProgramCalculator { if (!isNil(backingItem)) { // Will play this item on the first found server... unsure if that is // what we want - const plexInfo = find( - backingItem.externalIds, - (eid) => eid.sourceType === ProgramExternalIdType.PLEX, + const externalInfo = backingItem.externalIds.find( + (eid) => + eid.sourceType === ProgramExternalIdType.PLEX || + eid.sourceType === ProgramExternalIdType.JELLYFIN, ); if ( - !isUndefined(plexInfo) && - isNonEmptyString(plexInfo.externalSourceId) + !isUndefined(externalInfo) && + isNonEmptyString(externalInfo.externalSourceId) ) { program = { type: 'program', - plexFilePath: plexInfo.externalFilePath, - externalKey: plexInfo.externalKey, - filePath: plexInfo.directFilePath, - externalSourceId: plexInfo.externalSourceId, + externalSource: + externalInfo.sourceType === ProgramExternalIdType.JELLYFIN + ? MediaSourceType.Jellyfin + : MediaSourceType.Plex, + plexFilePath: externalInfo.externalFilePath, + externalKey: externalInfo.externalKey, + filePath: externalInfo.directFilePath, + externalSourceId: externalInfo.externalSourceId, duration: backingItem.duration, programId: backingItem.uuid, title: backingItem.title, @@ -302,24 +306,38 @@ export class StreamProgramCalculator { } } - return { - // just add the video, starting at 0, playing the entire duration - type: 'commercial', - title: filler.title, - filePath: filler.filePath!, - externalKey: filler.externalKey, - start: fillerstart, - streamDuration: Math.max( - 1, - Math.min(filler.duration - fillerstart, remaining), - ), - duration: filler.duration, - programId: filler.uuid, - beginningOffset: beginningOffset, - externalSourceId: filler.externalSourceId, - plexFilePath: filler.plexFilePath!, - programType: filler.type as ProgramType, - }; + const externalInfos = await getEm().find(ProgramExternalId, { + program: { uuid: filler.uuid }, + sourceType: { + $in: [ProgramExternalIdType.PLEX, ProgramExternalIdType.JELLYFIN], + }, + }); + + if (!isEmpty(externalInfos)) { + const externalInfo = first(externalInfos)!; + return { + // just add the video, starting at 0, playing the entire duration + type: 'commercial', + title: filler.title, + filePath: externalInfo.directFilePath, + externalKey: externalInfo.externalKey, + externalSource: + externalInfo.sourceType === ProgramExternalIdType.JELLYFIN + ? MediaSourceType.Jellyfin + : MediaSourceType.Plex, + start: fillerstart, + streamDuration: Math.max( + 1, + Math.min(filler.duration - fillerstart, remaining), + ), + duration: filler.duration, + programId: filler.uuid, + beginningOffset: beginningOffset, + externalSourceId: externalInfo.externalSourceId!, + plexFilePath: externalInfo.externalFilePath, + programType: filler.type as ProgramType, + }; + } } // pick the offline screen remaining = Math.min(remaining, 10 * 60 * 1000); diff --git a/server/src/stream/VideoStream.ts b/server/src/stream/VideoStream.ts index 739eaa77..a7102d0f 100644 --- a/server/src/stream/VideoStream.ts +++ b/server/src/stream/VideoStream.ts @@ -15,7 +15,6 @@ import { fileExists } from '../util/fsUtil'; import { deepCopy } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory'; import { - ProgramAndTimeElapsed, StreamProgramCalculator, generateChannelContext, } from './StreamProgramCalculator'; @@ -107,80 +106,75 @@ export class VideoStream { } let lineupItem: Maybe; - let currentProgram: ProgramAndTimeElapsed | undefined; let channelContext: Loaded = channel; const redirectChannels: string[] = []; const upperBounds: number[] = []; - if (isUndefined(lineupItem)) { - const lineup = await serverCtx.channelDB.loadLineup(channel.uuid); - currentProgram = await this.calculator.getCurrentProgramAndTimeElapsed( - startTimestamp, - channel, - lineup, + let currentProgram = await this.calculator.getCurrentProgramAndTimeElapsed( + startTimestamp, + channel, + lineup, + ); + + while ( + !isUndefined(currentProgram) && + currentProgram.program.type === 'redirect' + ) { + redirectChannels.push(channelContext.uuid); + upperBounds.push( + currentProgram.program.duration - currentProgram.timeElapsed, ); - while ( - !isUndefined(currentProgram) && - currentProgram.program.type === 'redirect' - ) { - redirectChannels.push(channelContext.uuid); - upperBounds.push( - currentProgram.program.duration - currentProgram.timeElapsed, - ); - - if (redirectChannels.includes(currentProgram.program.channel)) { - await serverCtx.channelCache.recordPlayback( - channelContext.uuid, - startTimestamp, - { - type: 'error', - title: 'Error', - error: - 'Recursive channel redirect found: ' + - redirectChannels.join(', '), - duration: 60000, - start: 0, - }, - ); - } - - const nextChannelId = currentProgram.program.channel; - const newChannelAndLineup = - await serverCtx.channelDB.loadChannelAndLineup(nextChannelId); - - if (isNil(newChannelAndLineup)) { - const msg = "Invalid redirect to a channel that doesn't exist"; - this.logger.error(msg); - currentProgram = { - program: { - ...createOfflineStreamLineupIteam(60000), - type: 'error', - error: msg, - }, - timeElapsed: 0, - programIndex: -1, - }; - continue; - } - - channelContext = newChannelAndLineup.channel; - lineupItem = serverCtx.channelCache.getCurrentLineupItem( + if (redirectChannels.includes(currentProgram.program.channel)) { + await serverCtx.channelCache.recordPlayback( channelContext.uuid, startTimestamp, + { + type: 'error', + title: 'Error', + error: + 'Recursive channel redirect found: ' + + redirectChannels.join(', '), + duration: 60000, + start: 0, + }, ); + } - if (!isUndefined(lineupItem)) { - lineupItem = deepCopy(lineupItem); - break; - } else { - currentProgram = - await this.calculator.getCurrentProgramAndTimeElapsed( - startTimestamp, - channelContext, - newChannelAndLineup.lineup, - ); - } + const nextChannelId = currentProgram.program.channel; + const newChannelAndLineup = + await serverCtx.channelDB.loadChannelAndLineup(nextChannelId); + + if (isNil(newChannelAndLineup)) { + const msg = "Invalid redirect to a channel that doesn't exist"; + this.logger.error(msg); + currentProgram = { + program: { + ...createOfflineStreamLineupIteam(60000), + type: 'error', + error: msg, + }, + timeElapsed: 0, + programIndex: -1, + }; + continue; + } + + channelContext = newChannelAndLineup.channel; + lineupItem = serverCtx.channelCache.getCurrentLineupItem( + channelContext.uuid, + startTimestamp, + ); + + if (!isUndefined(lineupItem)) { + lineupItem = deepCopy(lineupItem); + break; + } else { + currentProgram = await this.calculator.getCurrentProgramAndTimeElapsed( + startTimestamp, + channelContext, + newChannelAndLineup.lineup, + ); } } @@ -246,7 +240,6 @@ export class VideoStream { for (let i = redirectChannels.length - 1; i >= 0; i--) { const thisUpperBound = nth(upperBounds, i); if (!isNil(thisUpperBound)) { - console.log('adjusting upper bound....'); const nextBound = thisUpperBound + beginningOffset; const prevBound = isNil(lineupItem.streamDuration) ? upperBound @@ -310,10 +303,10 @@ export class VideoStream { }; const playerContext: PlayerContext = { - lineupItem: lineupItem, - ffmpegSettings: ffmpegSettings, + lineupItem, + ffmpegSettings, channel: combinedChannel, - m3u8: m3u8, + m3u8, audioOnly: audioOnly, // A little hacky... entityManager: ( @@ -322,7 +315,7 @@ export class VideoStream { settings: serverCtx.settings, }; - const player: ProgramPlayer = new ProgramPlayer(playerContext); + const player = new ProgramPlayer(playerContext); let stopped = false; const stop = () => { @@ -343,11 +336,10 @@ export class VideoStream { }); ffmpegEmitter?.on('end', () => { - this.logger.trace('playObj.end'); stop(); }); } catch (err) { - this.logger.error('Error when attempting to play video: %O', err); + this.logger.error(err, 'Error when attempting to play video'); stop(); return { type: 'error', diff --git a/server/src/stream/jellyfin/JellyfinPlayer.ts b/server/src/stream/jellyfin/JellyfinPlayer.ts new file mode 100644 index 00000000..587cfabd --- /dev/null +++ b/server/src/stream/jellyfin/JellyfinPlayer.ts @@ -0,0 +1,191 @@ +import constants from '@tunarr/shared/constants'; +import EventEmitter from 'events'; +import { isNil, isNull, isUndefined } from 'lodash-es'; +import { Writable } from 'stream'; +import { isContentBackedLineupIteam } from '../../dao/derived_types/StreamLineup.js'; +import { + MediaSource, + MediaSourceType, +} from '../../dao/entities/MediaSource.js'; +import { ProgramDB } from '../../dao/programDB.js'; +import { FFMPEG, FfmpegEvents } from '../../ffmpeg/ffmpeg.js'; +import { TypedEventEmitter } from '../../types/eventEmitter.js'; +import { Nullable } from '../../types/util.js'; +import { isDefined } from '../../util/index.js'; +import { LoggerFactory } from '../../util/logging/LoggerFactory.js'; +import { Player, PlayerContext } from '../Player.js'; +import { JellyfinStreamDetails } from './JellyfinStreamDetails.js'; + +export class JellyfinPlayer extends Player { + private logger = LoggerFactory.child({ + caller: import.meta, + className: JellyfinPlayer.name, + }); + private ffmpeg: Nullable = null; + private killed: boolean = false; + + constructor(private context: PlayerContext) { + super(); + } + + cleanUp() { + super.cleanUp(); + this.killed = true; + // ifDefined(this.updatePlexStatusTask, (task) => { + // task.stop(); + // }); + + if (!isNull(this.ffmpeg)) { + this.ffmpeg.kill(); + this.ffmpeg = null; + } + } + + async play( + outStream: Writable, + ): Promise | undefined> { + const lineupItem = this.context.lineupItem; + if (!isContentBackedLineupIteam(lineupItem)) { + throw new Error( + 'Lineup item is not backed by Plex: ' + JSON.stringify(lineupItem), + ); + } + + const ffmpegSettings = this.context.ffmpegSettings; + const db = this.context.entityManager.repo(MediaSource); + const channel = this.context.channel; + const server = await db.findOne({ + type: MediaSourceType.Jellyfin, + name: lineupItem.externalSourceId, + }); + + if (isNil(server)) { + throw new Error( + `Unable to find server "${lineupItem.externalSourceId}" specified by program.`, + ); + } + + // const plexSettings = this.context.settings.plexSettings(); + const jellyfinStreamDetails = new JellyfinStreamDetails( + server, + this.context.settings, + new ProgramDB(), + ); + + const watermark = this.context.watermark; + this.ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + this.ffmpeg.setAudioOnly(this.context.audioOnly); + + let streamDuration: number | undefined; + + if ( + !isUndefined(lineupItem.streamDuration) && + (lineupItem.start ?? 0) + lineupItem.streamDuration + constants.SLACK < + lineupItem.duration + ) { + streamDuration = lineupItem.streamDuration / 1000; + } + + const stream = await jellyfinStreamDetails.getStream(lineupItem); + if (isNull(stream)) { + this.logger.error('Unable to retrieve stream details from Jellyfin'); + return; + } + + if (this.killed) { + this.logger.warn('Stream was killed already, returning'); + return; + } + + if (isDefined(stream.streamDetails)) { + stream.streamDetails.duration = lineupItem.streamDuration; + } + + const streamUrl = new URL(stream.streamUrl); + streamUrl.searchParams.append( + 'startTimeTicks', + Math.round((lineupItem.start ?? 0) * 1000).toString(), + ); + + const emitter = new EventEmitter() as TypedEventEmitter; + let ffmpegOutStream = this.ffmpeg.spawnStream( + streamUrl.toString(), + stream.streamDetails, + // Don't use FFMPEG's -ss parameter for Jellyfin since we need to request + // the seek against their API instead + 0, + streamDuration?.toString(), + watermark, + { + 'X-Emby-Token': server.accessToken, + }, + ); // Spawn the ffmpeg process + + if (isUndefined(ffmpegOutStream)) { + throw new Error('Unable to spawn ffmpeg'); + } + + ffmpegOutStream.pipe(outStream, { end: false }); + + // if (plexSettings.updatePlayStatus) { + // this.updatePlexStatusTask = new UpdatePlexPlayStatusScheduledTask( + // server, + // { + // channelNumber: channel.number, + // duration: lineupItem.duration, + // ratingKey: lineupItem.externalKey, + // startTime: lineupItem.start ?? 0, + // }, + // ); + + // GlobalScheduler.scheduleTask( + // this.updatePlexStatusTask.id, + // this.updatePlexStatusTask, + // ); + // } + + this.ffmpeg.on('end', () => { + emitter.emit('end'); + }); + + this.ffmpeg.on('close', () => { + emitter.emit('close'); + }); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + this.ffmpeg.on('error', (err) => { + this.logger.debug('Replacing failed stream with error stream'); + ffmpegOutStream!.unpipe(outStream); + this.ffmpeg?.removeAllListeners(); + // TODO: Extremely weird logic here leftover, should sort this all out. + this.ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + this.ffmpeg.setAudioOnly(this.context.audioOnly); + this.ffmpeg.on('close', () => { + emitter.emit('close'); + }); + this.ffmpeg.on('end', () => { + emitter.emit('end'); + }); + this.ffmpeg.on('error', (err) => { + emitter.emit('error', err); + }); + + try { + ffmpegOutStream = this.ffmpeg.spawnError( + 'oops', + 'oops', + Math.min(stream.streamDetails?.duration ?? 30000, 60000), + ); + if (isUndefined(ffmpegOutStream)) { + throw new Error('Unable to spawn ffmpeg...what is going on here'); + } + ffmpegOutStream.pipe(outStream); + } catch (err) { + this.logger.error(err, 'Err while trying to spawn error stream! YIKES'); + } + + emitter.emit('error', err); + }); + return emitter; + } +} diff --git a/server/src/stream/jellyfin/JellyfinStreamDetails.ts b/server/src/stream/jellyfin/JellyfinStreamDetails.ts new file mode 100644 index 00000000..08af3aca --- /dev/null +++ b/server/src/stream/jellyfin/JellyfinStreamDetails.ts @@ -0,0 +1,255 @@ +import { + find, + first, + isNull, + replace, + trimEnd, + isUndefined, + attempt, + isError, + isEmpty, + trimStart, +} from 'lodash-es'; +import { MediaSource } from '../../dao/entities/MediaSource.js'; +import { ProgramDB } from '../../dao/programDB.js'; +import { SettingsDB } from '../../dao/settings.js'; +import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.js'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js'; +import { + isDefined, + isNonEmptyString, + nullToUndefined, +} from '../../util/index.js'; +import { Logger, LoggerFactory } from '../../util/logging/LoggerFactory.js'; +import { makeLocalUrl } from '../../util/serverUtil.js'; +import { PlexStream } from '../types.js'; +import { StreamDetails } from '../types.js'; +import { ContentBackedStreamLineupItem } from '../../dao/derived_types/StreamLineup.js'; +import { Nullable } from '../../types/util.js'; +import { isQueryError } from '../../external/BaseApiClient.js'; +import { JellyfinItem } from '@tunarr/types/jellyfin'; +import { ProgramType } from '../../dao/entities/Program.js'; + +// The minimum fields we need to get stream details about an item +// TODO: See if we need separate types for JF and Plex and what is really necessary here +type JellyfinItemStreamDetailsQuery = Pick< + ContentBackedStreamLineupItem, + 'programType' | 'externalKey' | 'plexFilePath' | 'filePath' | 'programId' +>; + +export class JellyfinStreamDetails { + private logger: Logger; + private jellyfin: JellyfinApiClient; + + constructor( + private server: MediaSource, + private settings: SettingsDB, + private programDB: ProgramDB, + ) { + this.logger = LoggerFactory.child({ + jellyfinServer: server.name, + // channel: channel.uuid, + caller: import.meta, + }); + + this.jellyfin = MediaSourceApiFactory().getJellyfinClient({ + apiKey: server.accessToken, + type: 'jellyfin', + url: server.uri, + name: server.name, + }); + } + + async getStream(item: JellyfinItemStreamDetailsQuery) { + return this.getStreamInternal(item); + } + + private async getStreamInternal( + item: JellyfinItemStreamDetailsQuery, + depth: number = 0, + ): Promise> { + if (depth > 1) { + return null; + } + + const expectedItemType = item.programType; + const itemMetadataResult = await this.jellyfin.getItem(item.externalKey); + + if (isQueryError(itemMetadataResult)) { + this.logger.error(itemMetadataResult, 'Error getting Jellyfin stream'); + return null; + } else if (isUndefined(itemMetadataResult.data)) { + this.logger.error( + 'Jellyfin item with ID %s does not exist', + item.externalKey, + ); + return null; + } + + const itemMetadata = itemMetadataResult.data; + + if (expectedItemType !== jellyfinItemTypeToProgramType(itemMetadata)) { + this.logger.warn( + 'Got unexpected item type %s from Plex (ID = %s) when starting stream. Expected item type %s', + itemMetadata.Type, + item.externalKey, + expectedItemType, + ); + return null; + } + + const details = await this.getItemStreamDetails(item, itemMetadata); + + if (isNull(details)) { + return null; + } + + if ( + isNonEmptyString(details.serverPath) && + details.serverPath !== item.plexFilePath + ) { + this.programDB + .updateProgramPlexRatingKey(item.programId, this.server.name, { + externalKey: item.externalKey, + externalFilePath: details.serverPath, + directFilePath: details.directFilePath, + }) + .catch((err) => { + this.logger.error( + err, + 'Error while updating Plex file path for program %s', + item.programId, + ); + }); + } + + const streamSettings = this.settings.plexSettings(); + + let streamUrl: string; + const filePath = + details.directFilePath ?? first(itemMetadata?.MediaSources)?.Path; + if (streamSettings.streamPath === 'direct' && isNonEmptyString(filePath)) { + streamUrl = replace( + filePath, + streamSettings.pathReplace, + streamSettings.pathReplaceWith, + ); + } else { + const path = details.serverPath ?? item.plexFilePath; + if (isNonEmptyString(path)) { + streamUrl = `${trimEnd(this.server.uri, '/')}/Videos/${trimStart( + path, + '/', + )}/stream`; + } else { + throw new Error('Could not resolve stream URL'); + } + } + + return { + directPlay: true, + streamUrl, + streamDetails: details, + }; + } + + private async getItemStreamDetails( + item: JellyfinItemStreamDetailsQuery, + media: JellyfinItem, + ): Promise> { + const streamDetails: StreamDetails = {}; + const firstMediaSource = first(media.MediaSources); + streamDetails.serverPath = nullToUndefined(firstMediaSource?.Id); + streamDetails.directFilePath = nullToUndefined(firstMediaSource?.Path); + + // const firstStream = firstPart?.Stream; + // if (isUndefined(firstStream)) { + // this.logger.error( + // 'Could not extract a stream for Jellyfin item ID = %s', + // item.externalKey, + // ); + // } + + const videoStream = find( + firstMediaSource?.MediaStreams, + (stream) => stream.Type === 'Video', + ); + + const audioStream = find( + firstMediaSource?.MediaStreams, + (stream) => stream.Type === 'Audio' && !!stream.IsDefault, + ); + const audioOnly = isUndefined(videoStream) && !isUndefined(audioStream); + + // Video + if (isDefined(videoStream)) { + // TODO Parse pixel aspect ratio + streamDetails.anamorphic = !!videoStream.IsAnamorphic; + streamDetails.videoCodec = nullToUndefined(videoStream.Codec); + // Keeping old behavior here for now + streamDetails.videoFramerate = videoStream.AverageFrameRate + ? Math.round(videoStream.AverageFrameRate) + : undefined; + streamDetails.videoHeight = nullToUndefined(videoStream.Height); + streamDetails.videoWidth = nullToUndefined(videoStream.Width); + streamDetails.videoBitDepth = nullToUndefined(videoStream.BitDepth); + streamDetails.pixelP = 1; + streamDetails.pixelQ = 1; + } + + if (isDefined(audioStream)) { + streamDetails.audioChannels = nullToUndefined(audioStream.Channels); + streamDetails.audioCodec = nullToUndefined(audioStream.Codec); + streamDetails.audioIndex = + nullToUndefined(audioStream.Index?.toString()) ?? 'a'; + } + + if (isUndefined(videoStream) && isUndefined(audioStream)) { + this.logger.warn( + 'Could not find a video nor audio stream for Plex item %s', + item.externalKey, + ); + return null; + } + + if (audioOnly) { + // TODO Use our proxy endpoint here + const placeholderThumbPath = + media.Type === 'Audio' + ? media.AlbumId ?? first(media.ArtistItems)?.Id ?? media.Id + : media.SeasonId ?? media.Id; + + // We have to check that we can hit this URL or the stream will not work + if (isNonEmptyString(placeholderThumbPath)) { + const path = `/Items/${placeholderThumbPath}/Images/Primary`; + const result = await attempt(() => this.jellyfin.doHead({ url: path })); + if (!isError(result)) { + streamDetails.placeholderImage = this.jellyfin.getFullUrl(path); + } + } + + if (isEmpty(streamDetails.placeholderImage)) { + streamDetails.placeholderImage = makeLocalUrl( + '/images/generic-music-screen.png', + ); + } + } + + streamDetails.audioOnly = audioOnly; + + return streamDetails; + } +} + +function jellyfinItemTypeToProgramType(item: JellyfinItem) { + switch (item.Type) { + case 'Movie': + return ProgramType.Movie; + case 'Episode': + return ProgramType.Episode; + case 'Audio': + return ProgramType.Track; + default: + return null; + } +} diff --git a/server/src/stream/plex/PlexPlayer.ts b/server/src/stream/plex/PlexPlayer.ts index 8593a69d..2924f690 100644 --- a/server/src/stream/plex/PlexPlayer.ts +++ b/server/src/stream/plex/PlexPlayer.ts @@ -3,10 +3,13 @@ import EventEmitter from 'events'; import { isNil, isNull, isUndefined } from 'lodash-es'; import { Writable } from 'stream'; import { isContentBackedLineupIteam } from '../../dao/derived_types/StreamLineup.js'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; +import { + MediaSource, + MediaSourceType, +} from '../../dao/entities/MediaSource.js'; import { FFMPEG, FfmpegEvents } from '../../ffmpeg/ffmpeg.js'; import { GlobalScheduler } from '../../services/scheduler.js'; -import { UpdatePlexPlayStatusScheduledTask } from '../../tasks/UpdatePlexPlayStatusTask.js'; +import { UpdatePlexPlayStatusScheduledTask } from '../../tasks/plex/UpdatePlexPlayStatusTask.js'; import { TypedEventEmitter } from '../../types/eventEmitter.js'; import { Maybe, Nullable } from '../../types/util.js'; import { ifDefined } from '../../util/index.js'; @@ -61,9 +64,12 @@ export class PlexPlayer extends Player { } const ffmpegSettings = this.context.ffmpegSettings; - const db = this.context.entityManager.repo(PlexServerSettings); + const db = this.context.entityManager.repo(MediaSource); const channel = this.context.channel; - const server = await db.findOne({ name: lineupItem.externalSourceId }); + const server = await db.findOne({ + name: lineupItem.externalSourceId, + type: MediaSourceType.Plex, + }); if (isNil(server)) { throw Error( `Unable to find server "${lineupItem.externalSourceId}" specified by program.`, diff --git a/server/src/stream/plex/PlexStreamDetails.ts b/server/src/stream/plex/PlexStreamDetails.ts index d6e18dca..428b4ee2 100644 --- a/server/src/stream/plex/PlexStreamDetails.ts +++ b/server/src/stream/plex/PlexStreamDetails.ts @@ -17,22 +17,20 @@ import { replace, trimEnd, } from 'lodash-es'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings'; -import { - Plex, - isPlexQueryError, - isPlexQuerySuccess, -} from '../../external/plex'; -import { PlexApiFactory } from '../../external/PlexApiFactory'; +import { MediaSource } from '../../dao/entities/MediaSource'; +import { PlexApiClient } from '../../external/plex/PlexApiClient'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory'; import { Nullable } from '../../types/util'; import { Logger, LoggerFactory } from '../../util/logging/LoggerFactory'; -import { PlexStream, StreamDetails } from './PlexTranscoder'; +import { PlexStream } from '../types'; +import { StreamDetails } from '../types'; import { attempt, isNonEmptyString } from '../../util'; import { ContentBackedStreamLineupItem } from '../../dao/derived_types/StreamLineup.js'; import { SettingsDB } from '../../dao/settings.js'; import { makeLocalUrl } from '../../util/serverUtil.js'; import { ProgramDB } from '../../dao/programDB'; import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType'; +import { isQueryError, isQuerySuccess } from '../../external/BaseApiClient.js'; // The minimum fields we need to get stream details about an item type PlexItemStreamDetailsQuery = Pick< @@ -48,10 +46,10 @@ type PlexItemStreamDetailsQuery = Pick< */ export class PlexStreamDetails { private logger: Logger; - private plex: Plex; + private plex: PlexApiClient; constructor( - private server: PlexServerSettings, + private server: MediaSource, private settings: SettingsDB, private programDB: ProgramDB, ) { @@ -61,7 +59,7 @@ export class PlexStreamDetails { caller: import.meta, }); - this.plex = PlexApiFactory().get(this.server); + this.plex = MediaSourceApiFactory().get(this.server); } async getStream(item: PlexItemStreamDetailsQuery) { @@ -81,7 +79,7 @@ export class PlexStreamDetails { item.externalKey, ); - if (isPlexQueryError(itemMetadataResult)) { + if (isQueryError(itemMetadataResult)) { if (itemMetadataResult.code === 'not_found') { this.logger.debug( 'Could not find item %s in Plex. Rating key may have changed. Attempting to update.', @@ -105,7 +103,7 @@ export class PlexStreamDetails { }, ); - if (isPlexQuerySuccess(byGuidResult)) { + if (isQuerySuccess(byGuidResult)) { if (byGuidResult.data.MediaContainer.size > 0) { this.logger.debug( 'Found %d matching items in library. Using the first', @@ -286,7 +284,7 @@ export class PlexStreamDetails { // We have to check that we can hit this URL or the stream will not work if (isNonEmptyString(placeholderThumbPath)) { const result = await attempt(() => - this.plex.doHead(placeholderThumbPath), + this.plex.doHead({ url: placeholderThumbPath }), ); if (!isError(result)) { streamDetails.placeholderImage = diff --git a/server/src/stream/plex/PlexTranscoder.ts b/server/src/stream/plex/PlexTranscoder.ts deleted file mode 100644 index 37246636..00000000 --- a/server/src/stream/plex/PlexTranscoder.ts +++ /dev/null @@ -1,775 +0,0 @@ -import { PlexStreamSettings } from '@tunarr/types'; -import { first, isNil, isUndefined, pick } from 'lodash-es'; -import { constants as fsConstants } from 'node:fs'; -import * as fs from 'node:fs/promises'; -import { stringify } from 'node:querystring'; -import { DeepReadonly } from 'ts-essentials'; -import { v4 as uuidv4 } from 'uuid'; -import { ContentBackedStreamLineupItem } from '../../dao/derived_types/StreamLineup.js'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; -import { serverOptions } from '../../globals.js'; -import { Plex } from '../../external/plex.js'; -import { PlexApiFactory } from '../../external/PlexApiFactory.js'; -import { StreamContextChannel } from '../types.js'; -import { Maybe } from '../../types/util.js'; -import { - PlexItemMetadata, - PlexMediaContainer, - PlexMediaVideoStream, - TranscodeDecision, - TranscodeDecisionMediaStream, - isPlexVideoStream, -} from '../../types/plexApiTypes.js'; -import { Logger, LoggerFactory } from '../../util/logging/LoggerFactory.js'; - -export type PlexStream = { - directPlay: boolean; - streamUrl: string; - separateVideoStream?: string; - streamDetails?: StreamDetails; -}; - -export type StreamDetails = { - duration?: number; - anamorphic?: boolean; - pixelP?: number; - pixelQ?: number; - - videoCodec?: string; - videoWidth?: number; - videoHeight?: number; - videoFramerate?: number; - videoDecision?: string; - videoScanType?: string; - videoBitDepth?: number; - - audioDecision?: string; - audioOnly?: boolean; - audioChannels?: number; - audioCodec?: string; - audioIndex?: string; - - placeholderImage?: string; - - serverPath?: string; - directFilePath?: string; -}; - -export class PlexTranscoder { - private logger: Logger; - private session: string; - private device: string; - private deviceName: string; - private clientIdentifier: string; - private product: string; - private settings: DeepReadonly; - private plexFile: string; - private file: string; - private transcodeUrlBase: string; - private ratingKey: string; - private currTimeMs: number; - public currTimeS: number; - private duration: number; - private server: PlexServerSettings; - private transcodingArgs: string | undefined; - private decisionJson: Maybe>; - private updateInterval: number; - private updatingPlex: NodeJS.Timeout | undefined; - private playState: string; - private mediaHasNoVideo: boolean; - private albumArt: { path?: string; attempted: boolean }; - private plex: Plex; - private directInfo?: PlexItemMetadata; - private videoIsDirect: boolean = false; - private cachedItemMetadata: Maybe; - - constructor( - clientId: string, - server: PlexServerSettings, - settings: DeepReadonly, - channel: StreamContextChannel, - lineupItem: ContentBackedStreamLineupItem, - ) { - this.logger = LoggerFactory.child({ - plexServer: server.name, - channel: channel.uuid, - caller: import.meta, - }); - this.session = uuidv4(); - - this.device = 'channel-' + channel.number; - this.deviceName = this.device; - this.clientIdentifier = clientId; - this.product = 'Tunarr'; - - this.settings = settings; - - if (settings.enableDebugLogging) { - this.log('Plex transcoder initiated'); - this.log('Debug logging enabled'); - } - - this.plex = PlexApiFactory().get(server); - // this.metadataPath = `${lineupItem.key}?X-Plex-Token=${server.accessToken}`; - this.plexFile = `${server.uri}${lineupItem.plexFilePath}?X-Plex-Token=${server.accessToken}`; - if (!isUndefined(lineupItem.filePath)) { - this.file = lineupItem.filePath.replace( - settings.pathReplace, - settings.pathReplaceWith, - ); - } - this.transcodeUrlBase = `${server.uri}/video/:/transcode/universal/start.m3u8?`; - this.ratingKey = lineupItem.externalKey; - this.currTimeMs = lineupItem.start ?? 0; - this.currTimeS = this.currTimeMs / 1000; - this.duration = lineupItem.duration; - this.server = server; - - this.transcodingArgs = undefined; - this.decisionJson = undefined; - - this.updateInterval = 30000; - this.updatingPlex = undefined; - this.playState = 'stopped'; - this.mediaHasNoVideo = false; - this.albumArt = { - attempted: false, - }; - } - - async getStream(deinterlace: boolean): Promise { - // let stream: PlexStream = { directPlay: false }; - let directPlay: boolean = false; - let streamUrl: string; - let separateVideoStream: Maybe; - - this.log('Getting stream'); - this.log(` deinterlace: ${deinterlace}`); - this.log(` streamPath: ${this.settings.streamPath}`); - - this.setTranscodingArgs(directPlay, true, false, false); - await this.tryToDetectAudioOnly(); - - if ( - this.settings.streamPath === 'direct' || - this.settings.forceDirectPlay - ) { - if (this.settings.enableSubtitles) { - this.log('Direct play is forced, so subtitles are forcibly disabled.'); - this.settings = { ...this.settings, enableSubtitles: false }; - } - directPlay = true; - } else { - try { - this.log('Setting transcoding parameters'); - this.setTranscodingArgs( - directPlay, - true, - deinterlace, - this.mediaHasNoVideo, - ); - await this.getDecision(directPlay); - if (this.isDirectPlay()) { - directPlay = true; - streamUrl = this.plexFile; - } - } catch (err) { - console.error( - "Error when getting decision. 1. Check Plex connection. 2. This might also be a sign that plex direct play and transcode settings are too strict and it can't find any allowed action for the selected video.", - err, - ); - directPlay = true; - } - } - if (directPlay /* || this.isAV1()*/) { - // if (!directPlay) { - // this.log( - // "Plex doesn't support av1, so we are forcing direct play, including for audio because otherwise plex breaks the stream.", - // ); - // } - this.log('Direct play forced or native paths enabled'); - directPlay = true; - this.setTranscodingArgs(directPlay, true, false, this.mediaHasNoVideo); - // Update transcode decision for session - await this.getDecision(directPlay); - streamUrl = - this.settings.streamPath === 'direct' ? this.file : this.plexFile; - if (this.settings.streamPath === 'direct') { - await fs.access(this.file, fsConstants.F_OK); - } - if (isUndefined(streamUrl)) { - throw Error( - 'Direct path playback is not possible for this program because it was registered at a time when the direct path settings were not set. To fix this, you must either revert the direct path setting or rebuild this channel.', - ); - } - } else if (!this.isVideoDirectStream()) { - this.log('Decision: Should transcode'); - // Change transcoding arguments to be the user chosen transcode parameters - this.setTranscodingArgs( - directPlay, - false, - deinterlace, - this.mediaHasNoVideo, - ); - // Update transcode decision for session - await this.getDecision(directPlay); - streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}`; - } else { - //This case sounds complex. Apparently plex is sending us just the audio, so we would need to get the video in a separate stream. - this.log('Decision: Direct stream. Audio is being transcoded'); - separateVideoStream = - this.settings.streamPath === 'direct' ? this.file : this.plexFile; - streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}`; - this.directInfo = await this.getDirectInfo(); - this.videoIsDirect = true; - } - - const streamStats = this.getVideoStats(); - - // use correct audio stream if direct play - streamStats.audioIndex = directPlay ? await this.getAudioIndex() : 'a'; - - const stream: PlexStream = { - directPlay, - streamUrl, - separateVideoStream, - streamDetails: streamStats, - }; - - this.log('PlexStream: %O', stream); - - return stream; - } - - setTranscodingArgs( - directPlay: boolean, - directStream: boolean, - deinterlace: boolean, - audioOnly: boolean, - ) { - const resolution = directStream - ? this.settings.maxPlayableResolution - : this.settings.maxTranscodeResolution; - const bitrate = directStream - ? this.settings.directStreamBitrate - : this.settings.transcodeBitrate; - const mediaBufferSize = directStream - ? this.settings.mediaBufferSize - : this.settings.transcodeMediaBufferSize; - const subtitles = this.settings.enableSubtitles ? 'burn' : 'none'; // subtitle options: burn, none, embedded, sidecar - const streamContainer = 'mpegts'; // Other option is mkv, mkv has the option of copying it's subs for later processing - const isDirectPlay = directPlay ? '1' : '0'; - const hasMDE = '1'; - - const videoQuality = `100`; // Not sure how this applies, maybe this works if maxVideoBitrate is not set - const profileName = `Generic`; // Blank profile, everything is specified through X-Plex-Client-Profile-Extra - - const vc = [...this.settings.videoCodecs]; - //This codec is not currently supported by plex so requesting it to transcode will always - // cause an error. If Plex ever supports av1, remove this. I guess. - // UPDATE: Plex 1.30.1 added AV1 playback support - experimentally removing this clause here. - // if (vc.length > 0) { - // vc.push('av1'); - // } else { - // vc = ['av1']; - // } - - // let clientProfile = ''; - const clientProfileParts: string[] = []; - if (!audioOnly) { - clientProfileParts.push( - transcodeTarget({ - type: 'videoProfile', - protocol: this.settings.streamProtocol, - container: streamContainer, - videoCodecs: vc, - audioCodecs: this.settings.audioCodecs, - subtitleCodecs: [], - }), - transcodeTargetSettings({ - type: 'videoProfile', - protocol: this.settings.streamProtocol, - settings: { - CopyMatroskaAttachments: true, - }, - }), - transcodeTargetSettings({ - type: 'videoProfile', - protocol: this.settings.streamProtocol, - settings: { BreakNonKeyframes: true }, - }), - transcodeLimitation({ - scope: 'videoCodec', - scopeName: '*', - type: 'upperBound', - name: 'video.width', - value: resolution.widthPx, - }), - transcodeLimitation({ - scope: 'videoCodec', - scopeName: '*', - type: 'upperBound', - name: 'video.height', - value: resolution.heightPx, - }), - ); - } else { - clientProfileParts.push( - transcodeTarget({ - type: 'musicProfile', - protocol: this.settings.streamProtocol, - container: streamContainer, - audioCodecs: this.settings.audioCodecs, - }), - ); - } - - // Set transcode settings per audio codec - this.settings.audioCodecs.forEach((codec) => { - clientProfileParts.push( - transcodeAudioTarget({ - type: 'videoProfile', - protocol: this.settings.streamProtocol, - audioCodec: codec, - }), - ); - if (codec == 'mp3') { - clientProfileParts.push( - transcodeLimitation({ - scope: 'videoAudioCodec', - scopeName: codec, - type: 'upperBound', - name: 'audio.channels', - value: 2, - }), - ); - } else { - clientProfileParts.push( - transcodeLimitation({ - scope: 'videoAudioCodec', - scopeName: codec, - type: 'upperBound', - name: 'audio.channels', - value: this.settings.maxAudioChannels, - }), - ); - } - }); - - // deinterlace video if specified, only useful if overlaying channel logo later - if (deinterlace == true) { - clientProfileParts.push( - transcodeLimitation({ - scope: 'videoCodec', - scopeName: '*', - type: 'notMatch', - name: 'video.scanType', - value: 'interlaced', - }), - ); - } - - const clientProfile_enc = encodeURIComponent(clientProfileParts.join('+')); - this.transcodingArgs = `X-Plex-Platform=${profileName}&\ -X-Plex-Product=${this.product}&\ -X-Plex-Client-Platform=${profileName}&\ -X-Plex-Client-Profile-Name=${profileName}&\ -X-Plex-Device-Name=${this.deviceName}&\ -X-Plex-Device=${this.device}&\ -X-Plex-Client-Identifier=${this.clientIdentifier}&\ -X-Plex-Platform=${profileName}&\ -X-Plex-Token=${this.server.accessToken}&\ -X-Plex-Client-Profile-Extra=${clientProfile_enc}&\ -protocol=${this.settings.streamProtocol}&\ -Connection=keep-alive&\ -hasMDE=${hasMDE}&\ -path=/library/metadata/${this.ratingKey}&\ -mediaIndex=0&\ -partIndex=0&\ -fastSeek=1&\ -directPlay=${isDirectPlay}&\ -directStream=1&\ -directStreamAudio=1&\ -copyts=1&\ -audioBoost=${this.settings.audioBoost}&\ -mediaBufferSize=${mediaBufferSize}&\ -session=${this.session}&\ -offset=${this.currTimeS}&\ -subtitles=${subtitles}&\ -subtitleSize=${this.settings.subtitleSize}&\ -maxVideoBitrate=${bitrate}&\ -videoQuality=${videoQuality}&\ -videoResolution=${resolution.widthPx}x${resolution.heightPx}&\ -lang=en`; - } - - isVideoDirectStream() { - try { - return this.getVideoStats().videoDecision === 'copy'; - } catch (e) { - console.error('Error at decision:', e); - return false; - } - } - - isAV1() { - try { - return this.getVideoStats().videoCodec === 'av1'; - } catch (e) { - return false; - } - } - - isDirectPlay() { - try { - const videoStats = this.getVideoStats(); - if (videoStats.audioOnly) { - return videoStats.audioDecision === 'copy'; - } - return ( - videoStats.videoDecision === 'copy' && - videoStats.audioDecision === 'copy' - ); - } catch (e) { - console.error('Error at decision:', e); - return false; - } - } - - // TODO - cache this somehow so we only update VideoStats if decisionJson or directInfo change - getVideoStats(): StreamDetails { - const ret: Partial = {}; - - try { - const streams: TranscodeDecisionMediaStream[] = - this.decisionJson?.Metadata[0].Media[0].Part[0].Stream ?? []; - ret.duration = this.decisionJson?.Metadata[0].Media[0].Part[0].duration; - streams.forEach((_stream, idx) => { - // Video - if (_stream.streamType === 1) { - let stream: TranscodeDecisionMediaStream | PlexMediaVideoStream = - _stream; - if (this.videoIsDirect && !isNil(this.directInfo)) { - const directStream = - this.directInfo.Metadata[0].Media[0].Part[0].Stream[idx]; - if (isPlexVideoStream(directStream)) { - stream = directStream; - } - } - ret.anamorphic = - stream.anamorphic === '1' || stream.anamorphic === true; - if (ret.anamorphic) { - const parsed = parsePixelAspectRatio(stream.pixelAspectRatio); - if (isUndefined(parsed)) { - throw Error( - 'Unable to parse pixelAspectRatio: ' + stream.pixelAspectRatio, - ); - } - ret.pixelP = parsed.p; - ret.pixelQ = parsed.q; - } else { - ret.pixelP = 1; - ret.pixelQ = 1; - } - ret.videoCodec = stream.codec; - ret.videoWidth = stream.width; - ret.videoHeight = stream.height; - ret.videoFramerate = Math.round(stream.frameRate); - // Rounding framerate avoids scenarios where - // 29.9999999 & 30 don't match. - ret.videoDecision = isUndefined(stream['decision'] as Maybe) - ? 'copy' - : (stream['decision'] as string); - ret.videoScanType = stream.scanType; - } - - // Audio. Only look at stream being used - if (_stream.streamType === 2 && _stream.selected) { - ret.audioChannels = _stream.channels; - ret.audioCodec = _stream.codec; - ret.audioDecision = isUndefined(_stream.decision) - ? 'copy' - : _stream.decision; - } - }); - } catch (e) { - console.error('Error at decision:', e); - } - - if (isUndefined(ret.videoCodec)) { - ret.audioOnly = true; - ret.placeholderImage = !isNil(this.albumArt?.path) - ? (ret.placeholderImage = this.albumArt.path) - : (ret.placeholderImage = `http://localhost:${ - serverOptions().port - }/images/generic-music-screen.png`); - } - - this.log('Current video stats: %O', ret); - - return ret as Required; // This isn't technically right, but this is how the current code treats this - } - - private async getAudioIndex() { - let index: string | number = 'a'; - // Duplicate call to API here ... we should try to keep a cache. - const response = await this.getPlexItemMetadata(); - this.log('Got Plex item metadata response: %O', response); - - if (isUndefined(response)) { - return index; - } - - try { - const streams = response.Metadata[0].Media[0].Part[0].Stream; - - streams.forEach(function (stream) { - // Audio. Only look at stream being used - if (stream.streamType === 2 && stream.selected) { - index = stream.index; - } - }); - } catch (e) { - console.error('Error at get media info:' + e); - } - - this.log(`Found audio index: ${index}`); - - return index; - } - - async getDirectInfo() { - return this.getPlexItemMetadata(); - } - async tryToDetectAudioOnly() { - try { - this.log('Try to detect audio only:'); - const mediaContainer = await this.getPlexItemMetadata(); - - const metadata = first(mediaContainer?.Metadata); - if (!isUndefined(metadata)) { - this.albumArt = this.albumArt || {}; - this.albumArt.path = `${this.server.uri}${metadata.thumb}?X-Plex-Token=${this.server.accessToken}`; - - const media = first(metadata.Media); - if (!isUndefined(media)) { - if (isUndefined(media.videoCodec)) { - this.log('Audio-only file detected'); - this.mediaHasNoVideo = true; - } - } - } - } catch (err) { - console.error('Error when getting album art', err); - } - } - - async getDecisionUnmanaged(directPlay: boolean) { - this.decisionJson = await this.plex.doGet( - `/video/:/transcode/universal/decision?${this.transcodingArgs}`, - ); - - if (isUndefined(this.decisionJson)) { - throw new Error('Got unexpected undefined response from Plex'); - } - - this.log('Received transcode decision: %O', this.decisionJson); - - // Print error message if transcode not possible - // TODO: handle failure better - // mdeDecisionCode doesn't seem to exist on later Plex versions... - // if (response.mdeDecisionCode === 1000) { - // this.log("mde decision code 1000, so it's all right?"); - // return; - // } - - const transcodeDecisionCode = this.decisionJson.transcodeDecisionCode; - if (isUndefined(transcodeDecisionCode)) { - this.log('Strange case, attempt direct play'); - } else if (!(directPlay || transcodeDecisionCode == 1001)) { - this.log( - `IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`, - ); - this.log(`Error message: '${this.decisionJson.transcodeDecisionText}'`); - } - } - - async getDecision(directPlay: boolean) { - try { - await this.getDecisionUnmanaged(directPlay); - } catch (err) { - console.error(err); - } - } - - private getStatusUrl(): { - path: string; - params: Record; - } { - const profileName = `Generic`; - - const containerKey = `/video/:/transcode/universal/decision?${this.transcodingArgs}`; - const containerKey_enc = encodeURIComponent(containerKey); - - return { - path: '/:/timeline', - params: { - containerKey: containerKey_enc, - ratingKey: this.ratingKey, - state: this.playState, - key: `/library/metadata/${this.ratingKey}`, - time: this.currTimeMs, - duration: this.duration, - 'X-Plex-Product': this.product, - 'X-Plex-Platform': profileName, - 'X-Plex-Client-Platform': profileName, - 'X-Plex-Client-Profile-Name': profileName, - 'X-Plex-Device-Name': this.deviceName, - 'X-Plex-Device': this.device, - 'X-Plex-Client-Identifier': this.clientIdentifier, - 'X-Plex-Token': this.server.accessToken, - }, - }; - } - - private async getPlexItemMetadata(force: boolean = false) { - if (!force && !isUndefined(this.cachedItemMetadata)) { - this.log('Using cached response from Plex for metadata'); - return this.cachedItemMetadata; - } - - this.cachedItemMetadata = await this.plex.doGet( - `/library/metadata/${this.ratingKey}`, - ); - return this.cachedItemMetadata; - } - - async startUpdatingPlex() { - if (this.settings.updatePlayStatus == true) { - this.playState = 'playing'; - await this.updatePlex(); // do initial update - this.updatingPlex = setInterval( - // eslint-disable-next-line @typescript-eslint/no-misused-promises - async () => await this.updatePlex(), - this.updateInterval, - ); - } - } - - async stopUpdatingPlex() { - if (this.settings.updatePlayStatus == true) { - clearInterval(this.updatingPlex); - this.playState = 'stopped'; - await this.updatePlex(); - } - } - - private async updatePlex() { - this.log('Updating plex status'); - const { path: statusUrl, params } = this.getStatusUrl(); - try { - await this.plex.doPost(statusUrl, params); - } catch (error) { - this.logger.warn( - `Problem updating Plex status using status URL ${statusUrl}: `, - error, - ); - return false; - } - this.currTimeMs += this.updateInterval; - if (this.currTimeMs > this.duration) { - this.currTimeMs = this.duration; - } - this.currTimeS = this.duration / 1000; - return true; - } - - //(obj: unknown, msg?: string, ...args: any[]): void; - //(msg: string, ...args: any[]): void; - // private log(obj: unknown, msg?: string, ...args: unknown[]): void; - private log(obj: object, msg?: string, ...args: unknown[]): void; - private log(msg: string, ...args: unknown[]); - private log( - t0: string | object, - msg: string | undefined, - ...rest: unknown[] - ): void { - if (this.settings.enableDebugLogging) { - return this.logger.debug(t0, msg, ...rest); - } else { - return this.logger.trace(t0, msg, ...rest); - } - } -} - -function parsePixelAspectRatio(s: Maybe) { - if (isUndefined(s)) return; - const x = s.split(':'); - return { - p: parseInt(x[0], 10), - q: parseInt(x[1], 10), - }; -} - -function transcodeTarget(opts: { - type: 'videoProfile' | 'musicProfile'; - protocol: string; - container: string; - videoCodecs?: ReadonlyArray; - audioCodecs?: ReadonlyArray; - subtitleCodecs?: ReadonlyArray; -}) { - const parts = { - ...pick(opts, ['type', 'protocol', 'container']), - subtitleCodec: (opts.subtitleCodecs ?? []).join(','), - context: 'streaming', - replace: true, - }; - - if (opts.videoCodecs) { - parts['videoCodec'] = opts.videoCodecs.join(','); - } - - if (opts.audioCodecs) { - parts['audioCodec'] = opts.audioCodecs.join(','); - } - - return `add-transcode-target(${stringify(parts)})`; -} - -function transcodeTargetSettings(opts: { - type: string; - protocol: string; - settings: Record; -}) { - const parts = { - ...pick(opts, ['type', 'protocol']), - ...opts.settings, - context: 'streaming', - }; - - return `add-transcode-target-settings(${stringify(parts)})`; -} - -function transcodeLimitation(opts: { - scope: string; - scopeName: string; - type: string; - name: string; - value: string | number; -}) { - return `add-limitation(${stringify(opts)})`; -} - -function transcodeAudioTarget(opts: { - type: 'videoProfile'; - protocol: string; - audioCodec: string; -}) { - const parts = { - ...opts, - context: 'streaming', - }; - - return `add-transcode-target-audio-codec(${stringify(parts)})`; -} diff --git a/server/src/stream/types.ts b/server/src/stream/types.ts index f4c702c0..cc677f56 100644 --- a/server/src/stream/types.ts +++ b/server/src/stream/types.ts @@ -17,3 +17,35 @@ export type StreamContextChannel = Pick< Channel & { offlinePicture?: string; offlineSoundtrack?: string }, TupleToUnion >; +export type StreamDetails = { + duration?: number; + anamorphic?: boolean; + pixelP?: number; + pixelQ?: number; + + videoCodec?: string; + videoWidth?: number; + videoHeight?: number; + videoFramerate?: number; + videoDecision?: string; + videoScanType?: string; + videoBitDepth?: number; + + audioDecision?: string; + audioOnly?: boolean; + audioChannels?: number; + audioCodec?: string; + audioIndex?: string; + + placeholderImage?: string; + + serverPath?: string; + directFilePath?: string; +}; + +export type PlexStream = { + directPlay: boolean; + streamUrl: string; + separateVideoStream?: string; + streamDetails?: StreamDetails; +}; diff --git a/server/src/tasks/ReconcileProgramDurationsTask.ts b/server/src/tasks/ReconcileProgramDurationsTask.ts index bf4221ee..c31234dc 100644 --- a/server/src/tasks/ReconcileProgramDurationsTask.ts +++ b/server/src/tasks/ReconcileProgramDurationsTask.ts @@ -81,7 +81,7 @@ export class ReconcileProgramDurationsTask extends Task { !isUndefined(dbItemDuration) && dbItemDuration !== item.durationMs ) { - console.debug('Found duration mismatch: %s', item.id); + this.logger.debug('Found duration mismatch: %s', item.id); changed = true; return { ...item, diff --git a/server/src/tasks/TaskQueue.ts b/server/src/tasks/TaskQueue.ts index 7c06f34f..c73f5749 100644 --- a/server/src/tasks/TaskQueue.ts +++ b/server/src/tasks/TaskQueue.ts @@ -53,3 +53,9 @@ export const PlexTaskQueue = new TaskQueue('PlexTaskQueue', { intervalCap: 5, interval: 2000, }); + +export const JellyfinTaskQueue = new TaskQueue('JellyfinTaskQueue', { + concurrency: 2, + intervalCap: 5, + interval: 2000, +}); diff --git a/server/src/tasks/UpdateXmlTvTask.ts b/server/src/tasks/UpdateXmlTvTask.ts index ac504fbd..531cb59b 100644 --- a/server/src/tasks/UpdateXmlTvTask.ts +++ b/server/src/tasks/UpdateXmlTvTask.ts @@ -3,9 +3,9 @@ import { PlexDvr } from '@tunarr/types/plex'; import dayjs from 'dayjs'; import { ChannelDB } from '../dao/channelDb.js'; import { withDb } from '../dao/dataSource.js'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; +import { MediaSource } from '../dao/entities/MediaSource.js'; import { SettingsDB, defaultXmlTvSettings } from '../dao/settings.js'; -import { Plex } from '../external/plex.js'; +import { PlexApiClient } from '../external/plex/PlexApiClient.js'; import { globalOptions } from '../globals.js'; import { ServerContext } from '../serverContext.js'; import { TVGuideService } from '../services/tvGuideService.js'; @@ -88,11 +88,11 @@ export class UpdateXmlTvTask extends Task { const channels = await this.#channelDB.getAllChannels(); const allPlexServers = await withDb((em) => { - return em.find(PlexServerSettings, {}); + return em.find(MediaSource, {}); }); await mapAsyncSeq(allPlexServers, async (plexServer) => { - const plex = new Plex(plexServer); + const plex = new PlexApiClient(plexServer); let dvrs: PlexDvr[] = []; if (!plexServer.sendGuideUpdates && !plexServer.sendChannelUpdates) { diff --git a/server/src/tasks/fixers/BackfillProgramExternalIds.ts b/server/src/tasks/fixers/BackfillProgramExternalIds.ts index 069b291c..a4a27c58 100644 --- a/server/src/tasks/fixers/BackfillProgramExternalIds.ts +++ b/server/src/tasks/fixers/BackfillProgramExternalIds.ts @@ -10,11 +10,11 @@ import { import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType'; import { ProgramSourceType } from '../../dao/custom_types/ProgramSourceType.js'; import { getEm } from '../../dao/dataSource'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; +import { MediaSource } from '../../dao/entities/MediaSource.js'; import { Program } from '../../dao/entities/Program'; import { ProgramExternalId } from '../../dao/entities/ProgramExternalId.js'; -import { Plex, isPlexQueryError } from '../../external/plex.js'; -import { PlexApiFactory } from '../../external/PlexApiFactory'; +import { PlexApiClient } from '../../external/plex/PlexApiClient.js'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory'; import { Maybe } from '../../types/util.js'; import { asyncPool } from '../../util/asyncPool.js'; import { attempt, attemptSync, groupByUniq, wait } from '../../util/index.js'; @@ -22,6 +22,7 @@ import { LoggerFactory } from '../../util/logging/LoggerFactory.js'; import Fixer from './fixer'; import { PlexTerminalMedia } from '@tunarr/types/plex'; import { upsertProgramExternalIds_deprecated } from '../../dao/programExternalIdHelpers'; +import { isQueryError } from '../../external/BaseApiClient.js'; export class BackfillProgramExternalIds extends Fixer { #logger = LoggerFactory.child({ caller: import.meta }); @@ -53,7 +54,7 @@ export class BackfillProgramExternalIds extends Fixer { cursor.totalCount, ); - const plexConnections: Record = {}; + const plexConnections: Record = {}; while (cursor.length > 0) { await wait(50); // process @@ -66,12 +67,12 @@ export class BackfillProgramExternalIds extends Fixer { keys(plexConnections), ); - const serverSettings = await em.find(PlexServerSettings, { + const serverSettings = await em.find(MediaSource, { name: { $in: missingServers }, }); forEach(serverSettings, (server) => { - plexConnections[server.name] = PlexApiFactory().get(server); + plexConnections[server.name] = MediaSourceApiFactory().get(server); }); for await (const result of asyncPool( @@ -123,7 +124,7 @@ export class BackfillProgramExternalIds extends Fixer { } } - private async handleProgram(program: Program, plex: Maybe) { + private async handleProgram(program: Program, plex: Maybe) { if (isUndefined(plex)) { throw new Error( 'No Plex server connection found for server ' + @@ -133,7 +134,7 @@ export class BackfillProgramExternalIds extends Fixer { const metadataResult = await plex.getItemMetadata(program.externalKey); - if (isPlexQueryError(metadataResult)) { + if (isQueryError(metadataResult)) { throw new Error( `Could not retrieve metadata for program ID ${program.uuid}, rating key = ${program.externalKey}`, ); diff --git a/server/src/tasks/fixers/addPlexServerIds.ts b/server/src/tasks/fixers/addPlexServerIds.ts index ae462505..58ac9a7e 100644 --- a/server/src/tasks/fixers/addPlexServerIds.ts +++ b/server/src/tasks/fixers/addPlexServerIds.ts @@ -1,17 +1,20 @@ import { find, isNil } from 'lodash-es'; import { EntityManager } from '../../dao/dataSource.js'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; -import { Plex } from '../../external/plex.js'; +import { + MediaSource, + MediaSourceType, +} from '../../dao/entities/MediaSource.js'; +import { PlexApiClient } from '../../external/plex/PlexApiClient.js'; import Fixer from './fixer.js'; export class AddPlexServerIdsFixer extends Fixer { async runInternal(em: EntityManager): Promise { const plexServers = await em - .repo(PlexServerSettings) - .find({ clientIdentifier: null }); + .repo(MediaSource) + .find({ clientIdentifier: null, type: MediaSourceType.Plex }); for (const server of plexServers) { - const api = new Plex(server); + const api = new PlexApiClient(server); const devices = await api.getDevices(); if (!isNil(devices) && devices.MediaContainer.Device) { const matchingServer = find( diff --git a/server/src/tasks/fixers/backfillProgramGroupings.ts b/server/src/tasks/fixers/backfillProgramGroupings.ts index 0e82be33..1b2735fc 100644 --- a/server/src/tasks/fixers/backfillProgramGroupings.ts +++ b/server/src/tasks/fixers/backfillProgramGroupings.ts @@ -16,14 +16,14 @@ import ld, { } from 'lodash-es'; import { ProgramSourceType } from '../../dao/custom_types/ProgramSourceType'; import { getEm } from '../../dao/dataSource'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings'; +import { MediaSource } from '../../dao/entities/MediaSource'; import { Program, ProgramType } from '../../dao/entities/Program'; import { ProgramGrouping, ProgramGroupingType, } from '../../dao/entities/ProgramGrouping'; import { ProgramGroupingExternalId } from '../../dao/entities/ProgramGroupingExternalId'; -import { PlexApiFactory } from '../../external/PlexApiFactory'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory'; import { LoggerFactory } from '../../util/logging/LoggerFactory'; import Fixer from './fixer'; import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType'; @@ -35,7 +35,7 @@ export class BackfillProgramGroupings extends Fixer { }); protected async runInternal(em: EntityManager): Promise { - const plexServers = await em.findAll(PlexServerSettings); + const plexServers = await em.findAll(MediaSource); // Update shows first, then seasons, so we can relate them const serversAndShows = await em @@ -77,8 +77,8 @@ export class BackfillProgramGroupings extends Fixer { continue; } - const plex = PlexApiFactory().get(server); - const plexResult = await plex.doGet( + const plex = MediaSourceApiFactory().get(server); + const plexResult = await plex.doGetPath( '/library/metadata/' + grandparentExternalKey, ); @@ -147,8 +147,8 @@ export class BackfillProgramGroupings extends Fixer { continue; } - const plex = PlexApiFactory().get(server); - const plexResult = await plex.doGet( + const plex = MediaSourceApiFactory().get(server); + const plexResult = await plex.doGetPath( '/library/metadata/' + parentExternalKey, ); @@ -245,8 +245,8 @@ export class BackfillProgramGroupings extends Fixer { continue; } - const plex = PlexApiFactory().get(server); - const plexResult = await plex.doGet( + const plex = MediaSourceApiFactory().get(server); + const plexResult = await plex.doGetPath( '/library/metadata/' + ref.externalKey, ); diff --git a/server/src/tasks/fixers/missingSeasonNumbersFixer.ts b/server/src/tasks/fixers/missingSeasonNumbersFixer.ts index 6658d042..9faa9795 100644 --- a/server/src/tasks/fixers/missingSeasonNumbersFixer.ts +++ b/server/src/tasks/fixers/missingSeasonNumbersFixer.ts @@ -2,9 +2,9 @@ import { EntityManager } from '@mikro-orm/better-sqlite'; import { Cursor } from '@mikro-orm/core'; import { PlexEpisodeView, PlexSeasonView } from '@tunarr/types/plex'; import { first, forEach, groupBy, mapValues, pickBy } from 'lodash-es'; -import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; +import { MediaSource } from '../../dao/entities/MediaSource.js'; import { Program, ProgramType } from '../../dao/entities/Program.js'; -import { Plex } from '../../external/plex.js'; +import { PlexApiClient } from '../../external/plex/PlexApiClient.js'; import { Maybe } from '../../types/util.js'; import { groupByUniqAndMap, wait } from '../../util/index.js'; import Fixer from './fixer.js'; @@ -14,7 +14,7 @@ export class MissingSeasonNumbersFixer extends Fixer { private logger = LoggerFactory.child({ caller: import.meta }); async runInternal(em: EntityManager): Promise { - const allPlexServers = await em.findAll(PlexServerSettings); + const allPlexServers = await em.findAll(MediaSource); if (allPlexServers.length === 0) { return; @@ -23,7 +23,7 @@ export class MissingSeasonNumbersFixer extends Fixer { const plexByName = groupByUniqAndMap( allPlexServers, 'name', - (server) => new Plex(server), + (server) => new PlexApiClient(server), ); let cursor: Maybe> = undefined; @@ -122,9 +122,12 @@ export class MissingSeasonNumbersFixer extends Fixer { } while (cursor.hasNextPage); } - private async findSeasonNumberUsingEpisode(episodeId: string, plex: Plex) { + private async findSeasonNumberUsingEpisode( + episodeId: string, + plex: PlexApiClient, + ) { try { - const episode = await plex.doGet( + const episode = await plex.doGetPath( `/library/metadata/${episodeId}`, ); return episode?.parentIndex; @@ -134,11 +137,14 @@ export class MissingSeasonNumbersFixer extends Fixer { } } - private async findSeasonNumberUsingParent(seasonId: string, plex: Plex) { + private async findSeasonNumberUsingParent( + seasonId: string, + plex: PlexApiClient, + ) { // We get the parent because we're dealing with an episode and we want the // season index. try { - const season = await plex.doGet( + const season = await plex.doGetPath( `/library/metadata/${seasonId}`, ); return first(season?.Metadata ?? [])?.index; diff --git a/server/src/tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.ts b/server/src/tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.ts new file mode 100644 index 00000000..72710ca6 --- /dev/null +++ b/server/src/tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.ts @@ -0,0 +1,99 @@ +import { ref } from '@mikro-orm/core'; +import { compact, isEmpty, isUndefined, map } from 'lodash-es'; +import { + ProgramExternalIdType, + programExternalIdTypeFromJellyfinProvider, +} from '../../dao/custom_types/ProgramExternalIdType.js'; +import { getEm } from '../../dao/dataSource.js'; +import { Program } from '../../dao/entities/Program.js'; +import { ProgramExternalId } from '../../dao/entities/ProgramExternalId.js'; +import { upsertProgramExternalIds_deprecated } from '../../dao/programExternalIdHelpers.js'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js'; +import { Maybe } from '../../types/util.js'; +import { isDefined, isNonEmptyString } from '../../util/index.js'; +import { Task } from '../Task.js'; +import { isQueryError } from '../../external/BaseApiClient.js'; +import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.js'; + +export class SaveJellyfinProgramExternalIdsTask extends Task { + ID = SaveJellyfinProgramExternalIdsTask.name; + + constructor(private programId: string) { + super(); + } + + protected async runInternal(): Promise { + const em = getEm(); + + const program = await em.findOneOrFail(Program, this.programId); + + const jellyfinIds = program.externalIds.filter( + (eid) => + eid.sourceType === ProgramExternalIdType.JELLYFIN && + isNonEmptyString(eid.externalSourceId), + ); + + if (isEmpty(jellyfinIds)) { + return; + } + + let chosenId: Maybe = undefined; + let api: Maybe; + for (const id of jellyfinIds) { + if (!isNonEmptyString(id.externalSourceId)) { + continue; + } + + api = await MediaSourceApiFactory().getJellyfinByName( + id.externalSourceId, + ); + + if (isDefined(api)) { + chosenId = id; + break; + } + } + + if (isUndefined(api) || isUndefined(chosenId)) { + return; + } + + const metadataResult = await api.getItem(chosenId.externalKey); + + if (isQueryError(metadataResult)) { + this.logger.error( + 'Error querying Jellyfin for item %s', + chosenId.externalKey, + ); + return; + } + + const metadata = metadataResult.data; + + const eids = compact( + map(metadata?.ProviderIds, (id, provider) => { + if (!isNonEmptyString(id)) { + return; + } + + const type = programExternalIdTypeFromJellyfinProvider(provider); + if (!type) { + return; + } + + const eid = new ProgramExternalId(); + eid.program = ref(program); + eid.externalSourceId = undefined; + eid.externalKey = id; + eid.sourceType = type; + return eid; + }), + ); + + return await upsertProgramExternalIds_deprecated(eids); + } + + get taskName() { + return SaveJellyfinProgramExternalIdsTask.name; + } +} diff --git a/server/src/tasks/jellyfin/SaveJellyfinProgramGroupingsTask.ts b/server/src/tasks/jellyfin/SaveJellyfinProgramGroupingsTask.ts new file mode 100644 index 00000000..1b713653 --- /dev/null +++ b/server/src/tasks/jellyfin/SaveJellyfinProgramGroupingsTask.ts @@ -0,0 +1,41 @@ +import { Tag } from '@tunarr/types'; +import { Task, TaskId } from '../Task.js'; +import { ProgramGroupingCalculator } from '../../dao/ProgramGroupingCalculator.js'; +import { ProgramDB } from '../../dao/programDB.js'; +import { ProgramType } from '../../dao/entities/Program.js'; + +type SaveJellyfinProgramGroupingsRequest = { + programType: ProgramType; + jellyfinServerName: string; + programAndJellyfinIds: { + programId: string; + jellyfinItemId: string; + parentKey: string; + }[]; + parentKeys: string[]; + grandparentKey: string; +}; + +export class SaveJellyfinProgramGroupingsTask extends Task { + public ID: string | Tag; + + constructor( + private request: SaveJellyfinProgramGroupingsRequest, + private programDB: ProgramDB = new ProgramDB(), + ) { + super(); + } + + protected async runInternal(): Promise { + await new ProgramGroupingCalculator( + this.programDB, + ).createHierarchyForManyFromJellyfin( + this.request.programType, + this.request.jellyfinServerName, + this.request.programAndJellyfinIds, + this.request.parentKeys, + this.request.grandparentKey, + ); + return; + } +} diff --git a/server/src/tasks/SavePlexProgramExternalIdsTask.ts b/server/src/tasks/plex/SavePlexProgramExternalIdsTask.ts similarity index 65% rename from server/src/tasks/SavePlexProgramExternalIdsTask.ts rename to server/src/tasks/plex/SavePlexProgramExternalIdsTask.ts index a2901ea7..76058a4d 100644 --- a/server/src/tasks/SavePlexProgramExternalIdsTask.ts +++ b/server/src/tasks/plex/SavePlexProgramExternalIdsTask.ts @@ -1,17 +1,18 @@ import { ref } from '@mikro-orm/core'; import { PlexTerminalMedia } from '@tunarr/types/plex'; import { compact, isEmpty, isError, isUndefined, map } from 'lodash-es'; -import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js'; -import { getEm } from '../dao/dataSource.js'; -import { Program } from '../dao/entities/Program.js'; -import { ProgramExternalId } from '../dao/entities/ProgramExternalId.js'; -import { upsertProgramExternalIds_deprecated } from '../dao/programExternalIdHelpers.js'; -import { Plex, isPlexQueryError } from '../external/plex.js'; -import { PlexApiFactory } from '../external/PlexApiFactory.js'; -import { Maybe } from '../types/util.js'; -import { parsePlexExternalGuid } from '../util/externalIds.js'; -import { isDefined, isNonEmptyString } from '../util/index.js'; -import { Task } from './Task.js'; +import { ProgramExternalIdType } from '../../dao/custom_types/ProgramExternalIdType.js'; +import { getEm } from '../../dao/dataSource.js'; +import { Program } from '../../dao/entities/Program.js'; +import { ProgramExternalId } from '../../dao/entities/ProgramExternalId.js'; +import { upsertProgramExternalIds_deprecated } from '../../dao/programExternalIdHelpers.js'; +import { PlexApiClient } from '../../external/plex/PlexApiClient.js'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.js'; +import { Maybe } from '../../types/util.js'; +import { parsePlexExternalGuid } from '../../util/externalIds.js'; +import { isDefined, isNonEmptyString } from '../../util/index.js'; +import { Task } from '../Task.js'; +import { isQueryError } from '../../external/BaseApiClient.js'; export class SavePlexProgramExternalIdsTask extends Task { ID = SavePlexProgramExternalIdsTask.name; @@ -36,13 +37,13 @@ export class SavePlexProgramExternalIdsTask extends Task { } let chosenId: Maybe = undefined; - let api: Maybe; + let api: Maybe; for (const id of plexIds) { if (!isNonEmptyString(id.externalSourceId)) { continue; } - api = await PlexApiFactory().getOrSet(id.externalSourceId); + api = await MediaSourceApiFactory().getOrSet(id.externalSourceId); if (isDefined(api)) { chosenId = id; @@ -56,7 +57,7 @@ export class SavePlexProgramExternalIdsTask extends Task { const metadataResult = await api.getItemMetadata(chosenId.externalKey); - if (isPlexQueryError(metadataResult)) { + if (isQueryError(metadataResult)) { this.logger.error( 'Error querying Plex for item %s', chosenId.externalKey, diff --git a/server/src/tasks/plex/SavePlexProgramGroupingsTask.ts b/server/src/tasks/plex/SavePlexProgramGroupingsTask.ts index 6950db30..8470e708 100644 --- a/server/src/tasks/plex/SavePlexProgramGroupingsTask.ts +++ b/server/src/tasks/plex/SavePlexProgramGroupingsTask.ts @@ -7,7 +7,7 @@ import { ProgramType } from '../../dao/entities/Program.js'; type SavePlexProgramGroupingsRequest = { programType: ProgramType; plexServerName: string; - programAndPlexIds: { programId: string; plexId: string, parentKey }[]; + programAndPlexIds: { programId: string; plexId: string; parentKey }[]; parentKeys: string[]; grandparentKey: string; }; @@ -23,8 +23,9 @@ export class SavePlexProgramGroupingsTask extends Task { } protected async runInternal(): Promise { - const calculator = new ProgramGroupingCalculator(this.programDB); - await calculator.createHierarchyForManyFromPlex( + await new ProgramGroupingCalculator( + this.programDB, + ).createHierarchyForManyFromPlex( this.request.programType, this.request.plexServerName, this.request.programAndPlexIds, diff --git a/server/src/tasks/UpdatePlexPlayStatusTask.ts b/server/src/tasks/plex/UpdatePlexPlayStatusTask.ts similarity index 85% rename from server/src/tasks/UpdatePlexPlayStatusTask.ts rename to server/src/tasks/plex/UpdatePlexPlayStatusTask.ts index 3e535595..6c9bbfca 100644 --- a/server/src/tasks/UpdatePlexPlayStatusTask.ts +++ b/server/src/tasks/plex/UpdatePlexPlayStatusTask.ts @@ -1,11 +1,11 @@ import { RecurrenceRule } from 'node-schedule'; -import { PlexServerSettings } from '../dao/entities/PlexServerSettings'; -import { PlexApiFactory } from '../external/PlexApiFactory'; -import { run } from '../util'; -import { ScheduledTask } from './ScheduledTask'; -import { Task } from './Task'; +import { MediaSource } from '../../dao/entities/MediaSource'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory'; +import { run } from '../../util'; +import { ScheduledTask } from '../ScheduledTask'; +import { Task } from '../Task'; import dayjs from 'dayjs'; -import { GlobalScheduler } from '../services/scheduler'; +import { GlobalScheduler } from '../../services/scheduler'; import { v4 } from 'uuid'; type UpdatePlexPlayStatusScheduleRequest = { @@ -33,7 +33,7 @@ export class UpdatePlexPlayStatusScheduledTask extends ScheduledTask { private playState: PlayState = 'playing'; constructor( - private plexServer: PlexServerSettings, + private plexServer: MediaSource, private request: UpdatePlexPlayStatusScheduleRequest, public sessionId: string = v4(), ) { @@ -96,14 +96,14 @@ class UpdatePlexPlayStatusTask extends Task { } constructor( - private plexServer: PlexServerSettings, + private plexServer: MediaSource, private request: UpdatePlexPlayStatusInvocation, ) { super(); } protected async runInternal(): Promise { - const plex = PlexApiFactory().get(this.plexServer); + const plex = MediaSourceApiFactory().get(this.plexServer); const deviceName = `tunarr-channel-${this.request.channelNumber}`; const params = { @@ -119,7 +119,7 @@ class UpdatePlexPlayStatusTask extends Task { }; try { - await plex.doPost('/:/timeline', params); + await plex.doPost({ url: '/:/timeline', params }); } catch (error) { this.logger.warn( error, diff --git a/server/src/types/serverType.ts b/server/src/types/serverType.ts index 85bc790d..e5f1d0a5 100644 --- a/server/src/types/serverType.ts +++ b/server/src/types/serverType.ts @@ -4,9 +4,14 @@ import { FastifyPluginAsync, FastifyPluginCallback, RawServerDefault, + RouteGenericInterface, } from 'fastify'; import { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { FastifyRequest } from 'fastify/types/request'; +import { FastifySchema } from 'fastify/types/schema'; +import { ResolveFastifyRequestType } from 'fastify/types/type-provider'; import { IncomingMessage, ServerResponse } from 'http'; +import { z } from 'zod'; export type ServerType = FastifyInstance< RawServerDefault, @@ -27,3 +32,27 @@ export type RouterPluginAsyncCallback = FastifyPluginAsync< RawServerDefault, ZodTypeProvider >; + +export type ZodFastifySchema = { + body?: z.AnyZodObject; + querystring?: z.AnyZodObject; + params?: z.AnyZodObject; + headers?: z.AnyZodObject; + response?: z.AnyZodObject; +}; + +export type ZodFastifyRequest = + FastifyRequest< + RouteGenericInterface, + RawServerDefault, + IncomingMessage, + Readonly, + ZodTypeProvider, + unknown, + FastifyBaseLogger, + ResolveFastifyRequestType< + ZodTypeProvider, + Readonly, + RouteGenericInterface + > + >; diff --git a/server/src/types/util.ts b/server/src/types/util.ts index 4a0f33de..3d844cdd 100644 --- a/server/src/types/util.ts +++ b/server/src/types/util.ts @@ -4,6 +4,8 @@ export type Maybe = T | undefined; export type Nullable = T | null; +export type Nilable = Maybe | Nullable; + export type TupleToUnion> = T[number]; export type Intersection = { diff --git a/server/src/util/ProgramMinter.ts b/server/src/util/ProgramMinter.ts index 390edd4b..a10ac4a9 100644 --- a/server/src/util/ProgramMinter.ts +++ b/server/src/util/ProgramMinter.ts @@ -1,17 +1,28 @@ -import { EntityManager, ref } from '@mikro-orm/better-sqlite'; +import { + EntityManager, + RequiredEntityData, + ref, +} from '@mikro-orm/better-sqlite'; import { PlexEpisode, PlexMovie, PlexMusicTrack, PlexTerminalMedia, } from '@tunarr/types/plex'; -import { compact, first, isError, map } from 'lodash-es'; +import { compact, first, isError, isNil, map } from 'lodash-es'; import { ProgramSourceType } from '../dao/custom_types/ProgramSourceType.js'; import { Program, ProgramType } from '../dao/entities/Program.js'; import { ProgramExternalId } from '../dao/entities/ProgramExternalId.js'; -import { ProgramExternalIdType } from '../dao/custom_types/ProgramExternalIdType.js'; +import { + ProgramExternalIdType, + programExternalIdTypeFromJellyfinProvider, +} from '../dao/custom_types/ProgramExternalIdType.js'; import { LoggerFactory } from './logging/LoggerFactory.js'; import { parsePlexExternalGuid } from './externalIds.js'; +import { ContentProgramOriginalProgram } from '@tunarr/types/schemas'; +import { JellyfinItem } from '@tunarr/types/jellyfin'; +import { nullToUndefined } from '@tunarr/shared/util'; +import dayjs from 'dayjs'; /** * Generates Program DB entities for Plex media @@ -24,18 +35,46 @@ class PlexProgramMinter { this.#em = em; } - mint(serverName: string, plexItem: PlexTerminalMedia) { - switch (plexItem.type) { - case 'movie': - return this.mintMovieProgram(serverName, plexItem); - case 'episode': - return this.mintEpisodeProgram(serverName, plexItem); - case 'track': - return this.mintTrackProgram(serverName, plexItem); + mint(serverName: string, program: ContentProgramOriginalProgram) { + switch (program.sourceType) { + case 'plex': + switch (program.program.type) { + case 'movie': + return this.mintMovieProgramForPlex(serverName, program.program); + case 'episode': + return this.mintEpisodeProgramForPlex(serverName, program.program); + case 'track': + return this.mintTrackProgramForPlex(serverName, program.program); + } + // Disabled because eslint does not pickup the fact that the above is + // exhaustive. + // eslint-disable-next-line no-fallthrough + case 'jellyfin': + switch (program.program.Type) { + case 'Movie': + return this.mintMovieProgramForJellyfin( + serverName, + program.program, + ); + case 'Episode': + return this.mintEpisodeProgramForJellyfin( + serverName, + program.program, + ); + case 'Audio': + default: + return this.mintTrackProgramForJellyfin( + serverName, + program.program, + ); + } } } - private mintMovieProgram(serverName: string, plexMovie: PlexMovie): Program { + private mintMovieProgramForPlex( + serverName: string, + plexMovie: PlexMovie, + ): Program { const file = first(first(plexMovie.Media)?.Part ?? []); return this.#em.create( Program, @@ -58,7 +97,34 @@ class PlexProgramMinter { ); } - private mintEpisodeProgram( + private mintMovieProgramForJellyfin( + serverName: string, + item: JellyfinItem, + ): Program { + // const file = first(first(plexMovie.Media)?.Part ?? []); + return this.#em.create( + Program, + { + sourceType: ProgramSourceType.JELLYFIN, + originalAirDate: nullToUndefined(item.PremiereDate), + duration: (item.RunTimeTicks ?? 0) / 10_000, + filePath: nullToUndefined(item.Path), + externalSourceId: serverName, + externalKey: item.Id, + plexRatingKey: item.Id, + plexFilePath: '', + // plexFilePath: file?.key, + rating: nullToUndefined(item.OfficialRating), + summary: nullToUndefined(item.Overview), + title: nullToUndefined(item.Name) ?? '', + type: ProgramType.Movie, + year: nullToUndefined(item.ProductionYear), + } satisfies RequiredEntityData, + { persist: false }, + ); + } + + private mintEpisodeProgramForPlex( serverName: string, plexEpisode: PlexEpisode, ): Program { @@ -92,7 +158,39 @@ class PlexProgramMinter { return program; } - private mintTrackProgram(serverName: string, plexTrack: PlexMusicTrack) { + private mintEpisodeProgramForJellyfin( + serverName: string, + item: JellyfinItem, + ): Program { + // const file = first(first(plexEpisode.Media)?.Part ?? []); + return this.#em.create( + Program, + { + sourceType: ProgramSourceType.JELLYFIN, + originalAirDate: nullToUndefined(item.PremiereDate), + duration: (item.RunTimeTicks ?? 0) / 10_000, + externalSourceId: serverName, + externalKey: item.Id, + rating: nullToUndefined(item.OfficialRating), + summary: nullToUndefined(item.Overview), + title: nullToUndefined(item.Name) ?? '', + type: ProgramType.Episode, + year: nullToUndefined(item.ProductionYear), + showTitle: nullToUndefined(item.SeriesName), + showIcon: nullToUndefined(item.SeriesThumbImageTag), + seasonNumber: nullToUndefined(item.ParentIndexNumber), + episode: nullToUndefined(item.IndexNumber), + parentExternalKey: nullToUndefined(item.SeasonId), + grandparentExternalKey: nullToUndefined(item.SeriesId), + }, + { persist: false }, + ); + } + + private mintTrackProgramForPlex( + serverName: string, + plexTrack: PlexMusicTrack, + ) { const file = first(first(plexTrack.Media)?.Part ?? []); return this.#em.create( Program, @@ -121,7 +219,52 @@ class PlexProgramMinter { ); } + private mintTrackProgramForJellyfin(serverName: string, item: JellyfinItem) { + // const file = first(first(plexTrack.Media)?.Part ?? []); + return this.#em.create( + Program, + { + sourceType: ProgramSourceType.JELLYFIN, + originalAirDate: nullToUndefined(item.PremiereDate), + duration: (item.RunTimeTicks ?? 0) / 10_000, + externalSourceId: serverName, + externalKey: item.Id, + rating: nullToUndefined(item.OfficialRating), + summary: nullToUndefined(item.Overview), + title: nullToUndefined(item.Name) ?? '', + type: ProgramType.Track, + year: item.PremiereDate ? dayjs(item.PremiereDate).year() : undefined, + parentExternalKey: nullToUndefined(item.AlbumId), + grandparentExternalKey: first(item.AlbumArtists)?.Id, + albumName: nullToUndefined(item.Album), + artistName: nullToUndefined(item.AlbumArtist), + }, + { persist: false }, + ); + } + mintExternalIds( + serverName: string, + program: Program, + { sourceType, program: originalProgram }: ContentProgramOriginalProgram, + ) { + switch (sourceType) { + case 'plex': + return this.mintExternalIdsForPlex( + serverName, + program, + originalProgram, + ); + case 'jellyfin': + return this.mintExternalIdsForJellyfin( + serverName, + program, + originalProgram, + ); + } + } + + mintExternalIdsForPlex( serverName: string, program: Program, media: PlexTerminalMedia, @@ -167,6 +310,60 @@ class PlexProgramMinter { return [ratingId, guidId, ...externalGuids]; } + + mintExternalIdsForJellyfin( + serverName: string, + program: Program, + media: JellyfinItem, + ) { + const ratingId = this.#em.create( + ProgramExternalId, + { + externalKey: media.Id, + sourceType: ProgramExternalIdType.JELLYFIN, + program, + externalSourceId: serverName, + // externalFilePath: file?.key, + // directFilePath: file?.file, + }, + { persist: false }, + ); + + // const guidId = this.#em.create( + // ProgramExternalId, + // { + // externalKey: media.guid, + // sourceType: ProgramExternalIdType.PLEX_GUID, + // program, + // }, + // { persist: false }, + // ); + + const externalGuids = compact( + map(media.ProviderIds, (externalGuid, guidType) => { + if (isNil(externalGuid)) { + return; + } + + const typ = programExternalIdTypeFromJellyfinProvider(guidType); + if (typ) { + return this.#em.create( + ProgramExternalId, + { + externalKey: externalGuid, + sourceType: typ, + program, + }, + { persist: false }, + ); + } + + return; + }), + ); + + return [ratingId, ...externalGuids]; + } } export class ProgramMinterFactory { diff --git a/server/src/util/axios.ts b/server/src/util/axios.ts new file mode 100644 index 00000000..7ae56175 --- /dev/null +++ b/server/src/util/axios.ts @@ -0,0 +1,48 @@ +import { AxiosInstance, InternalAxiosRequestConfig, isAxiosError } from 'axios'; +import { Logger } from './logging/LoggerFactory'; +import querystring from 'node:querystring'; + +type AxiosConfigWithMetadata = InternalAxiosRequestConfig & { + metadata: { + startTime: number; + }; +}; + +export function configureAxiosLogging(instance: AxiosInstance, logger: Logger) { + const logAxiosRequest = (req: AxiosConfigWithMetadata, status: number) => { + const query = req.params + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + `?${querystring.stringify(req.params)}` + : ''; + const elapsedTime = new Date().getTime() - req.metadata.startTime; + logger.http( + `[Axios Request]: ${req.method?.toUpperCase()} ${req.baseURL}${ + req.url + }${query} - (${status}) ${elapsedTime}ms`, + ); + }; + + instance.interceptors.request.use((req) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + (req as AxiosConfigWithMetadata).metadata = { + startTime: new Date().getTime(), + }; + return req; + }); + + instance.interceptors.response.use( + (resp) => { + logAxiosRequest(resp.config as AxiosConfigWithMetadata, resp.status); + return resp; + }, + (err) => { + if (isAxiosError(err) && err.config) { + logAxiosRequest( + err.config as AxiosConfigWithMetadata, + err.status ?? -1, + ); + } + throw err; + }, + ); +} diff --git a/server/src/util/index.ts b/server/src/util/index.ts index 368043ba..0ed82122 100644 --- a/server/src/util/index.ts +++ b/server/src/util/index.ts @@ -16,6 +16,7 @@ import _, { map, once, range, + reject, zipWith, } from 'lodash-es'; import fs from 'node:fs/promises'; @@ -464,3 +465,7 @@ export function nullToUndefined(x: T | null | undefined): T | undefined { } return x; } + +export function removeErrors(coll: Try[] | null | undefined): T[] { + return reject(coll, isError) satisfies T[] as T[]; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index e131e5a1..854e8776 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,25 +1,35 @@ import { ExternalId, SingleExternalId, MultiExternalId } from '@tunarr/types'; import { PlexMedia } from '@tunarr/types/plex'; -import { type ExternalIdType } from '@tunarr/types/schemas'; +import { + SingleExternalIdType, + type ExternalIdType, +} from '@tunarr/types/schemas'; export { scheduleRandomSlots } from './services/randomSlotsService.js'; export { scheduleTimeSlots } from './services/timeSlotService.js'; export { mod as dayjsMod } from './util/dayjsExtensions.js'; // TODO replace first arg with shared type export function createExternalId( - sourceType: ExternalIdType, + sourceType: ExternalIdType, //StrictExclude, sourceId: string, itemId: string, ): `${string}|${string}|${string}` { return `${sourceType}|${sourceId}|${itemId}`; } +export function createGlobalExternalIdString( + sourceType: SingleExternalIdType, + id: string, +): `${string}|${string}` { + return `${sourceType}|${id}`; +} + export function createExternalIdFromMulti(multi: MultiExternalId) { return createExternalId(multi.source, multi.sourceId, multi.id); } export function createExternalIdFromGlobal(global: SingleExternalId) { - return createExternalId(global.source, '', global.id); + return createGlobalExternalIdString(global.source, global.id); } // We could type this better if we reuse the other ExternalId diff --git a/shared/src/util/index.ts b/shared/src/util/index.ts index 3196c141..d14a305e 100644 --- a/shared/src/util/index.ts +++ b/shared/src/util/index.ts @@ -4,6 +4,7 @@ import { PlexMedia } from '@tunarr/types/plex'; import isFunction from 'lodash-es/isFunction.js'; import { MarkRequired } from 'ts-essentials'; import type { PerTypeCallback } from '../types/index.js'; +import { isNull } from 'lodash-es'; export { mod as dayjsMod } from './dayjsExtensions.js'; export * as seq from './seq.js'; @@ -111,3 +112,10 @@ export function forPlexMedia(choices: PerTypeCallback) { return null; }; } + +export function nullToUndefined(x: T | null | undefined): T | undefined { + if (isNull(x)) { + return undefined; + } + return x; +} diff --git a/types/package.json b/types/package.json index 996e8cc9..45da8a5a 100644 --- a/types/package.json +++ b/types/package.json @@ -42,6 +42,10 @@ "types": "./build/plex/index.d.ts", "default": "./build/plex/index.js" }, + "./jellyfin": { + "types": "./build/jellyfin/index.d.ts", + "default": "./build/jellyfin/index.js" + }, "./api": { "types": "./build/api/index.d.ts", "default": "./build/api/index.js" diff --git a/types/src/PlexSettings.ts b/types/src/MediaSourceSettings.ts similarity index 60% rename from types/src/PlexSettings.ts rename to types/src/MediaSourceSettings.ts index fb596aae..0bad6985 100644 --- a/types/src/PlexSettings.ts +++ b/types/src/MediaSourceSettings.ts @@ -1,11 +1,19 @@ import z from 'zod'; import { + JellyfinServerSettingsSchema, + MediaSourceSettingsSchema, PlexServerSettingsSchema, PlexStreamSettingsSchema, } from './schemas/settingsSchemas.js'; export type PlexServerSettings = z.infer; +export type JellyfinServerSettings = z.infer< + typeof JellyfinServerSettingsSchema +>; + +export type MediaSourceSettings = z.infer; + export type PlexStreamSettings = z.infer; export const defaultPlexStreamSettings = PlexStreamSettingsSchema.parse({}); diff --git a/types/src/api/index.ts b/types/src/api/index.ts index 9e9041b1..10a2ba66 100644 --- a/types/src/api/index.ts +++ b/types/src/api/index.ts @@ -6,6 +6,7 @@ import { } from '../schemas/programmingSchema.js'; import { BackupSettingsSchema, + JellyfinServerSettingsSchema, PlexServerSettingsSchema, } from '../schemas/settingsSchemas.js'; import { @@ -118,19 +119,36 @@ export const RandomSlotProgramLineupSchema = z.object({ schedule: RandomSlotScheduleSchema, }); -export const UpdateChannelProgrammingRequestSchema = z.discriminatedUnion( +export const UpdateChannelProgrammingRequestSchema: z.ZodDiscriminatedUnion< 'type', [ - ManualProgramLineupSchema, - TimeBasedProgramLineupSchema, - RandomSlotProgramLineupSchema, - ], -); + typeof ManualProgramLineupSchema, + typeof TimeBasedProgramLineupSchema, + typeof RandomSlotProgramLineupSchema, + ] +> = z.discriminatedUnion('type', [ + ManualProgramLineupSchema, + TimeBasedProgramLineupSchema, + RandomSlotProgramLineupSchema, +]); export type UpdateChannelProgrammingRequest = z.infer< typeof UpdateChannelProgrammingRequestSchema >; +export const UpdateMediaSourceRequestSchema = z.discriminatedUnion('type', [ + PlexServerSettingsSchema.partial({ + sendChannelUpdates: true, + sendGuideUpdates: true, + clientIdentifier: true, + }), + JellyfinServerSettingsSchema, +]); + +export type UpdateMediaSourceRequest = z.infer< + typeof UpdateMediaSourceRequestSchema +>; + export const UpdatePlexServerRequestSchema = PlexServerSettingsSchema.partial({ sendChannelUpdates: true, sendGuideUpdates: true, @@ -142,17 +160,18 @@ export type UpdatePlexServerRequest = z.infer< typeof UpdatePlexServerRequestSchema >; -export const InsertPlexServerRequestSchema = PlexServerSettingsSchema.partial({ - sendChannelUpdates: true, - sendGuideUpdates: true, - index: true, - clientIdentifier: true, -}).omit({ - id: true, -}); +export const InsertMediaSourceRequestSchema = z.discriminatedUnion('type', [ + PlexServerSettingsSchema.partial({ + sendChannelUpdates: true, + sendGuideUpdates: true, + index: true, + clientIdentifier: true, + }).omit({ id: true }), + JellyfinServerSettingsSchema.omit({ id: true }), +]); -export type InsertPlexServerRequest = z.infer< - typeof InsertPlexServerRequestSchema +export type InsertMediaSourceRequest = z.infer< + typeof InsertMediaSourceRequestSchema >; export const VersionApiResponseSchema = z.object({ @@ -201,3 +220,9 @@ export type UpdateSystemSettingsRequest = z.infer< >; export const UpdateBackupSettingsRequestSchema = BackupSettingsSchema; + +export const JellyfinLoginRequest = z.object({ + url: z.string().url(), + username: z.string().min(1), + password: z.string().min(1), +}); diff --git a/types/src/index.ts b/types/src/index.ts index dd9096c9..e86b21e5 100644 --- a/types/src/index.ts +++ b/types/src/index.ts @@ -5,7 +5,7 @@ export * from './FfmpegSettings.js'; export * from './FillerList.js'; export * from './GuideApi.js'; export * from './HdhrSettings.js'; -export * from './PlexSettings.js'; +export * from './MediaSourceSettings.js'; export * from './Program.js'; export * from './Tasks.js'; export * from './Theme.js'; diff --git a/types/src/jellyfin/index.ts b/types/src/jellyfin/index.ts new file mode 100644 index 00000000..29b80795 --- /dev/null +++ b/types/src/jellyfin/index.ts @@ -0,0 +1,996 @@ +import { z } from 'zod'; + +// Some of this is generated from the Jellyfin 10.9.7 OpenAPI Schema + +export const JellyfinItemFields = z.enum([ + 'AirTime', + 'CanDelete', + 'CanDownload', + 'ChannelInfo', + 'Chapters', + 'Trickplay', + 'ChildCount', + 'CumulativeRunTimeTicks', + 'CustomRating', + 'DateCreated', + 'DateLastMediaAdded', + 'DisplayPreferencesId', + 'Etag', + 'ExternalUrls', + 'Genres', + 'HomePageUrl', + 'ItemCounts', + 'MediaSourceCount', + 'MediaSources', + 'OriginalTitle', + 'Overview', + 'ParentId', + 'Path', + 'People', + 'PlayAccess', + 'ProductionLocations', + 'ProviderIds', + 'PrimaryImageAspectRatio', + 'RecursiveItemCount', + 'Settings', + 'ScreenshotImageTags', + 'SeriesPrimaryImage', + 'SeriesStudio', + 'SortName', + 'SpecialEpisodeNumbers', + 'Studios', + 'Taglines', + 'Tags', + 'RemoteTrailers', + 'MediaStreams', + 'SeasonUserData', + 'ServiceName', + 'ThemeSongIds', + 'ThemeVideoIds', + 'ExternalEtag', + 'PresentationUniqueKey', + 'InheritedParentalRatingValue', + 'ExternalSeriesId', + 'SeriesPresentationUniqueKey', + 'DateLastRefreshed', + 'DateLastSaved', + 'RefreshState', + 'ChannelImage', + 'EnableMediaSourceDisplay', + 'Width', + 'Height', + 'ExtraIds', + 'LocalTrailerCount', + 'IsHD', + 'SpecialFeatureCount', +]); + +export type JellyfinItemFields = z.infer; + +export const JellyfinBaseItemKind = z.enum([ + 'AggregateFolder', + 'Audio', + 'AudioBook', + 'BasePluginFolder', + 'Book', + 'BoxSet', + 'Channel', + 'ChannelFolderItem', + 'CollectionFolder', + 'Episode', + 'Folder', + 'Genre', + 'ManualPlaylistsFolder', + 'Movie', + 'LiveTvChannel', + 'LiveTvProgram', + 'MusicAlbum', + 'MusicArtist', + 'MusicGenre', + 'MusicVideo', + 'Person', + 'Photo', + 'PhotoAlbum', + 'Playlist', + 'PlaylistsFolder', + 'Program', + 'Recording', + 'Season', + 'Series', + 'Studio', + 'Trailer', + 'TvChannel', + 'TvProgram', + 'UserRootFolder', + 'UserView', + 'Video', + 'Year', +]); + +export const JellyfinItemFilter = z.enum([ + 'IsFolder', + 'IsNotFolder', + 'IsUnplayed', + 'IsPlayed', + 'IsFavorite', + 'IsResumable', + 'Likes', + 'Dislikes', + 'IsFavoriteOrLikes', +]); + +export const JellyfinMediaType = z.enum([ + 'Unknown', + 'Video', + 'Audio', + 'Photo', + 'Book', +]); + +export const JellyfinImageType = z.enum([ + 'Primary', + 'Art', + 'Backdrop', + 'Banner', + 'Logo', + 'Thumb', + 'Disc', + 'Box', + 'Screenshot', + 'Menu', + 'Chapter', + 'BoxRear', + 'Profile', +]); + +export const JellyfinItemSortBy = z.enum([ + 'Default', + 'AiredEpisodeOrder', + 'Album', + 'AlbumArtist', + 'Artist', + 'DateCreated', + 'OfficialRating', + 'DatePlayed', + 'PremiereDate', + 'StartDate', + 'SortName', + 'Name', + 'Random', + 'Runtime', + 'CommunityRating', + 'ProductionYear', + 'PlayCount', + 'CriticRating', + 'IsFolder', + 'IsUnplayed', + 'IsPlayed', + 'SeriesSortName', + 'VideoBitRate', + 'AirTime', + 'Studio', + 'IsFavoriteOrLiked', + 'DateLastContentAdded', + 'SeriesDatePlayed', + 'ParentIndexNumber', + 'IndexNumber', + 'SimilarityScore', + 'SearchScore', +]); + +const CollectionType = z.enum([ + 'unknown', + 'movies', + 'tvshows', + 'music', + 'musicvideos', + 'trailers', + 'homevideos', + 'boxsets', + 'books', + 'photos', + 'livetv', + 'playlists', + 'folders', +]); + +export const JellyfinSortOrder = z.enum(['Ascending', 'Descending']); + +const ChapterInfo = z + .object({ + StartPositionTicks: z.number().int(), + Name: z.string().nullable().optional(), + ImagePath: z.string().nullable().optional(), + ImageDateModified: z.string().datetime({ offset: true }), + ImageTag: z.string().nullable().optional(), + }) + .partial(); + +export const JellyfinUserPolicyResponse = z.object({ + IsAdministrator: z.boolean(), + IsDisabled: z.boolean(), + EnableAllFolders: z.boolean(), +}); + +export type JellyfinUserPolicyResponse = z.infer< + typeof JellyfinUserPolicyResponse +>; + +export const JellyfinUserResponse = z.object({ + Name: z.string(), + Id: z.string(), + Policy: JellyfinUserPolicyResponse, +}); + +export type JellyfinUserResponse = z.infer; + +export const JellyfinLibraryPathInfo = z.object({ + Path: z.string(), + NetworkPath: z.string().nullable().optional(), +}); + +export const JellyfinLibraryOptions = z.object({ + PathInfos: z.array(JellyfinLibraryPathInfo), +}); + +export const JellyfinLibrary = z.object({ + Name: z.string(), + CollectionType: z.string(), + ItemId: z.string(), + LibraryOptions: JellyfinLibraryOptions, +}); + +export type JellyfinLibrary = z.infer; + +export const JellyfinLibraryResponse = z.array(JellyfinLibrary); + +export const JellyfinMediaStream = z.object({ + Type: z.string(), + Codec: z.string(), + Language: z.string(), + IsInterlaced: z.boolean().nullable().optional(), + Height: z.number().positive().nullable().optional().catch(undefined), + Width: z.number().positive().nullable().optional().catch(undefined), + Index: z.number(), + IsDefault: z.boolean(), + IsForced: z.boolean(), + IsExternal: z.boolean(), + IsHearingImpaired: z.boolean().nullable().optional(), + VideoRange: z.string().nullable().optional(), + AudioSpatialFormat: z.string().nullable().optional(), + AspectRatio: z.string().nullable().optional(), + BitRate: z.number().positive().nullable().optional(), + ChannelLayout: z.string().nullable().optional(), + Channels: z.number().positive().nullable().optional(), + RealFrameRate: z.number().positive().nullable().optional(), + PixelFormat: z.string().nullable().optional(), + Title: z.string().nullable().optional(), + Profile: z.string().nullable().optional(), + ColorRange: z.string().nullable().optional(), + ColorSpace: z.string().nullable().optional(), + ColorTransfer: z.string().nullable().optional(), + ColorPrimaries: z.string().nullable().optional(), + IsAnamorphic: z.boolean().nullable().optional(), +}); + +export const JellyfinImageBlurHashes = z.object({ + Backdrop: z.record(z.string()).nullable().optional(), + Primary: z.record(z.string()).nullable().optional(), + Logo: z.record(z.string()).nullable().optional(), + Thumb: z.record(z.string()).nullable().optional(), +}); + +export const JellyfinJoinItem = z.object({ + Name: z.string(), + Id: z.string(), +}); + +export const JellyfinPerson = JellyfinJoinItem.extend({ + Role: z.string().nullable().optional(), + Type: z.string().nullable().optional(), + PrimaryImageTag: z.string().nullable().optional(), + ImageBlurHashes: JellyfinImageBlurHashes.nullable().optional(), +}); + +export const JellyfinChapter = z.object({ + StartPositionTicks: z.number().positive(), + Name: z.string().nullable().optional(), +}); + +type Video3DFormat = + | 'HalfSideBySide' + | 'FullSideBySide' + | 'FullTopAndBottom' + | 'HalfTopAndBottom' + | 'MVC'; +type ExternalUrl = Partial<{ + Name: string | null; + Url: string | null; +}>; + +type MediaProtocol = 'File' | 'Http' | 'Rtmp' | 'Rtsp' | 'Udp' | 'Rtp' | 'Ftp'; +type MediaSourceType = 'Default' | 'Grouping' | 'Placeholder'; +type VideoType = 'VideoFile' | 'Iso' | 'Dvd' | 'BluRay'; +type IsoType = 'Dvd' | 'BluRay'; + +type VideoRange = 'Unknown' | 'SDR' | 'HDR'; + +type VideoRangeType = + | 'Unknown' + | 'SDR' + | 'HDR10' + | 'HLG' + | 'DOVI' + | 'DOVIWithHDR10' + | 'DOVIWithHLG' + | 'DOVIWithSDR' + | 'HDR10Plus'; + +type MediaStreamType = + | 'Audio' + | 'Video' + | 'Subtitle' + | 'EmbeddedImage' + | 'Data' + | 'Lyric'; + +type SubtitleDeliveryMethod = 'Encode' | 'Embed' | 'External' | 'Hls' | 'Drop'; + +export const JellyfinItemKind = z.enum([ + 'AggregateFolder', + 'Audio', + 'AudioBook', + 'BasePluginFolder', + 'Book', + 'BoxSet', + 'Channel', + 'ChannelFolderItem', + 'CollectionFolder', + 'Episode', + 'Folder', + 'Genre', + 'ManualPlaylistsFolder', + 'Movie', + 'LiveTvChannel', + 'LiveTvProgram', + 'MusicAlbum', + 'MusicArtist', + 'MusicGenre', + 'MusicVideo', + 'Person', + 'Photo', + 'PhotoAlbum', + 'Playlist', + 'PlaylistsFolder', + 'Program', + 'Recording', + 'Season', + 'Series', + 'Studio', + 'Trailer', + 'TvChannel', + 'TvProgram', + 'UserRootFolder', + 'UserView', + 'Video', + 'Year', +]); + +export type JellyfinItemKind = z.infer; + +export type PersonKind = + | 'Unknown' + | 'Actor' + | 'Director' + | 'Composer' + | 'Writer' + | 'GuestStar' + | 'Producer' + | 'Conductor' + | 'Lyricist' + | 'Arranger' + | 'Engineer' + | 'Mixer' + | 'Remixer' + | 'Creator' + | 'Artist' + | 'AlbumArtist' + | 'Author' + | 'Illustrator' + | 'Penciller' + | 'Inker' + | 'Colorist' + | 'Letterer' + | 'CoverArtist' + | 'Editor' + | 'Translator'; + +const NameGuidPair = z + .object({ Name: z.string().nullable().optional(), Id: z.string() }) + .partial(); + +type NameGuidPair = z.infer; + +type CollectionType = + | 'unknown' + | 'movies' + | 'tvshows' + | 'music' + | 'musicvideos' + | 'trailers' + | 'homevideos' + | 'boxsets' + | 'books' + | 'photos' + | 'livetv' + | 'playlists' + | 'folders'; + +type ChapterInfo = Partial<{ + StartPositionTicks: number; + Name: string | null; + ImagePath: string | null; + ImageDateModified: string; + ImageTag: string | null; +}>; + +const ExtraType = z.enum([ + 'Unknown', + 'Clip', + 'Trailer', + 'BehindTheScenes', + 'DeletedScene', + 'Interview', + 'Scene', + 'Sample', + 'ThemeSong', + 'ThemeVideo', + 'Featurette', + 'Short', +]); + +const Video3DFormat = z.enum([ + 'HalfSideBySide', + 'FullSideBySide', + 'FullTopAndBottom', + 'HalfTopAndBottom', + 'MVC', +]); +const ExternalUrl = z + .object({ + Name: z.string().nullable().optional(), + Url: z.string().nullable().optional(), + }) + .partial(); +const MediaProtocol = z.enum([ + 'File', + 'Http', + 'Rtmp', + 'Rtsp', + 'Udp', + 'Rtp', + 'Ftp', +]); +const MediaSourceType = z.enum(['Default', 'Grouping', 'Placeholder']); +const VideoType = z.enum(['VideoFile', 'Iso', 'Dvd', 'BluRay']); +const IsoType = z.enum(['Dvd', 'BluRay']); +const VideoRange = z.enum(['Unknown', 'SDR', 'HDR']); +const VideoRangeType = z.enum([ + 'Unknown', + 'SDR', + 'HDR10', + 'HLG', + 'DOVI', + 'DOVIWithHDR10', + 'DOVIWithHLG', + 'DOVIWithSDR', + 'HDR10Plus', +]); + +const SubtitleDeliveryMethod = z.enum([ + 'Encode', + 'Embed', + 'External', + 'Hls', + 'Drop', +]); + +const MediaStreamType = z.enum([ + 'Audio', + 'Video', + 'Subtitle', + 'EmbeddedImage', + 'Data', + 'Lyric', +]); + +const MediaAttachment = z + .object({ + Codec: z.string().nullable().optional(), + CodecTag: z.string().nullable().optional(), + Comment: z.string().nullable().optional(), + Index: z.number().int(), + FileName: z.string().nullable().optional(), + MimeType: z.string().nullable().optional(), + DeliveryUrl: z.string().nullable().optional(), + }) + .partial(); + +const MediaStream = z + .object({ + Codec: z.string().nullable().optional(), + CodecTag: z.string().nullable().optional(), + Language: z.string().nullable().optional(), + ColorRange: z.string().nullable().optional(), + ColorSpace: z.string().nullable().optional(), + ColorTransfer: z.string().nullable().optional(), + ColorPrimaries: z.string().nullable().optional(), + DvVersionMajor: z.number().int().nullable().optional(), + DvVersionMinor: z.number().int().nullable().optional(), + DvProfile: z.number().int().nullable().optional(), + DvLevel: z.number().int().nullable().optional(), + RpuPresentFlag: z.number().int().nullable().optional(), + ElPresentFlag: z.number().int().nullable().optional(), + BlPresentFlag: z.number().int().nullable().optional(), + DvBlSignalCompatibilityId: z.number().int().nullable().optional(), + Comment: z.string().nullable().optional(), + TimeBase: z.string().nullable().optional(), + CodecTimeBase: z.string().nullable().optional(), + Title: z.string().nullable().optional(), + VideoRange: VideoRange, + VideoRangeType: VideoRangeType, + VideoDoViTitle: z.string().nullable().optional(), + // AudioSpatialFormat: AudioSpatialFormat.default('None'), + LocalizedUndefined: z.string().nullable().optional(), + LocalizedDefault: z.string().nullable().optional(), + LocalizedForced: z.string().nullable().optional(), + LocalizedExternal: z.string().nullable().optional(), + LocalizedHearingImpaired: z.string().nullable().optional(), + DisplayTitle: z.string().nullable().optional(), + NalLengthSize: z.string().nullable().optional(), + IsInterlaced: z.boolean(), + IsAVC: z.boolean().nullable().optional(), + ChannelLayout: z.string().nullable().optional(), + BitRate: z.number().int().nullable().optional(), + BitDepth: z.number().int().nullable().optional(), + RefFrames: z.number().int().nullable().optional(), + PacketLength: z.number().int().nullable().optional(), + Channels: z.number().int().nullable().optional(), + SampleRate: z.number().int().nullable().optional(), + IsDefault: z.boolean(), + IsForced: z.boolean(), + IsHearingImpaired: z.boolean(), + Height: z.number().int().nullable().optional(), + Width: z.number().int().nullable().optional(), + AverageFrameRate: z.number().nullable().optional(), + RealFrameRate: z.number().nullable().optional(), + Profile: z.string().nullable().optional(), + Type: MediaStreamType, + AspectRatio: z.string().nullable().optional(), + Index: z.number().int(), + Score: z.number().int().nullable().optional(), + IsExternal: z.boolean(), + DeliveryMethod: SubtitleDeliveryMethod.nullable().optional(), + DeliveryUrl: z.string().nullable().optional(), + IsExternalUrl: z.boolean().nullable().optional(), + IsTextSubtitleStream: z.boolean(), + SupportsExternalStream: z.boolean(), + Path: z.string().nullable().optional(), + PixelFormat: z.string().nullable().optional(), + Level: z.number().nullable().optional(), + IsAnamorphic: z.boolean().nullable().optional(), + }) + .partial(); + +export type JellyfinMediaStream = z.infer; + +const MediaSourceInfo = z + .object({ + Protocol: MediaProtocol, + Id: z.string().nullable().optional(), + Path: z.string().nullable().optional(), + EncoderPath: z.string().nullable().optional(), + EncoderProtocol: MediaProtocol.nullable().optional(), + Type: MediaSourceType, + Container: z.string().nullable().optional(), + Size: z.number().int().nullable().optional(), + Name: z.string().nullable().optional(), + IsRemote: z.boolean(), + ETag: z.string().nullable().optional(), + RunTimeTicks: z.number().int().nullable().optional(), + ReadAtNativeFramerate: z.boolean(), + IgnoreDts: z.boolean(), + IgnoreIndex: z.boolean(), + GenPtsInput: z.boolean(), + SupportsTranscoding: z.boolean(), + SupportsDirectStream: z.boolean(), + SupportsDirectPlay: z.boolean(), + IsInfiniteStream: z.boolean(), + RequiresOpening: z.boolean(), + OpenToken: z.string().nullable().optional(), + RequiresClosing: z.boolean(), + LiveStreamId: z.string().nullable().optional(), + BufferMs: z.number().int().nullable().optional(), + RequiresLooping: z.boolean(), + SupportsProbing: z.boolean(), + VideoType: VideoType.nullable().optional(), + IsoType: IsoType.nullable().optional(), + Video3DFormat: Video3DFormat.nullable().optional(), + MediaStreams: z.array(MediaStream).nullable().optional(), + MediaAttachments: z.array(MediaAttachment).nullable().optional(), + Formats: z.array(z.string()).nullable().optional(), + Bitrate: z.number().int().nullable().optional(), + // Timestamp: TransportStreamTimestamp.nullable().optional(), + RequiredHttpHeaders: z + .record(z.string().nullable().optional()) + .nullable() + .optional(), + TranscodingUrl: z.string().nullable().optional(), + // TranscodingSubProtocol: MediaStreamProtocol, + TranscodingContainer: z.string().nullable().optional(), + AnalyzeDurationMs: z.number().int().nullable().optional(), + DefaultAudioStreamIndex: z.number().int().nullable().optional(), + DefaultSubtitleStreamIndex: z.number().int().nullable().optional(), + }) + .partial(); + +export const JellyfinItem = z.object({ + Name: z.string().nullable().optional(), + OriginalTitle: z.string().nullable().optional(), + ServerId: z.string().nullable().optional(), + Id: z.string(), + Etag: z.string().nullable().optional(), + SourceType: z.string().nullable().optional(), + PlaylistItemId: z.string().nullable().optional(), + DateCreated: z.string().datetime({ offset: true }).nullable().optional(), + DateLastMediaAdded: z + .string() + .datetime({ offset: true }) + .nullable() + .optional(), + ExtraType: ExtraType.nullable().optional(), + AirsBeforeSeasonNumber: z.number().int().nullable().optional(), + AirsAfterSeasonNumber: z.number().int().nullable().optional(), + AirsBeforeEpisodeNumber: z.number().int().nullable().optional(), + CanDelete: z.boolean().nullable().optional(), + CanDownload: z.boolean().nullable().optional(), + HasLyrics: z.boolean().nullable().optional(), + HasSubtitles: z.boolean().nullable().optional(), + PreferredMetadataLanguage: z.string().nullable().optional(), + PreferredMetadataCountryCode: z.string().nullable().optional(), + Container: z.string().nullable().optional(), + SortName: z.string().nullable().optional(), + ForcedSortName: z.string().nullable().optional(), + Video3DFormat: Video3DFormat.nullable().optional(), + PremiereDate: z.string().datetime({ offset: true }).nullable().optional(), + ExternalUrls: z.array(ExternalUrl).nullable().optional(), + MediaSources: z.array(MediaSourceInfo).nullable().optional(), + CriticRating: z.number().nullable().optional(), + ProductionLocations: z.array(z.string()).nullable().optional(), + Path: z.string().nullable().optional(), + EnableMediaSourceDisplay: z.boolean().nullable().optional(), + OfficialRating: z.string().nullable().optional(), + CustomRating: z.string().nullable().optional(), + ChannelId: z.string().nullable().optional(), + ChannelName: z.string().nullable().optional(), + Overview: z.string().nullable().optional(), + Taglines: z.array(z.string()).nullable().optional(), + Genres: z.array(z.string()).nullable().optional(), + CommunityRating: z.number().nullable().optional(), + CumulativeRunTimeTicks: z.number().int().nullable().optional(), + RunTimeTicks: z.number().int().nullable().optional(), + // PlayAccess: PlayAccess.nullable().optional(), + AspectRatio: z.string().nullable().optional(), + ProductionYear: z.number().int().nullable().optional(), + IsPlaceHolder: z.boolean().nullable().optional(), + Number: z.string().nullable().optional(), + ChannelNumber: z.string().nullable().optional(), + IndexNumber: z.number().int().nullable().optional(), + IndexNumberEnd: z.number().int().nullable().optional(), + ParentIndexNumber: z.number().int().nullable().optional(), + // RemoteTrailers: z.array(MediaUrl).nullable().optional(), + ProviderIds: z.record(z.string().nullable().optional()).nullable().optional(), + IsHD: z.boolean().nullable().optional(), + IsFolder: z.boolean().nullable().optional(), + ParentId: z.string().nullable().optional(), + Type: JellyfinItemKind, + // People: z.array(BaseItemPerson).nullable().optional(), + Studios: z.array(NameGuidPair).nullable().optional(), + GenreItems: z.array(NameGuidPair).nullable().optional(), + ParentLogoItemId: z.string().nullable().optional(), + ParentBackdropItemId: z.string().nullable().optional(), + ParentBackdropImageTags: z.array(z.string()).nullable().optional(), + LocalTrailerCount: z.number().int().nullable().optional(), + // UserData: UserItemDataDto.nullable().optional(), + RecursiveItemCount: z.number().int().nullable().optional(), + ChildCount: z.number().int().nullable().optional(), + SeriesName: z.string().nullable().optional(), + SeriesId: z.string().nullable().optional(), + SeasonId: z.string().nullable().optional(), + SpecialFeatureCount: z.number().int().nullable().optional(), + DisplayPreferencesId: z.string().nullable().optional(), + Status: z.string().nullable().optional(), + AirTime: z.string().nullable().optional(), + // AirDays: z.array(DayOfWeek).nullable().optional(), + Tags: z.array(z.string()).nullable().optional(), + PrimaryImageAspectRatio: z.number().nullable().optional(), + Artists: z.array(z.string()).nullable().optional(), + ArtistItems: z.array(NameGuidPair).nullable().optional(), + Album: z.string().nullable().optional(), + CollectionType: CollectionType.nullable().optional(), + DisplayOrder: z.string().nullable().optional(), + AlbumId: z.string().nullable().optional(), + AlbumPrimaryImageTag: z.string().nullable().optional(), + SeriesPrimaryImageTag: z.string().nullable().optional(), + AlbumArtist: z.string().nullable().optional(), + AlbumArtists: z.array(NameGuidPair).nullable().optional(), + SeasonName: z.string().nullable().optional(), + MediaStreams: z.array(MediaStream).nullable().optional(), + VideoType: VideoType.nullable().optional(), + PartCount: z.number().int().nullable().optional(), + MediaSourceCount: z.number().int().nullable().optional(), + ImageTags: z.record(z.string()).nullable().optional(), + BackdropImageTags: z.array(z.string()).nullable().optional(), + ScreenshotImageTags: z.array(z.string()).nullable().optional(), + ParentLogoImageTag: z.string().nullable().optional(), + ParentArtItemId: z.string().nullable().optional(), + ParentArtImageTag: z.string().nullable().optional(), + SeriesThumbImageTag: z.string().nullable().optional(), + ImageBlurHashes: z + .object({ + Primary: z.record(z.string()), + Art: z.record(z.string()), + Backdrop: z.record(z.string()), + Banner: z.record(z.string()), + Logo: z.record(z.string()), + Thumb: z.record(z.string()), + Disc: z.record(z.string()), + Box: z.record(z.string()), + Screenshot: z.record(z.string()), + Menu: z.record(z.string()), + Chapter: z.record(z.string()), + BoxRear: z.record(z.string()), + Profile: z.record(z.string()), + }) + .partial() + .passthrough() + .nullable() + .optional(), + SeriesStudio: z.string().nullable().optional(), + ParentThumbItemId: z.string().nullable().optional(), + ParentThumbImageTag: z.string().nullable().optional(), + ParentPrimaryImageItemId: z.string().nullable().optional(), + ParentPrimaryImageTag: z.string().nullable().optional(), + Chapters: z.array(ChapterInfo).nullable().optional(), + // Trickplay: z.record(z.record(TrickplayInfo)).nullable().optional(), + // LocationType: LocationType.nullable().optional(), + IsoType: IsoType.nullable().optional(), + MediaType: JellyfinMediaType, + EndDate: z.string().datetime({ offset: true }).nullable().optional(), + // LockedFields: z.array(MetadataField).nullable().optional(), + TrailerCount: z.number().int().nullable().optional(), + MovieCount: z.number().int().nullable().optional(), + SeriesCount: z.number().int().nullable().optional(), + ProgramCount: z.number().int().nullable().optional(), + EpisodeCount: z.number().int().nullable().optional(), + SongCount: z.number().int().nullable().optional(), + AlbumCount: z.number().int().nullable().optional(), + ArtistCount: z.number().int().nullable().optional(), + MusicVideoCount: z.number().int().nullable().optional(), + LockData: z.boolean().nullable().optional(), + Width: z.number().int().nullable().optional(), + Height: z.number().int().nullable().optional(), + CameraMake: z.string().nullable().optional(), + CameraModel: z.string().nullable().optional(), + Software: z.string().nullable().optional(), + ExposureTime: z.number().nullable().optional(), + FocalLength: z.number().nullable().optional(), + // ImageOrientation: ImageOrientation.nullable().optional(), + Aperture: z.number().nullable().optional(), + ShutterSpeed: z.number().nullable().optional(), + Latitude: z.number().nullable().optional(), + Longitude: z.number().nullable().optional(), + Altitude: z.number().nullable().optional(), + IsoSpeedRating: z.number().int().nullable().optional(), + SeriesTimerId: z.string().nullable().optional(), + ProgramId: z.string().nullable().optional(), + ChannelPrimaryImageTag: z.string().nullable().optional(), + StartDate: z.string().datetime({ offset: true }).nullable().optional(), + CompletionPercentage: z.number().nullable().optional(), + IsRepeat: z.boolean().nullable().optional(), + EpisodeTitle: z.string().nullable().optional(), + // ChannelType: ChannelType.nullable().optional(), + // Audio: ProgramAudio.nullable().optional(), + IsMovie: z.boolean().nullable().optional(), + IsSports: z.boolean().nullable().optional(), + IsSeries: z.boolean().nullable().optional(), + IsLive: z.boolean().nullable().optional(), + IsNews: z.boolean().nullable().optional(), + IsKids: z.boolean().nullable().optional(), + IsPremiere: z.boolean().nullable().optional(), + TimerId: z.string().nullable().optional(), + NormalizationGain: z.number().nullable().optional(), + // CurrentProgram: BaseItemDto.nullable().optional(), +}); +// ); + +export type JellyfinItem = z.infer; + +// export const JellyfinLibraryItem = z.object({ +// Name: z.string(), +// Id: z.string(), +// Etag: z.string().nullable().optional(), +// // We should always request this +// Path: z.string().nullable().optional(), +// OfficialRating: z.string().nullable().optional(), +// DateCreated: z.string().nullable().optional(), +// CommunityRating: z.number().nullable().optional(), +// RunTimeTicks: z.number(), +// Genres: z.array(z.string()).nullable().optional(), +// Tags: z.array(z.string()).nullable().optional(), +// ProductionYear: z.number().nullable().optional(), +// ProviderIds: z.object({ +// Imdb: z.string().nullable().optional(), +// Tmdb: z.string().nullable().optional(), +// TmdbCollection: z.string().nullable().optional(), +// Tvdb: z.string().nullable().optional(), +// }), +// PremiereDate: z.string().nullable().optional(), +// MediaStreams: z.array(JellyfinMediaStream).nullable().optional(), +// LocationType: z.string(), +// Overview: z.string(), +// Taglines: z.array(z.string()).nullable().optional(), +// Studios: z.array(JellyfinJoinItem).nullable().optional(), +// People: z.array(JellyfinPerson).nullable().optional(), +// ImageTags: z +// .object({ +// Primary: z.string().nullable().optional(), +// Logo: z.string().nullable().optional(), +// Thumb: z.string().nullable().optional(), +// }) +// .nullable().optional(), +// BackdropImageTags: z.array(z.string()).nullable().optional(), +// IndexNumber: z.number().nullable().optional(), +// Type: z.string(), +// Chapters: z.array(JellyfinChapter).nullable().optional(), +// }); + +export const JellyfinLibraryItemsResponse = z.object({ + Items: z.array(JellyfinItem), + TotalRecordCount: z.number(), + StartIndex: z.number().nullable().optional(), +}); + +export type JellyfinLibraryItemsResponse = z.infer< + typeof JellyfinLibraryItemsResponse +>; + +const JellyfinSessionInfo = z + .object({ + // PlayState: PlayerStateInfo.nullable().optional(), + // AdditionalUsers: z.array(SessionUserInfo).nullable().optional(), + // Capabilities: ClientCapabilities.nullable().optional(), + RemoteEndPoint: z.string().nullable().optional(), + PlayableMediaTypes: z.array(JellyfinMediaType).nullable().optional(), + Id: z.string().nullable().optional(), + UserId: z.string(), + UserName: z.string().nullable().optional(), + Client: z.string().nullable().optional(), + LastActivityDate: z.string().datetime({ offset: true }), + LastPlaybackCheckIn: z.string().datetime({ offset: true }), + LastPausedDate: z.string().datetime({ offset: true }).nullable().optional(), + DeviceName: z.string().nullable().optional(), + DeviceType: z.string().nullable().optional(), + // NowPlayingItem: BaseItemDto.nullable().optional(), + // NowViewingItem: BaseItemDto.nullable().optional(), + DeviceId: z.string().nullable().optional(), + ApplicationVersion: z.string().nullable().optional(), + // TranscodingInfo: TranscodingInfo.nullable().optional(), + IsActive: z.boolean(), + SupportsMediaControl: z.boolean(), + SupportsRemoteControl: z.boolean(), + // NowPlayingQueue: z.array(QueueItem).nullable().optional(), + // NowPlayingQueueFullItems: z.array(BaseItemDto).nullable().optional(), + HasCustomDeviceName: z.boolean(), + PlaylistItemId: z.string().nullable().optional(), + ServerId: z.string().nullable().optional(), + UserPrimaryImageTag: z.string().nullable().optional(), + // SupportedCommands: z.array(GeneralCommandType).nullable().optional(), + }) + .partial(); + +export const JellyfinUserConfiguration = z + .object({ + AudioLanguagePreference: z.string().nullable().optional(), + PlayDefaultAudioTrack: z.boolean(), + SubtitleLanguagePreference: z.string().nullable().optional(), + DisplayMissingEpisodes: z.boolean(), + GroupedFolders: z.array(z.string()), + // SubtitleMode: SubtitlePlaybackMode, + DisplayCollectionsView: z.boolean(), + EnableLocalPassword: z.boolean(), + OrderedViews: z.array(z.string()), + LatestItemsExcludes: z.array(z.string()), + MyMediaExcludes: z.array(z.string()), + HidePlayedInLatest: z.boolean(), + RememberAudioSelections: z.boolean(), + RememberSubtitleSelections: z.boolean(), + EnableNextEpisodeAutoPlay: z.boolean(), + CastReceiverId: z.string().nullable().optional(), + }) + .partial(); + +export const JellyfinUser = z + .object({ + Name: z.string().nullable().optional(), + ServerId: z.string().nullable().optional(), + ServerName: z.string().nullable().optional(), + Id: z.string(), + PrimaryImageTag: z.string().nullable().optional(), + HasPassword: z.boolean(), + HasConfiguredPassword: z.boolean(), + HasConfiguredEasyPassword: z.boolean(), + EnableAutoLogin: z.boolean().nullable().optional(), + LastLoginDate: z.string().datetime({ offset: true }).nullable().optional(), + LastActivityDate: z + .string() + .datetime({ offset: true }) + .nullable() + .optional(), + Configuration: JellyfinUserConfiguration.nullable().optional(), + // Policy: UserPolicy.nullable().optional(), + PrimaryImageAspectRatio: z.number().nullable().optional(), + }) + .partial(); + +export const JellyfinAuthenticationResult = z + .object({ + User: JellyfinUser.nullable().optional(), + SessionInfo: JellyfinSessionInfo.nullable().optional(), + AccessToken: z.string().nullable().optional(), + ServerId: z.string().nullable().optional(), + }) + .partial(); + +export const JellyfinSystemInfo = z + .object({ + LocalAddress: z.string().nullable().optional(), + ServerName: z.string().nullable().optional(), + Version: z.string().nullable().optional(), + ProductName: z.string().nullable().optional(), + OperatingSystem: z.string().nullable().optional(), + Id: z.string().nullable().optional(), + StartupWizardCompleted: z.boolean().nullable().optional(), + OperatingSystemDisplayName: z.string().nullable().optional(), + PackageName: z.string().nullable().optional(), + HasPendingRestart: z.boolean(), + IsShuttingDown: z.boolean(), + SupportsLibraryMonitor: z.boolean(), + WebSocketPortNumber: z.number().int(), + // CompletedInstallations: z.array(InstallationInfo).nullable().optional(), + CanSelfRestart: z.boolean().default(true), + CanLaunchWebBrowser: z.boolean().default(false), + ProgramDataPath: z.string().nullable().optional(), + WebPath: z.string().nullable().optional(), + ItemsByNamePath: z.string().nullable().optional(), + CachePath: z.string().nullable().optional(), + LogPath: z.string().nullable().optional(), + InternalMetadataPath: z.string().nullable().optional(), + TranscodingTempPath: z.string().nullable().optional(), + // CastReceiverApplications: z.array(CastReceiverApplication).nullable().optional(), + HasUpdateAvailable: z.boolean().default(false), + EncoderLocation: z.string().nullable().optional().default('System'), + SystemArchitecture: z.string().nullable().optional().default('X64'), + }) + .partial(); + +export function isTerminalJellyfinItem(item: JellyfinItem): boolean { + return ['Movie', 'Episode', 'Audio'].includes(item.Type); +} + +export function isJellyfinType( + item: JellyfinItem, + types: JellyfinItemKind[], +): boolean { + return types.includes(item.Type); +} diff --git a/types/src/plex/index.ts b/types/src/plex/index.ts index 02c8ba03..27e9105b 100644 --- a/types/src/plex/index.ts +++ b/types/src/plex/index.ts @@ -1,4 +1,5 @@ import z from 'zod'; +import { FindChild } from '../util.js'; export * from './dvr.js'; @@ -757,21 +758,12 @@ export type PlexMetadataType< T extends { Metadata: M[] } = { Metadata: M[] }, > = T['Metadata'][0]; -type FindChild0 = Arr extends [ - [infer Head, infer Child], - ...infer Tail, -] - ? Head extends Target - ? Child - : FindChild0 - : never; - export type PlexChildMediaType = Target extends PlexTerminalMedia ? Target - : FindChild0; + : FindChild; -export type PlexChildMediaApiType = FindChild0< +export type PlexChildMediaApiType = FindChild< Target, PlexMediaApiChildType >; diff --git a/types/src/schemas/programmingSchema.ts b/types/src/schemas/programmingSchema.ts index 4c5bfc85..4a68f356 100644 --- a/types/src/schemas/programmingSchema.ts +++ b/types/src/schemas/programmingSchema.ts @@ -9,6 +9,7 @@ import { PlexMusicTrackSchema, } from '../plex/index.js'; import { ChannelIconSchema, ExternalIdSchema } from './utilSchemas.js'; +import { JellyfinItem } from '../jellyfin/index.js'; export const ProgramTypeSchema = z.union([ z.literal('movie'), @@ -19,7 +20,10 @@ export const ProgramTypeSchema = z.union([ z.literal('flex'), ]); -export const ExternalSourceTypeSchema = z.literal('plex'); +export const ExternalSourceTypeSchema = z.union([ + z.literal('plex'), + z.literal('jellyfin'), +]); export const ProgramSchema = z.object({ artistName: z.string().optional(), @@ -80,17 +84,30 @@ export const RedirectProgramSchema = BaseProgramSchema.extend({ channelName: z.string(), }); +const OriginalProgramSchema = z.discriminatedUnion('sourceType', [ + z.object({ + sourceType: z.literal('plex'), + program: z.discriminatedUnion('type', [ + PlexEpisodeSchema, + PlexMovieSchema, + PlexMusicTrackSchema, + ]), + }), + z.object({ + sourceType: z.literal('jellyfin'), + program: JellyfinItem, + }), +]); + +export type ContentProgramOriginalProgram = z.infer< + typeof OriginalProgramSchema +>; + export const CondensedContentProgramSchema = BaseProgramSchema.extend({ type: z.literal('content'), id: z.string().optional(), // Populated if persisted // Only populated on client requests to the server - originalProgram: z - .discriminatedUnion('type', [ - PlexEpisodeSchema, - PlexMovieSchema, - PlexMusicTrackSchema, - ]) - .optional(), + originalProgram: OriginalProgramSchema.optional(), }); export const ContentProgramTypeSchema = z.union([ diff --git a/types/src/schemas/settingsSchemas.ts b/types/src/schemas/settingsSchemas.ts index 0b8854ac..4e5e2e53 100644 --- a/types/src/schemas/settingsSchemas.ts +++ b/types/src/schemas/settingsSchemas.ts @@ -1,6 +1,6 @@ import z from 'zod'; import { ResolutionSchema } from './miscSchemas.js'; -import { TupleToUnion } from '../util.js'; +import { Tag, TupleToUnion } from '../util.js'; import { ScheduleSchema } from './utilSchemas.js'; export const XmlTvSettingsSchema = z.object({ @@ -88,19 +88,42 @@ export const FfmpegSettingsSchema = z.object({ disableChannelPrelude: z.boolean().default(false), }); -export const PlexServerSettingsSchema = z.object({ - id: z.string(), +const mediaSourceId = z.custom((val) => { + return typeof val === 'string'; +}); + +export type MediaSourceId = Tag; + +const BaseMediaSourceSettingsSchema = z.object({ + id: mediaSourceId, name: z.string(), uri: z.string(), accessToken: z.string(), +}); + +export const PlexServerSettingsSchema = BaseMediaSourceSettingsSchema.extend({ + type: z.literal('plex'), sendGuideUpdates: z.boolean(), sendChannelUpdates: z.boolean(), index: z.number(), clientIdentifier: z.string().optional(), }); +export const JellyfinServerSettingsSchema = + BaseMediaSourceSettingsSchema.extend({ + type: z.literal('jellyfin'), + }); + +export const MediaSourceSettingsSchema = z.discriminatedUnion('type', [ + PlexServerSettingsSchema, + JellyfinServerSettingsSchema, +]); + export const PlexStreamSettingsSchema = z.object({ - streamPath: z.union([z.literal('plex'), z.literal('direct')]).default('plex'), + // Plex is deprecated here + streamPath: z + .union([z.literal('plex'), z.literal('direct'), z.literal('network')]) + .default('network'), enableDebugLogging: z.boolean().default(false), directStreamBitrate: z.number().default(20000), transcodeBitrate: z.number().default(2000), diff --git a/types/src/schemas/utilSchemas.ts b/types/src/schemas/utilSchemas.ts index 846cf5d9..67383e7c 100644 --- a/types/src/schemas/utilSchemas.ts +++ b/types/src/schemas/utilSchemas.ts @@ -9,6 +9,7 @@ export const ExternalIdType = [ 'imdb', 'tmdb', 'tvdb', + 'jellyfin', ] as const; export type ExternalIdType = TupleToUnion; @@ -26,7 +27,7 @@ export const SingleExternalIdSourceSchema = constructZodLiteralUnionType( SingleExternalIdType.map((typ) => z.literal(typ)), ); -export const MultiExternalIdType = ['plex'] as const; +export const MultiExternalIdType = ['plex', 'jellyfin'] as const; export type MultiExternalIdType = TupleToUnion; function inConstArr( @@ -64,7 +65,10 @@ export const SingleExternalIdSchema = z.object({ }); // When we have more sources, this will be a union -export const MultiExternalSourceSchema = z.literal('plex'); +export const MultiExternalSourceSchema = z.union([ + z.literal('plex'), + z.literal('jellyfin'), +]); // Represents components of an ID that can be // used to address an object (program or grouping) in diff --git a/types/src/util.ts b/types/src/util.ts index 9cc986ae..51902863 100644 --- a/types/src/util.ts +++ b/types/src/util.ts @@ -14,3 +14,16 @@ export const tag = < ): UTag => x as unknown as UTag; export type TupleToUnion> = T[number]; + +/** + * Given a type of an array of 2-tuples representing "parent" and "child", + * finds the matching "child" given the type of the "parent". + */ +export type FindChild = Arr extends [ + [infer Head, infer Child], + ...infer Tail, +] + ? Head extends Target + ? Child + : FindChild + : never; diff --git a/types/tsup.config.ts b/types/tsup.config.ts index 12d89f6c..d4fe6606 100644 --- a/types/tsup.config.ts +++ b/types/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'schemas/index': 'src/schemas/index.ts', 'plex/index': 'src/plex/index.ts', 'api/index': 'src/api/index.ts', + 'jellyfin/index': 'src/jellyfin/index.ts', }, format: 'esm', dts: true, diff --git a/web/package.json b/web/package.json index 4a590918..fc621fb9 100644 --- a/web/package.json +++ b/web/package.json @@ -73,6 +73,7 @@ "ts-essentials": "^9.4.1", "typescript": "5.4.3", "vite": "^5.4.1", + "vite-plugin-svgr": "^4.2.0", "vitest": "^2.0.5" } } diff --git a/web/src/assets/jellyfin.svg b/web/src/assets/jellyfin.svg new file mode 100644 index 00000000..be8668ad --- /dev/null +++ b/web/src/assets/jellyfin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/components/GridContainerTabPanel.tsx b/web/src/components/GridContainerTabPanel.tsx new file mode 100644 index 00000000..6d856ff4 --- /dev/null +++ b/web/src/components/GridContainerTabPanel.tsx @@ -0,0 +1,49 @@ +import { Unstable_Grid2 as Grid } from '@mui/material'; +import { ForwardedRef, forwardRef } from 'react'; +import useStore from '../store'; + +type TabPanelProps = { + children?: React.ReactNode; + index: number; + value: number; + ref?: React.RefObject; +}; + +export const GridContainerTabPanel = forwardRef( + (props: TabPanelProps, ref: ForwardedRef) => { + const { children, value, index, ...other } = props; + + const viewType = useStore((state) => state.theme.programmingSelectorView); + + return ( + + ); + }, +); diff --git a/web/src/components/InlineModal.tsx b/web/src/components/InlineModal.tsx index ff46915f..e84c5852 100644 --- a/web/src/components/InlineModal.tsx +++ b/web/src/components/InlineModal.tsx @@ -1,60 +1,62 @@ +import { + useCurrentMediaSource, + useKnownMedia, +} from '@/store/programmingSelector/selectors.ts'; import { Box, Collapse, List } from '@mui/material'; -import { PlexMedia, isPlexMedia, isPlexParentItem } from '@tunarr/types/plex'; +import { MediaSourceSettings } from '@tunarr/types'; import { usePrevious } from '@uidotdev/usehooks'; -import _ from 'lodash-es'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useIntersectionObserver } from 'usehooks-ts'; +import { chain, first } from 'lodash-es'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useBoolean, useIntersectionObserver } from 'usehooks-ts'; import { extractLastIndexes, findFirstItemInNextRowIndex, getEstimatedModalHeight, getImagesPerRow, } from '../helpers/inlineModalUtil'; -import { toggle } from '../helpers/util.ts'; import useStore from '../store'; -import { PlexGridItem } from './channel_config/PlexGridItem'; +import { GridInlineModalProps } from './channel_config/MediaItemGrid.tsx'; -type InlineModalProps = { - itemGuid: string; - modalIndex: number; - open?: boolean; - rowSize: number; - type: PlexMedia['type'] | 'all'; -}; +interface InlineModalProps + extends GridInlineModalProps { + getItemType: (item: ItemType) => ItemKind; // Tmp change //PlexMedia['type'] | 'all'; + getChildItemType: (item: ItemType) => ItemKind; + sourceType: MediaSourceSettings['type']; + extractItemId: (item: ItemType) => string; +} -export function InlineModal(props: InlineModalProps) { - const { itemGuid, modalIndex, open, rowSize, type } = props; +export function InlineModal( + props: InlineModalProps, +) { + const { + modalItemGuid: itemGuid, + modalIndex, + open, + rowSize, + getItemType, + getChildItemType, + extractItemId, + renderChildren, + } = props; const previousItemGuid = usePrevious(itemGuid); const [containerWidth, setContainerWidth] = useState(0); const [itemWidth, setItemWidth] = useState(0); - const [isOpen, setIsOpen] = useState(false); + const { + value: isOpen, + setTrue: setOpen, + setFalse: setClosed, + } = useBoolean(false); const ref = useRef(null); const gridItemRef = useRef(null); const inlineModalRef = useRef(null); const darkMode = useStore((state) => state.theme.darkMode); const [childLimit, setChildLimit] = useState(9); const [imagesPerRow, setImagesPerRow] = useState(0); - const modalChildren: PlexMedia[] = useStore((s) => { - const known = s.contentHierarchyByServer[s.currentServer!.name]; - if (known) { - const children = known[itemGuid]; - if (children) { - return _.chain(children) - .map((id) => s.knownMediaByServer[s.currentServer!.name][id]) - .compact() - .filter(isPlexMedia) - .value(); - } - } - - return []; - }); + const currentMediaSource = useCurrentMediaSource(props.sourceType); + const knownMedia = useKnownMedia(); + const modalChildren = knownMedia + .getChildren(currentMediaSource!.id, itemGuid ?? '') + .map((media) => media.item) as ItemType[]; const modalHeight = useMemo( () => @@ -63,15 +65,11 @@ export function InlineModal(props: InlineModalProps) { containerWidth, itemWidth, modalChildren.length, - type, + first(modalChildren) ? getItemType(first(modalChildren)!) : 'unknown', ), - [containerWidth, itemWidth, modalChildren?.length, rowSize, type], + [containerWidth, itemWidth, modalChildren, rowSize, getItemType], ); - const toggleModal = useCallback(() => { - setIsOpen(toggle); - }, []); - useEffect(() => { if (ref.current && previousItemGuid !== itemGuid) { const containerWidth = ref?.current?.getBoundingClientRect().width || 0; @@ -82,11 +80,17 @@ export function InlineModal(props: InlineModalProps) { setItemWidth(itemWidth); setContainerWidth(containerWidth); setImagesPerRow(imagesPerRow); + setChildModalInfo({ childItemGuid: '', childModalIndex: -1 }); } }, [ref, gridItemRef, previousItemGuid, itemGuid]); - const [childItemGuid, setChildItemGuid] = useState(null); - const [childModalIndex, setChildModalIndex] = useState(-1); + const [{ childModalIndex, childItemGuid }, setChildModalInfo] = useState<{ + childItemGuid: string | null; + childModalIndex: number; + }>({ + childItemGuid: null, + childModalIndex: -1, + }); const firstItemInNextRowIndex = useMemo( () => @@ -98,10 +102,25 @@ export function InlineModal(props: InlineModalProps) { [childModalIndex, modalChildren?.length, rowSize], ); - const handleMoveModal = useCallback((index: number, item: PlexMedia) => { - setChildItemGuid((prev) => (prev === item.guid ? null : item.guid)); - setChildModalIndex((prev) => (prev === index ? -1 : index)); - }, []); + const handleMoveModal = useCallback( + (index: number, item: ItemType) => { + const id = extractItemId(item); + setChildModalInfo((prev) => { + if (prev.childItemGuid === id) { + return { + childItemGuid: null, + childModalIndex: -1, + }; + } else { + return { + childItemGuid: id, + childModalIndex: index, + }; + } + }); + }, + [extractItemId], + ); // TODO: Complete this by updating the limit below, not doing this // right now because already working with a huge changeset. @@ -125,10 +144,52 @@ export function InlineModal(props: InlineModalProps) { ).includes(childModalIndex) : false; + const renderChild = useCallback( + (idx: number, item: ItemType) => { + return renderChildren( + { + index: idx, + item: item, + isModalOpen: modalIndex === idx, + moveModal: handleMoveModal, + ref: gridItemRef, + }, + { + modalItemGuid: childItemGuid ?? '', + modalIndex: childModalIndex, + open: idx === firstItemInNextRowIndex, + renderChildren, + rowSize: rowSize, + }, + ); + }, + [ + childItemGuid, + childModalIndex, + firstItemInNextRowIndex, + handleMoveModal, + modalIndex, + renderChildren, + rowSize, + ], + ); + const show = useCallback(() => { + setOpen(); + }, [setOpen]); + + const hide = useCallback(() => { + setClosed(); + }, [setClosed]); + return ( - {_.chain(modalChildren) - .filter(isPlexMedia) - .take(childLimit) - .map((child: PlexMedia, idx: number) => ( - - {isPlexParentItem(child) && ( - - )} - - - )) - .value()} - {/* This Modal is for last row items because they can't be inserted using the above inline modal */} - -
  • + {isOpen && ( + <> + {chain(modalChildren) + .take(childLimit) + .map((item, idx) => renderChild(idx, item)) + .value()} + + {childLimit < modalChildren.length && ( +
  • + )} + + )}
    diff --git a/web/src/components/ProgramDetailsDialog.tsx b/web/src/components/ProgramDetailsDialog.tsx index 6a3ea680..82a9c6c8 100644 --- a/web/src/components/ProgramDetailsDialog.tsx +++ b/web/src/components/ProgramDetailsDialog.tsx @@ -1,3 +1,6 @@ +import JellyfinIcon from '@/assets/jellyfin.svg?react'; +import PlexIcon from '@/assets/plex.svg?react'; +import { Maybe } from '@/types/util.ts'; import { Close as CloseIcon, OpenInNew } from '@mui/icons-material'; import { Box, @@ -9,15 +12,20 @@ import { IconButton, Skeleton, Stack, + SvgIcon, Typography, useMediaQuery, useTheme, } from '@mui/material'; import { createExternalId } from '@tunarr/shared'; import { forProgramType } from '@tunarr/shared/util'; -import { ChannelProgram, TvGuideProgram } from '@tunarr/types'; +import { + ChannelProgram, + TvGuideProgram, + isContentProgram, +} from '@tunarr/types'; import dayjs, { Dayjs } from 'dayjs'; -import { isUndefined } from 'lodash-es'; +import { capitalize, compact, find, isUndefined } from 'lodash-es'; import { ReactEventHandler, useCallback, @@ -94,9 +102,10 @@ export default function ProgramDetailsDialog({ forProgramType({ content: (program) => ( ), }), @@ -107,12 +116,83 @@ export default function ProgramDetailsDialog({ (program: ChannelProgram) => { const ratingString = rating(program); return ratingString ? ( - + ) : null; }, [rating], ); + const sourceChip = useCallback((program: ChannelProgram) => { + if (isContentProgram(program)) { + const id = find( + program.externalIds, + (eid) => + eid.type === 'multi' && + (eid.source === 'jellyfin' || eid.source === 'plex'), + ); + if (!id) { + return null; + } + + let icon: Maybe = undefined; + switch (id.source) { + case 'jellyfin': + icon = ; + break; + case 'plex': + icon = ; + break; + default: + break; + } + + if (icon) { + return ( + {icon}} + label={capitalize(id.source)} + sx={{ mr: 1, mt: 1 }} + /> + ); + } + } + + return null; + }, []); + + const timeChip = () => { + if (start && stop) { + return ( + + ); + } + + return null; + }; + + const chips = (program: ChannelProgram) => { + return compact([ + durationChip(program), + ratingChip(program), + timeChip(), + sourceChip(program), + ]); + }; + const thumbnailImage = useMemo( () => forProgramType({ @@ -131,16 +211,35 @@ export default function ProgramDetailsDialog({ } let key = p.uniqueId; - if ( - p.subtype === 'track' && - p.originalProgram?.type === 'track' && - isNonEmptyString(p.originalProgram.parentRatingKey) - ) { - key = createExternalId( - 'plex', - p.externalSourceName!, - p.originalProgram.parentRatingKey, - ); + if (p.subtype === 'track' && p.originalProgram) { + switch (p.originalProgram.sourceType) { + case 'plex': { + if ( + p.originalProgram.program.type === 'track' && + isNonEmptyString(p.originalProgram.program.parentRatingKey) + ) { + key = createExternalId( + p.originalProgram?.sourceType, + p.externalSourceName!, + p.originalProgram.program.parentRatingKey, + ); + } + break; + } + case 'jellyfin': { + if ( + p.originalProgram.program.Type === 'Audio' && + isNonEmptyString(p.originalProgram.program.AlbumId) + ) { + key = createExternalId( + p.originalProgram.sourceType, + p.externalSourceName!, + p.originalProgram.program.AlbumId, + ); + } + break; + } + } } return `${settings.backendUri}/api/metadata/external?id=${key}&mode=proxy&asset=thumb`; @@ -181,8 +280,33 @@ export default function ProgramDetailsDialog({ const isEpisode = program && program.type === 'content' && program.subtype === 'episode'; const imageWidth = smallViewport ? (isEpisode ? '100%' : '55%') : 240; - const programStart = dayjs(start); - const programEnd = dayjs(stop); + + let externalSourceName: string = ''; + if (program) { + switch (program.type) { + case 'content': { + const eid = find( + program.externalIds, + (eid) => + eid.type === 'multi' && + (eid.source === 'plex' || eid.source === 'jellyfin'), + ); + if (eid) { + switch (eid.source) { + case 'plex': + externalSourceName = 'Plex'; + break; + case 'jellyfin': + externalSourceName = 'Jellyfin'; + break; + } + } + break; + } + default: + break; + } + } return ( program && ( @@ -206,17 +330,7 @@ export default function ProgramDetailsDialog({ - - {durationChip(program)} - {ratingChip(program)} - - + {chips(program)} )} @@ -267,7 +387,7 @@ export default function ProgramDetailsDialog({ width={imageWidth} /> )} - {externalUrl && ( + {externalUrl && isNonEmptyString(externalSourceName) && ( )} diff --git a/web/src/components/TabPanel.tsx b/web/src/components/TabPanel.tsx index cb581f3f..a30ed758 100644 --- a/web/src/components/TabPanel.tsx +++ b/web/src/components/TabPanel.tsx @@ -1,51 +1,22 @@ -import { Unstable_Grid2 as Grid } from '@mui/material'; -import { ForwardedRef, forwardRef } from 'react'; -import useStore from '../store'; - -type TabPanelProps = { +interface TabPanelProps { children?: React.ReactNode; index: number; value: number; - ref?: React.RefObject; -}; +} -const CustomTabPanel = forwardRef( - (props: TabPanelProps, ref: ForwardedRef) => { - const { children, value, index, ...other } = props; +export function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; - const viewType = useStore((state) => state.theme.programmingSelectorView); - - return ( - - ); - }, -); - -export default CustomTabPanel; + return ( + + ); +} diff --git a/web/src/components/channel_config/AddSelectedMediaButton.tsx b/web/src/components/channel_config/AddSelectedMediaButton.tsx index 650c957c..39eef9ed 100644 --- a/web/src/components/channel_config/AddSelectedMediaButton.tsx +++ b/web/src/components/channel_config/AddSelectedMediaButton.tsx @@ -13,6 +13,8 @@ import useStore from '../../store/index.ts'; import { clearSelectedMedia } from '../../store/programmingSelector/actions.ts'; import { CustomShowSelectedMedia } from '../../store/programmingSelector/store.ts'; import { AddedCustomShowProgram, AddedMedia } from '../../types/index.ts'; +import { useKnownMedia } from '@/store/programmingSelector/selectors.ts'; +import { enumerateJellyfinItem } from '@/hooks/jellyfin/jellyfinHookUtil.ts'; type Props = { onAdd: (items: AddedMedia[]) => void; @@ -29,7 +31,7 @@ export default function AddSelectedMediaButton({ ...rest }: Props) { const apiClient = useTunarrApi(); - const knownMedia = useStore((s) => s.knownMediaByServer); + const knownMedia = useKnownMedia(); const selectedMedia = useStore((s) => s.selectedMedia); const [isLoading, setIsLoading] = useState(false); @@ -41,14 +43,45 @@ export default function AddSelectedMediaButton({ selectedMedia, forSelectedMediaType>({ plex: async (selected) => { - const media = knownMedia[selected.server][selected.guid]; + const media = knownMedia.getMediaOfType( + selected.serverId, + selected.id, + 'plex', + ); + + if (!media) { + return []; + } + const items = await enumeratePlexItem( apiClient, - selected.server, + selected.serverId, + selected.serverName, media, )(); + return map(items, (item) => ({ media: item, type: 'plex' })); }, + jellyfin: async (selected) => { + const media = knownMedia.getMediaOfType( + selected.serverId, + selected.id, + 'jellyfin', + ); + + if (!media) { + return []; + } + + const items = await enumerateJellyfinItem( + apiClient, + selected.serverId, + selected.serverName, + media, + )(); + + return map(items, (item) => ({ media: item, type: 'jellyfin' })); + }, 'custom-show': ( selected: CustomShowSelectedMedia, ): Promise => { diff --git a/web/src/components/channel_config/ChannelProgrammingList.tsx b/web/src/components/channel_config/ChannelProgrammingList.tsx index 8ab309e1..eaf91ce2 100644 --- a/web/src/components/channel_config/ChannelProgrammingList.tsx +++ b/web/src/components/channel_config/ChannelProgrammingList.tsx @@ -340,7 +340,7 @@ export default function ChannelProgrammingList({ const [focusedProgramDetails, setFocusedProgramDetails] = useState< ChannelProgram | undefined >(); - const [startStop, setStartStop] = useState({}); + const [, setStartStop] = useState({}); const [editProgram, setEditProgram] = useState< ((UIFlexProgram | UIRedirectProgram) & { index: number }) | undefined >(); @@ -474,8 +474,6 @@ export default function ChannelProgrammingList({ open={!isUndefined(focusedProgramDetails)} onClose={() => setFocusedProgramDetails(undefined)} program={focusedProgramDetails} - start={startStop.start} - stop={startStop.stop} /> {} + +const extractChildCount = forJellyfinItem({ + Season: (s) => s.ChildCount, + Series: (s) => s.ChildCount, + CollectionFolder: (s) => s.ChildCount, + Playlist: (s) => s.ChildCount, + default: 0, +}); + +const childItemType = forJellyfinItem({ + Season: 'episode', + Series: 'season', + CollectionFolder: 'item', + Playlist: (pl) => (pl.MediaType === 'Audio' ? 'track' : 'video'), + MusicArtist: 'album', + MusicAlbum: 'track', +}); + +const childJellyfinType = forJellyfinItem({ + Season: 'Episode', + Series: 'Season', +}); + +const subtitle = forJellyfinItem({ + Movie: (item) => ( + {prettyItemDuration((item.RunTimeTicks ?? 0) / 10_000)} + ), + default: (item) => { + const childCount = extractChildCount(item); + if (isNil(childCount)) { + return null; + } + + return ( + {`${childCount} ${pluralize( + childItemType(item) ?? 'item', + childCount, + )}`} + ); + }, +}); + +export const JellyfinGridItem = memo( + forwardRef( + (props: JellyfinGridItemProps, ref: ForwardedRef) => { + const { item, index, moveModal } = props; + const [modalOpen, setModalOpen] = useState(false); + const currentServer = useCurrentMediaSource('jellyfin'); + + const isMusicItem = useCallback( + (item: JellyfinItem) => + ['MusicArtist', 'MusicAlbum', 'Audio'].includes(item.Type), + [], + ); + + const isEpisode = useCallback( + (item: JellyfinItem) => item.Type === 'Episode', + [], + ); + + const hasChildren = ['Series', 'Season'].includes(item.Type); + const childKind = childJellyfinType(item); + + useJellyfinLibraryItems( + currentServer!.id, + item.Id, + childKind ? [childKind] : [], + null, + hasChildren && modalOpen, + ); + + const moveModalToItem = useCallback(() => { + moveModal(index, item); + }, [index, item, moveModal]); + + const handleItemClick = useCallback(() => { + setModalOpen(toggle); + moveModalToItem(); + }, [moveModalToItem]); + + const thumbnailUrlFunc = useCallback( + (item: JellyfinItem) => { + return `${currentServer?.uri}/Items/${ + item.Id + }/Images/Primary?fillHeight=300&fillWidth=200&quality=96&tag=${ + (item.ImageTags ?? {})['Primary'] + }`; + }, + [currentServer], + ); + + const selectedMediaFunc = useCallback( + (item: JellyfinItem): SelectedMedia => { + return { + type: 'jellyfin', + serverId: currentServer!.id, + serverName: currentServer!.name, + childCount: extractChildCount(item), + id: item.Id, + }; + }, + [currentServer], + ); + + const metadata = useMemo( + () => ({ + itemId: item.Id, + isPlaylist: item.Type === 'Playlist', + hasThumbnail: isNonEmptyString((item.ImageTags ?? {})['Primary']), + childCount: extractChildCount(item), + isMusicItem: isMusicItem(item), + isEpisode: isEpisode(item), + title: item.Name ?? '', + subtitle: subtitle(item), + thumbnailUrl: thumbnailUrlFunc(item), + selectedMedia: selectedMediaFunc(item), + }), + [isEpisode, isMusicItem, item, selectedMediaFunc, thumbnailUrlFunc], + ); + + return ( + currentServer && ( + addJellyfinSelectedMedia(currentServer, item)} + /> + ) + ); + }, + ), + (prev, next) => { + // if (!isEqual(prev, next)) { + // console.log(prev, next); + // } + return isEqual(prev, next); + }, +); diff --git a/web/src/components/channel_config/JellyfinListItem.tsx b/web/src/components/channel_config/JellyfinListItem.tsx new file mode 100644 index 00000000..40bf7922 --- /dev/null +++ b/web/src/components/channel_config/JellyfinListItem.tsx @@ -0,0 +1,172 @@ +import { typedProperty } from '@/helpers/util.ts'; +import { useJellyfinLibraryItems } from '@/hooks/jellyfin/useJellyfinApi.ts'; +import { + addJellyfinSelectedMedia, + addKnownMediaForJellyfinServer, + removePlexSelectedMedia, +} from '@/store/programmingSelector/actions.ts'; +import { + useCurrentMediaSource, + useSelectedMedia, +} from '@/store/programmingSelector/selectors.ts'; +import { ExpandLess, ExpandMore } from '@mui/icons-material'; +import { + Button, + Collapse, + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Skeleton, +} from '@mui/material'; +import { + JellyfinItem, + JellyfinItemKind, + isTerminalJellyfinItem, +} from '@tunarr/types/jellyfin'; +import { isNull, map } from 'lodash-es'; +import React, { MouseEvent, useCallback, useEffect, useState } from 'react'; + +export interface JellyfinListItemProps { + item: JellyfinItem; + style?: React.CSSProperties; + index?: number; + length?: number; + parent?: string; +} + +function jellyfinChildType(item: JellyfinItem): JellyfinItemKind | null { + switch (item.Type) { + case 'Audio': + case 'Episode': + case 'Movie': + return null; + case 'MusicAlbum': + return 'Audio'; + case 'MusicArtist': + return 'MusicAlbum'; + case 'MusicGenre': + return 'MusicAlbum'; + // case 'Playlist': + // case 'PlaylistsFolder': + // case 'Program': + // case 'Recording': + case 'Season': + return 'Episode'; + case 'Series': + return 'Season'; + // case 'Studio': + // case 'Trailer': + // case 'TvChannel': + // case 'TvProgram': + default: + return null; + } +} + +export function JellyfinListItem(props: JellyfinListItemProps) { + const selectedServer = useCurrentMediaSource('jellyfin')!; + const [open, setOpen] = useState(false); + const { item } = props; + const hasChildren = !isTerminalJellyfinItem(item); + const childType = jellyfinChildType(item); + const { isPending, data: children } = useJellyfinLibraryItems( + selectedServer.id, + props.item.Id, + childType ? [childType] : [], + null, + !isNull(childType) && open, + ); + // const selectedServer = useCurrentMediaSource('plex'); + // const selectedMedia = useStore((s) => + // filter(s.selectedMedia, (m): m is PlexSelectedMedia => m.type === 'plex'), + // ); + const selectedMedia = useSelectedMedia('jellyfin'); + const selectedMediaIds = map(selectedMedia, typedProperty('id')); + + const handleClick = () => { + setOpen(!open); + }; + + useEffect(() => { + if (children) { + addKnownMediaForJellyfinServer( + selectedServer.id, + children.Items, + item.Id, + ); + } + }, [item.Id, selectedServer.id, children]); + + const handleItem = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + + if (selectedMediaIds.includes(item.Id)) { + removePlexSelectedMedia(selectedServer.id, [item.Id]); + } else { + addJellyfinSelectedMedia(selectedServer, item); + } + }, + [item, selectedServer, selectedMediaIds], + ); + + const renderChildren = () => { + return isPending ? ( + + ) : ( + + {children?.Items.map((child, idx, arr) => ( + + ))} + + ); + }; + + const getSecondaryText = () => { + // if (isPlexShow(item)) { + // return `${prettyItemDuration(item.duration)} each`; + // } else if (isTerminalItem(item)) { + // return prettyItemDuration(item.duration); + // } else if (isPlexCollection(item)) { + // const childCount = parseInt(item.childCount); + // const count = isNaN(childCount) ? 0 : childCount; + // return `${count} item${count === 0 || count > 1 ? 's' : ''}`; + // } else if (isPlexMusicArtist(item)) { + // return first(item.Genre)?.tag ?? ' '; + // } else if (isPlexMusicAlbum(item)) { + // return item.year ?? ' '; + // } else { + // return ' '; + // } + return ' '; + }; + + return ( + + + {hasChildren && ( + {open ? : } + )} + + + + + {renderChildren()} + + + + ); +} diff --git a/web/src/components/channel_config/JellyfinProgrammingSelector.tsx b/web/src/components/channel_config/JellyfinProgrammingSelector.tsx new file mode 100644 index 00000000..637a8e1b --- /dev/null +++ b/web/src/components/channel_config/JellyfinProgrammingSelector.tsx @@ -0,0 +1,205 @@ +import { useInfiniteJellyfinLibraryItems } from '@/hooks/jellyfin/useJellyfinApi'; +import { + useCurrentMediaSource, + useCurrentSourceLibrary, +} from '@/store/programmingSelector/selectors'; +import React, { useCallback, useMemo, useState } from 'react'; +import { + GridInlineModalProps, + GridItemProps, + MediaItemGrid, +} from './MediaItemGrid.tsx'; +import { + Box, + Stack, + Tab, + Tabs, + ToggleButton, + ToggleButtonGroup, +} from '@mui/material'; +import { JellyfinGridItem } from './JellyfinGridItem.tsx'; +import { tag } from '@tunarr/types'; +import { MediaSourceId } from '@tunarr/types/schemas'; +import { JellyfinItem, JellyfinItemKind } from '@tunarr/types/jellyfin'; +import { InlineModal } from '../InlineModal.tsx'; +import { first } from 'lodash-es'; +import { forJellyfinItem, typedProperty } from '@/helpers/util.ts'; +import { JellyfinListItem } from './JellyfinListItem.tsx'; +import { ViewList, GridView } from '@mui/icons-material'; +import { setProgrammingSelectorViewState } from '@/store/themeEditor/actions.ts'; +import { ProgramSelectorViewType } from '@/types/index.ts'; +import useStore from '@/store/index.ts'; + +enum TabValues { + Library = 0, +} + +// TODO move this somewhere common +function isParentItem(item: JellyfinItem) { + switch (item.Type) { + // These are the currently supported item types + case 'AggregateFolder': + case 'Season': + case 'Series': + case 'CollectionFolder': + case 'MusicAlbum': + case 'MusicArtist': + case 'MusicGenre': + case 'Genre': + case 'Playlist': + case 'PlaylistsFolder': + return true; + default: + return false; + } +} + +const childJellyfinType = forJellyfinItem({ + Season: 'Episode', + Series: 'Season', + default: 'Video', +}); + +export function JellyfinProgrammingSelector() { + const viewType = useStore((state) => state.theme.programmingSelectorView); + const selectedServer = useCurrentMediaSource('jellyfin'); + const selectedLibrary = useCurrentSourceLibrary('jellyfin'); + + const [tabValue, setTabValue] = useState(TabValues.Library); + + const setViewType = (view: ProgramSelectorViewType) => { + setProgrammingSelectorViewState(view); + }; + + const handleFormat = ( + _event: React.MouseEvent, + newFormats: ProgramSelectorViewType, + ) => { + setViewType(newFormats); + }; + + const itemTypes: JellyfinItemKind[] = []; + if (selectedLibrary?.library.CollectionType) { + switch (selectedLibrary.library.CollectionType) { + case 'movies': + itemTypes.push('Movie'); + break; + case 'tvshows': + itemTypes.push('Series'); + break; + case 'music': + itemTypes.push('MusicArtist'); + break; + default: + break; + } + } + + const jellyfinItemsQuery = useInfiniteJellyfinLibraryItems( + selectedServer?.id ?? tag(''), + selectedLibrary?.library.Id ?? '', + itemTypes, + true, + 20, + ); + + const totalItems = useMemo(() => { + return first(jellyfinItemsQuery.data?.pages)?.TotalRecordCount ?? 0; + }, [jellyfinItemsQuery.data]); + + const renderGridItem = ( + gridItemProps: GridItemProps, + modalProps: GridInlineModalProps, + ) => { + const isLast = gridItemProps.index === totalItems - 1; + + const renderModal = + isParentItem(gridItemProps.item) && + ((gridItemProps.index + 1) % modalProps.rowSize === 0 || isLast); + + return ( + + + {renderModal && ( + + )} + + ); + }; + + return ( + <> + + + + + + + + + + + + setTabValue(value)} + aria-label="Jellyfin media selector tabs" + variant="scrollable" + allowScrollButtonsMobile + > + + {/* {!isUndefined(collectionsData) && + sumBy(collectionsData.pages, (page) => page.size) > 0 && ( + + )} + {!isUndefined(playlistData) && + sumBy(playlistData.pages, 'size') > 0 && ( + + )} */} + + + ({ + total: page.TotalRecordCount, + size: page.Items.length, + })} + extractItems={(page) => page.Items} + getItemKey={useCallback((item: JellyfinItem) => item.Id, [])} + renderGridItem={renderGridItem} + renderListItem={(item, index) => ( + + )} + // renderFinalRow={renderFinalRowInlineModal} + infiniteQuery={jellyfinItemsQuery} + /> + + ); +} diff --git a/web/src/components/channel_config/MediaGridItem.tsx b/web/src/components/channel_config/MediaGridItem.tsx new file mode 100644 index 00000000..65fe0b32 --- /dev/null +++ b/web/src/components/channel_config/MediaGridItem.tsx @@ -0,0 +1,249 @@ +import { + addSelectedMedia, + removeSelectedMedia, +} from '@/store/programmingSelector/actions.ts'; +import { CheckCircle, RadioButtonUnchecked } from '@mui/icons-material'; +import { + Box, + Fade, + Unstable_Grid2 as Grid, + IconButton, + ImageListItem, + ImageListItemBar, + Skeleton, + alpha, + useTheme, +} from '@mui/material'; +import { MediaSourceSettings } from '@tunarr/types'; +import { filter, isUndefined, some } from 'lodash-es'; +import React, { + ForwardedRef, + MouseEvent, + forwardRef, + useCallback, + useState, +} from 'react'; +import { useIntersectionObserver } from 'usehooks-ts'; +import useStore from '../../store/index.ts'; +import { + JellyfinSelectedMedia, + PlexSelectedMedia, + SelectedMedia, +} from '../../store/programmingSelector/store.ts'; + +export type GridItemMetadataExtractors = { + id: (item: T) => string; + isPlaylist: (item: T) => boolean; + hasThumbnail: (item: T) => boolean; + childCount: (item: T) => number | null; + isMusicItem: (item: T) => boolean; + isEpisode: (item: T) => boolean; + title: (item: T) => string; + subtitle: (item: T) => JSX.Element | string | null; + thumbnailUrl: (item: T) => string; + selectedMedia: (item: T) => SelectedMedia; +}; + +export type GridItemMetadata = { + itemId: string; + isPlaylist: boolean; + hasThumbnail: boolean; + childCount: number | null; + isMusicItem: boolean; + isEpisode: boolean; + title: string; + subtitle: JSX.Element | string | null; + thumbnailUrl: string; + selectedMedia: SelectedMedia; +}; + +type Props = { + item: T; + itemSource: MediaSourceSettings['type']; + // extractors: GridItemMetadataExtractors; + metadata: GridItemMetadata; + style?: React.CSSProperties; + index: number; + isModalOpen: boolean; + onClick: (item: T) => void; + onSelect: (item: T) => void; +}; + +const MediaGridItemInner = ( + props: Props, + ref: ForwardedRef, +) => { + // const settings = useSettings(); + const theme = useTheme(); + const skeletonBgColor = alpha( + theme.palette.text.primary, + theme.palette.mode === 'light' ? 0.11 : 0.13, + ); + // const server = useCurrentMediaSource('plex')!; // We have to have a server at this point + const darkMode = useStore((state) => state.theme.darkMode); + const { + item, + metadata: { + hasThumbnail, + thumbnailUrl, + itemId, + selectedMedia: selectedMediaItem, + isMusicItem, + isEpisode: isEpisodeItem, + title, + subtitle, + childCount, + }, + style, + isModalOpen, + onClick, + } = props; + const [imageLoaded, setImageLoaded] = useState(false); + + const selectedMedia = useStore((s) => + filter( + s.selectedMedia, + (p): p is PlexSelectedMedia | JellyfinSelectedMedia => + p.type !== 'custom-show', + ), + ); + + const handleClick = useCallback(() => { + // moveModal(index, item); + onClick(item); + }, [item, onClick]); + + const isSelected = some( + selectedMedia, + (sm) => sm.type === props.itemSource && sm.id === itemId, + ); + + const handleItem = useCallback( + (e: MouseEvent) => { + console.log('handle'); + e.stopPropagation(); + if (isSelected) { + removeSelectedMedia([selectedMediaItem]); + } else { + addSelectedMedia(selectedMediaItem); + } + }, + [isSelected, selectedMediaItem], + ); + + const { isIntersecting: isInViewport, ref: imageContainerRef } = + useIntersectionObserver({ + threshold: 0, + rootMargin: '0px', + freezeOnceVisible: true, + }); + + return ( + +
    + + isModalOpen + ? darkMode + ? theme.palette.grey[800] + : theme.palette.grey[400] + : 'transparent', + ...style, + }} + onClick={(e) => + (childCount ?? 0) === 0 ? handleItem(e) : handleClick() + } + ref={ref} + > + {isInViewport && // TODO: Eventually turn this into isNearViewport so images load before they hit the viewport + (hasThumbnail ? ( + + setImageLoaded(true)} + onError={() => setImageLoaded(true)} + /> + + + ) : ( + + ))} + ) => + handleItem(event) + } + > + {isSelected ? : } + + } + actionPosition="right" + /> + +
    +
    + ); +}; +// ); + +export const MediaGridItem = forwardRef(MediaGridItemInner) as ( + props: Props & { ref?: ForwardedRef }, +) => ReturnType; diff --git a/web/src/components/channel_config/MediaItemGrid.tsx b/web/src/components/channel_config/MediaItemGrid.tsx new file mode 100644 index 00000000..fcc00b41 --- /dev/null +++ b/web/src/components/channel_config/MediaItemGrid.tsx @@ -0,0 +1,296 @@ +import { + findLastItemInRowIndex, + getImagesPerRow, + isNewModalAbove, +} from '@/helpers/inlineModalUtil.ts'; +import useStore from '@/store/index.ts'; +import { + Box, + CircularProgress, + Divider, + Grid, + Typography, +} from '@mui/material'; +import { InfiniteData, UseInfiniteQueryResult } from '@tanstack/react-query'; +import { usePrevious } from '@uidotdev/usehooks'; +import { compact, flatMap, map, sumBy } from 'lodash-es'; +import { + ForwardedRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + useDebounceCallback, + useIntersectionObserver, + useResizeObserver, +} from 'usehooks-ts'; +import { Nullable } from '@/types/util'; + +export interface GridItemProps { + item: ItemType; + index: number; + isModalOpen: boolean; + moveModal: (index: number, item: ItemType) => void; + ref: ForwardedRef; +} + +export interface GridInlineModalProps { + open: boolean; + modalItemGuid: Nullable; + modalIndex: number; + rowSize: number; + renderChildren: ( + gridItemProps: GridItemProps, + modalProps: GridInlineModalProps, + ) => JSX.Element; +} + +type Props = { + getPageDataSize: (page: PageDataType) => { total?: number; size: number }; + extractItems: (page: PageDataType) => ItemType[]; + renderGridItem: ( + gridItemProps: GridItemProps, + modalProps: GridInlineModalProps, + ) => JSX.Element; + renderListItem: (item: ItemType, index: number) => JSX.Element; + getItemKey: (item: ItemType) => string; + infiniteQuery: UseInfiniteQueryResult>; +}; + +type Size = { + width?: number; + height?: number; +}; + +type ModalState = { + modalIndex: number; + modalGuid: Nullable; +}; + +// magic number for top bar padding; TODO: calc it off ref +const TopBarPadddingPx = 64; + +export function MediaItemGrid({ + getPageDataSize, + renderGridItem, + renderListItem, + getItemKey, + extractItems, + infiniteQuery: { + data, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + isLoading, + }, +}: Props) { + const viewType = useStore((state) => state.theme.programmingSelectorView); + const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 }); + const [rowSize, setRowSize] = useState(9); + const [{ modalIndex, modalGuid }, setModalState] = useState({ + modalGuid: null, + modalIndex: -1, + }); + const gridContainerRef = useRef(null); + const selectedModalItemRef = useRef(null); + + // We only need a single grid item ref because all grid items are the same + // width. This ref is simply used to determine the width of grid items based + // on window/container size in order to derive other details about the grid. + // If the modal is open, this ref points to the selected dmain grid element, + // otherwise, it uses the first element in the grid + const gridItemRef = useRef(null); + + const previousModalIndex = usePrevious(modalIndex); + + const [{ width }, setSize] = useState({ + width: undefined, + height: undefined, + }); + + const onResize = useDebounceCallback(setSize, 200); + + useResizeObserver({ + ref: gridContainerRef, + onResize, + }); + + useEffect(() => { + if (viewType === 'grid') { + // 16 is additional padding available in the parent container + const rowSize = getImagesPerRow( + width ? width + 16 : 0, + gridItemRef.current?.getBoundingClientRect().width ?? 0, + ); + setRowSize(rowSize); + setScrollParams(({ max }) => ({ max, limit: rowSize * 4 })); + } + }, [width, viewType, modalGuid, gridItemRef]); + + const scrollToGridItem = useCallback( + (index: number) => { + if (index === -1) { + return; + } + + const selectedElement = selectedModalItemRef.current; + const includeModalInHeightCalc = isNewModalAbove( + previousModalIndex, + index, + rowSize, + ); + + if (selectedElement) { + // New modal is opening in a row above previous modal + const modalMovesUp = selectedElement.offsetTop - TopBarPadddingPx; + // New modal is opening in the same row or a row below the current modal + const modalMovesDown = + selectedElement.offsetTop - + selectedElement.offsetHeight - + TopBarPadddingPx; + + window.scrollTo({ + top: includeModalInHeightCalc ? modalMovesDown : modalMovesUp, + behavior: 'smooth', + }); + } + }, + [previousModalIndex, rowSize], + ); + + // Scroll to new selected item when modalIndex changes + // Doing this on modalIndex change negates the need to calc inline modal height since it's collapsed at this time + useEffect(() => { + scrollToGridItem(modalIndex); + }, [modalIndex, scrollToGridItem]); + + const handleMoveModal = useCallback( + (index: number, item: ItemType) => { + const key = getItemKey(item); + setModalState((prev) => { + if (prev.modalIndex === index) { + return { modalGuid: null, modalIndex: -1 }; + } else { + return { modalGuid: key, modalIndex: index }; + } + }); + }, + [getItemKey], + ); + + useEffect(() => { + if (data?.pages.length === 1) { + const { total, size } = getPageDataSize(data.pages[0]); + const actualSize = total ?? size; + if (scrollParams.max !== actualSize) { + setScrollParams(({ limit }) => ({ + limit, + max: actualSize, + })); + } + } + }, [data?.pages, getPageDataSize, scrollParams.max]); + + const { ref } = useIntersectionObserver({ + onChange: (_, entry) => { + if (entry.isIntersecting) { + if (scrollParams.limit < scrollParams.max) { + setScrollParams(({ limit: prevLimit, max }) => ({ + max, + limit: prevLimit + rowSize * 4, + })); + } + + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage().catch(console.error); + } + } + }, + threshold: 0.5, + }); + + // InlineModals are only potentially rendered for the last item in each row + // As such, we need to find the index of the last item in a given row, relative + // to the index of the selected item, row size, and total items. + const lastItemIndex = useMemo( + () => + findLastItemInRowIndex( + modalIndex, + rowSize, + sumBy(data?.pages, (page) => getPageDataSize(page).size) ?? 0, + ), + [modalIndex, rowSize, data?.pages, getPageDataSize], + ); + + const renderItems = () => { + return map(compact(flatMap(data?.pages, extractItems)), (item, index) => { + const isOpen = index === lastItemIndex; + // const shouldAttachRef = + // (modalIndex >= 0 && modalIndex === index) || index === 0; + + return viewType === 'list' + ? renderListItem(item, index) + : renderGridItem( + { + item, + index, + isModalOpen: modalIndex === index, + moveModal: handleMoveModal, + ref: + index === 0 + ? gridItemRef + : modalIndex === index + ? selectedModalItemRef + : null, + }, + { + open: isOpen, + modalItemGuid: modalGuid, + modalIndex: modalIndex, + rowSize: rowSize, + renderChildren: renderGridItem, + }, + ); + }); + }; + + return ( + <> + + + {renderItems()} + + + {!isLoading &&
    } + {isFetchingNextPage && ( + + )} + {data && !hasNextPage && ( + + fin. + + )} + + + ); +} diff --git a/web/src/components/channel_config/PlexDirectoryListItem.tsx b/web/src/components/channel_config/PlexDirectoryListItem.tsx index 9e790f3f..45003eca 100644 --- a/web/src/components/channel_config/PlexDirectoryListItem.tsx +++ b/web/src/components/channel_config/PlexDirectoryListItem.tsx @@ -23,7 +23,7 @@ import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react'; import { usePlexTyped2 } from '../../hooks/plex/usePlex.ts'; import useStore from '../../store/index.ts'; import { - addKnownMediaForServer, + addKnownMediaForPlexServer, addPlexSelectedMedia, } from '../../store/programmingSelector/actions.ts'; import { PlexListItem } from './PlexListItem.tsx'; @@ -43,20 +43,20 @@ export function PlexDirectoryListItem(props: { PlexLibraryCollections >([ { - serverName: props.server.name, + serverId: props.server.id, path: `/library/sections/${item.key}/all`, enabled: open, }, { - serverName: props.server.name, + serverId: props.server.id, path: `/library/sections/${item.key}/collections`, enabled: open, }, ]); - const listings = useStore((s) => s.knownMediaByServer[server.name]); + const listings = useStore((s) => s.knownMediaByServer[server.id]); const hierarchy = useStore( - (s) => s.contentHierarchyByServer[server.name][item.uuid], + (s) => s.contentHierarchyByServer[server.id][item.uuid], ); const observerTarget = useRef(null); @@ -86,13 +86,13 @@ export function PlexDirectoryListItem(props: { useEffect(() => { if (children && children.Metadata) { - addKnownMediaForServer(server.name, children.Metadata, item.uuid); + addKnownMediaForPlexServer(server.id, children.Metadata, item.uuid); } if (collections && collections.Metadata) { - addKnownMediaForServer(server.name, collections.Metadata, item.uuid); + addKnownMediaForPlexServer(server.id, collections.Metadata, item.uuid); } - }, [item.uuid, item.key, server.name, children, collections]); + }, [item.uuid, item.key, server.id, children, collections]); const handleClick = () => { setLimit(Math.min(hierarchy.length, 20)); @@ -102,17 +102,17 @@ export function PlexDirectoryListItem(props: { const addItems = useCallback( (e: MouseEvent) => { e.stopPropagation(); - addPlexSelectedMedia(server.name, [item]); + addPlexSelectedMedia(server, [item]); }, - [item, server.name], + [item, server], ); const renderCollectionRow = (id: string) => { - const media = listings[id]; + const { type, item } = listings[id]; - if (isPlexMedia(media)) { + if (type === 'plex' && isPlexMedia(item)) { return ( - + ); } }; diff --git a/web/src/components/channel_config/PlexGridItem.tsx b/web/src/components/channel_config/PlexGridItem.tsx index 26f02eb5..0b95e881 100644 --- a/web/src/components/channel_config/PlexGridItem.tsx +++ b/web/src/components/channel_config/PlexGridItem.tsx @@ -1,59 +1,41 @@ -import { useSettings } from '@/store/settings/selectors.ts'; -import { CheckCircle, RadioButtonUnchecked } from '@mui/icons-material'; -import { - Box, - Fade, - Unstable_Grid2 as Grid, - IconButton, - ImageListItem, - ImageListItemBar, - Skeleton, - alpha, - useTheme, -} from '@mui/material'; -import { createExternalId } from '@tunarr/shared'; import { PlexChildMediaApiType, PlexMedia, isPlexPlaylist, isTerminalItem, } from '@tunarr/types/plex'; -import { filter, isNaN, isNil, isUndefined } from 'lodash-es'; +import { isEmpty, isEqual, isNil, isUndefined } from 'lodash-es'; import pluralize from 'pluralize'; -import React, { +import { ForwardedRef, - MouseEvent, forwardRef, + memo, useCallback, useEffect, + useMemo, useState, } from 'react'; -import { useIntersectionObserver } from 'usehooks-ts'; import { forPlexMedia, isNonEmptyString, prettyItemDuration, toggle, } from '../../helpers/util.ts'; -import { usePlexTyped } from '../../hooks/plex/usePlex.ts'; -import useStore from '../../store/index.ts'; -import { - addKnownMediaForServer, - addPlexSelectedMedia, - removePlexSelectedMedia, -} from '../../store/programmingSelector/actions.ts'; -import { PlexSelectedMedia } from '../../store/programmingSelector/store.ts'; -export interface PlexGridItemProps { - item: T; - style?: React.CSSProperties; - index?: number; - parent?: string; - moveModal?: (index: number, item: T) => void; - modalIndex?: number; - onClick?: () => void; - ref?: React.RefObject; -} +import { usePlexTyped } from '@/hooks/plex/usePlex.ts'; +import { + addKnownMediaForPlexServer, + addPlexSelectedMedia, +} from '@/store/programmingSelector/actions.ts'; +import { useCurrentMediaSource } from '@/store/programmingSelector/selectors.ts'; +import { SelectedMedia } from '@/store/programmingSelector/store.ts'; +import { useSettings } from '@/store/settings/selectors.ts'; +import { createExternalId } from '@tunarr/shared'; +import { MediaGridItem } from './MediaGridItem.tsx'; +import { GridItemProps } from './MediaItemGrid.tsx'; + +export interface PlexGridItemProps + extends GridItemProps {} const genPlexChildPath = forPlexMedia({ collection: (collection) => @@ -67,6 +49,7 @@ const extractChildCount = forPlexMedia({ show: (s) => s.childCount, collection: (s) => parseInt(s.childCount), playlist: (s) => s.leafCount, + default: 0, }); const childItemType = forPlexMedia({ @@ -95,213 +78,131 @@ const subtitle = forPlexMedia({ }, }); -export const PlexGridItem = forwardRef( - ( - props: PlexGridItemProps, - ref: ForwardedRef, - ) => { - const settings = useSettings(); - const theme = useTheme(); - const skeletonBgColor = alpha( - theme.palette.text.primary, - theme.palette.mode === 'light' ? 0.11 : 0.13, - ); - const server = useStore((s) => s.currentServer!); // We have to have a server at this point - const darkMode = useStore((state) => state.theme.darkMode); - const [open, setOpen] = useState(false); - const { item, index, style, moveModal } = props; - const hasThumb = isNonEmptyString( - isPlexPlaylist(props.item) ? props.item.composite : props.item.thumb, - ); - const [imageLoaded, setImageLoaded] = useState(!hasThumb); - const hasChildren = !isTerminalItem(item); - const { data: children } = usePlexTyped>( - server.name, - genPlexChildPath(props.item), - hasChildren && open, - ); - const selectedServer = useStore((s) => s.currentServer); - const selectedMedia = useStore((s) => - filter(s.selectedMedia, (p): p is PlexSelectedMedia => p.type === 'plex'), - ); - const selectedMediaIds = selectedMedia.map((item) => item.guid); +export const PlexGridItem = memo( + forwardRef( + ( + props: PlexGridItemProps, + ref: ForwardedRef, + ) => { + const { item, index, moveModal } = props; + const server = useCurrentMediaSource('plex')!; // We have to have a server at this point + const settings = useSettings(); + const [modalOpen, setModalOpen] = useState(false); + const currentServer = useCurrentMediaSource('plex'); - const handleClick = () => { - setOpen(toggle); + const isMusicItem = useCallback( + (item: PlexMedia) => + ['MusicArtist', 'MusicAlbum', 'Audio'].includes(item.type), + [], + ); - if (!isUndefined(index) && !isUndefined(moveModal)) { - moveModal(index, item); - } - }; + const isEpisode = useCallback( + (item: PlexMedia) => item.type === 'episode', + [], + ); - useEffect(() => { - if (!isUndefined(children?.Metadata)) { - addKnownMediaForServer(server.name, children.Metadata, item.guid); - } - }, [item.guid, server.name, children]); + const onSelect = useCallback( + (item: PlexMedia) => { + addPlexSelectedMedia(server, [item]); + }, + [server], + ); - const handleItem = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); + const { data: childItems } = usePlexTyped>( + server.id, + genPlexChildPath(props.item), + !isTerminalItem(item) && modalOpen, + ); - if (selectedMediaIds.includes(item.guid)) { - removePlexSelectedMedia(selectedServer!.name, [item.guid]); - } else { - addPlexSelectedMedia(selectedServer!.name, [item]); + useEffect(() => { + if ( + !isUndefined(childItems) && + !isEmpty(childItems.Metadata) && + isNonEmptyString(currentServer?.id) + ) { + addKnownMediaForPlexServer( + currentServer.id, + childItems.Metadata, + item.guid, + ); } - }, - [item, selectedServer, selectedMediaIds], - ); + }, [childItems, currentServer?.id, item.guid]); - const { isIntersecting: isInViewport, ref: imageContainerRef } = - useIntersectionObserver({ - threshold: 0, - rootMargin: '0px', - freezeOnceVisible: true, - }); + const moveModalToItem = useCallback(() => { + moveModal(index, item); + }, [index, item, moveModal]); - const extractChildCount = forPlexMedia({ - season: (s) => s.leafCount, - show: (s) => s.childCount, - collection: (s) => parseInt(s.childCount), - }); + const handleItemClick = useCallback(() => { + setModalOpen(toggle); + moveModalToItem(); + }, [moveModalToItem]); - let childCount = isUndefined(item) ? null : extractChildCount(item); - if (isNaN(childCount)) { - childCount = null; - } + const thumbnailUrlFunc = useCallback( + (item: PlexMedia) => { + if (isPlexPlaylist(item)) { + return `${server.uri}${item.composite}?X-Plex-Token=${server.accessToken}`; + } else { + const query = new URLSearchParams({ + mode: 'proxy', + asset: 'thumb', + id: createExternalId('plex', server.name, item.ratingKey), + // Commenting this out for now as temporary solution for image loading issue + // thumbOptions: JSON.stringify({ width: 480, height: 720 }), + }); - const isMusicItem = ['artist', 'album', 'track', 'playlist'].includes( - item.type, - ); + return `${ + settings.backendUri + }/api/metadata/external?${query.toString()}`; + } + }, + [server.accessToken, server.name, server.uri, settings.backendUri], + ); - const isEpisodeItem = ['episode'].includes(item.type); + const selectedMediaFunc = useCallback( + (item: PlexMedia): SelectedMedia => { + return { + type: 'plex', + serverId: currentServer!.id, + serverName: currentServer!.name, + childCount: extractChildCount(item) ?? 0, + id: item.guid, + }; + }, + [currentServer], + ); - let thumbSrc: string; - if (isPlexPlaylist(item)) { - thumbSrc = `${server.uri}${item.composite}?X-Plex-Token=${server.accessToken}`; - } else { - const query = new URLSearchParams({ - mode: 'proxy', - asset: 'thumb', - id: createExternalId('plex', server.name, item.ratingKey), - // Commenting this out for now as temporary solution for image loading issue - // thumbOptions: JSON.stringify({ width: 480, height: 720 }), - }); + const metadata = useMemo( + () => ({ + itemId: item.guid, + hasThumbnail: isNonEmptyString( + isPlexPlaylist(item) ? item.composite : item.thumb, + ), + childCount: extractChildCount(item), + title: item.title, + subtitle: subtitle(item), + thumbnailUrl: thumbnailUrlFunc(item), + selectedMedia: selectedMediaFunc(item), + isMusicItem: isMusicItem(item), + isEpisode: isEpisode(item), + isPlaylist: isPlexPlaylist(item), + }), + [isEpisode, isMusicItem, item, selectedMediaFunc, thumbnailUrlFunc], + ); - thumbSrc = `${ - settings.backendUri - }/api/metadata/external?${query.toString()}`; - } - - return ( - -
    - - props.modalIndex === props.index - ? darkMode - ? theme.palette.grey[800] - : theme.palette.grey[400] - : 'transparent', - ...style, - }} - onClick={ - hasChildren - ? handleClick - : (event: MouseEvent) => handleItem(event) - } + return ( + currentServer && ( + - {isInViewport && // TODO: Eventually turn this into isNearViewport so images load before they hit the viewport - (hasThumb ? ( - - setImageLoaded(true)} - onError={() => setImageLoaded(true)} - /> - - - ) : ( - - ))} - ) => - handleItem(event) - } - > - {selectedMediaIds.includes(item.guid) ? ( - - ) : ( - - )} - - } - actionPosition="right" - /> - -
    -
    - ); - }, + metadata={metadata} + onClick={handleItemClick} + onSelect={onSelect} + /> + ) + ); + }, + ), + isEqual, ); diff --git a/web/src/components/channel_config/PlexListItem.tsx b/web/src/components/channel_config/PlexListItem.tsx index 030173ca..84699ded 100644 --- a/web/src/components/channel_config/PlexListItem.tsx +++ b/web/src/components/channel_config/PlexListItem.tsx @@ -28,11 +28,12 @@ import { import { usePlexTyped } from '../../hooks/plex/usePlex.ts'; import useStore from '../../store/index.ts'; import { - addKnownMediaForServer, + addKnownMediaForPlexServer, addPlexSelectedMedia, removePlexSelectedMedia, } from '../../store/programmingSelector/actions.ts'; import { PlexSelectedMedia } from '../../store/programmingSelector/store.ts'; +import { useCurrentMediaSource } from '@/store/programmingSelector/selectors.ts'; export interface PlexListItemProps { item: T; @@ -61,15 +62,15 @@ export function PlexListItem(props: PlexListItemProps) { const hasChildren = !isTerminalItem(item); const childPath = isPlexCollection(item) ? 'collections' : 'metadata'; const { isPending, data: children } = usePlexTyped>( - server.name, + server.id, `/library/${childPath}/${props.item.ratingKey}/children`, hasChildren && open, ); - const selectedServer = useStore((s) => s.currentServer); + const selectedServer = useCurrentMediaSource('plex'); const selectedMedia = useStore((s) => filter(s.selectedMedia, (m): m is PlexSelectedMedia => m.type === 'plex'), ); - const selectedMediaIds = map(selectedMedia, typedProperty('guid')); + const selectedMediaIds = map(selectedMedia, typedProperty('id')); const handleClick = () => { setOpen(!open); @@ -77,18 +78,18 @@ export function PlexListItem(props: PlexListItemProps) { useEffect(() => { if (children) { - addKnownMediaForServer(server.name, children.Metadata, item.guid); + addKnownMediaForPlexServer(server.id, children.Metadata, item.guid); } - }, [item.guid, server.name, children]); + }, [item.guid, server.id, children]); const handleItem = useCallback( (e: MouseEvent) => { e.stopPropagation(); if (selectedMediaIds.includes(item.guid)) { - removePlexSelectedMedia(selectedServer!.name, [item.guid]); + removePlexSelectedMedia(selectedServer!.id, [item.guid]); } else { - addPlexSelectedMedia(selectedServer!.name, [item]); + addPlexSelectedMedia(selectedServer!, [item]); } }, [item, selectedServer, selectedMediaIds], diff --git a/web/src/components/channel_config/PlexProgrammingSelector.tsx b/web/src/components/channel_config/PlexProgrammingSelector.tsx index 4ebef609..e5a0b411 100644 --- a/web/src/components/channel_config/PlexProgrammingSelector.tsx +++ b/web/src/components/channel_config/PlexProgrammingSelector.tsx @@ -1,14 +1,16 @@ import { usePlexCollectionsInfinite } from '@/hooks/plex/usePlexCollections.ts'; import { usePlexPlaylistsInfinite } from '@/hooks/plex/usePlexPlaylists.ts'; import { usePlexSearchInfinite } from '@/hooks/plex/usePlexSearch.ts'; +import { + useCurrentMediaSource, + useCurrentSourceLibrary, +} from '@/store/programmingSelector/selectors.ts'; import FilterAlt from '@mui/icons-material/FilterAlt'; import GridView from '@mui/icons-material/GridView'; import ViewList from '@mui/icons-material/ViewList'; import { Box, - CircularProgress, Collapse, - Divider, Grow, LinearProgress, Stack, @@ -16,59 +18,32 @@ import { Tabs, ToggleButton, ToggleButtonGroup, - Typography, } from '@mui/material'; -import { - PlexMedia, - PlexMovie, - PlexMusicArtist, - PlexTvShow, - isPlexParentItem, -} from '@tunarr/types/plex'; -import { usePrevious } from '@uidotdev/usehooks'; -import _, { - chain, - compact, - first, - flatMap, - isNil, - isUndefined, - map, - sumBy, -} from 'lodash-es'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { - useDebounceCallback, - useIntersectionObserver, - useResizeObserver, -} from 'usehooks-ts'; -import { - extractLastIndexes, - findFirstItemInNextRowIndex, - getImagesPerRow, - isNewModalAbove, -} from '../../helpers/inlineModalUtil'; -import { isNonEmptyString, toggle } from '../../helpers/util'; -import { usePlex } from '../../hooks/plex/usePlex.ts'; -import { usePlexServerSettings } from '../../hooks/settingsHooks'; -import useStore from '../../store'; -import { addKnownMediaForServer } from '../../store/programmingSelector/actions'; -import { setProgrammingSelectorViewState } from '../../store/themeEditor/actions'; -import { ProgramSelectorViewType } from '../../types'; -import { InlineModal } from '../InlineModal'; -import CustomTabPanel from '../TabPanel'; +import { PlexMedia, isPlexParentItem } from '@tunarr/types/plex'; +import { chain, filter, first, isNil, isUndefined, sumBy } from 'lodash-es'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { isNonEmptyString, toggle } from '../../helpers/util.ts'; +import { usePlexLibraries } from '../../hooks/plex/usePlex.ts'; +import { useMediaSources } from '../../hooks/settingsHooks.ts'; +import useStore from '../../store/index.ts'; +import { addKnownMediaForPlexServer } from '../../store/programmingSelector/actions.ts'; +import { setProgrammingSelectorViewState } from '../../store/themeEditor/actions.ts'; +import { ProgramSelectorViewType } from '../../types/index.ts'; +import { InlineModal } from '../InlineModal.tsx'; +import { TabPanel } from '../TabPanel.tsx'; import StandaloneToggleButton from '../base/StandaloneToggleButton.tsx'; -import ConnectPlex from '../settings/ConnectPlex'; +import ConnectPlex from '../settings/ConnectPlex.tsx'; +import { + GridInlineModalProps, + GridItemProps, + MediaItemGrid, +} from './MediaItemGrid.tsx'; import { PlexFilterBuilder } from './PlexFilterBuilder.tsx'; -import { PlexGridItem } from './PlexGridItem'; -import { PlexListItem } from './PlexListItem'; +import { PlexGridItem } from './PlexGridItem.tsx'; import { PlexSortField } from './PlexSortField.tsx'; +import { PlexListItem } from './PlexListItem.tsx'; +import { tag } from '@tunarr/types'; +import { MediaSourceId } from '@tunarr/types/schemas'; function a11yProps(index: number) { return { @@ -77,15 +52,6 @@ function a11yProps(index: number) { }; } -type RefMap = { - [k: string]: HTMLDivElement | null; -}; - -type Size = { - width?: number; - height?: number; -}; - enum TabValues { Library = 0, Collections = 1, @@ -93,125 +59,23 @@ enum TabValues { } export default function PlexProgrammingSelector() { - const { data: plexServers } = usePlexServerSettings(); - const selectedServer = useStore((s) => s.currentServer); - const selectedLibrary = useStore((s) => - s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, - ); + const { data: mediaSources } = useMediaSources(); + const plexServers = filter(mediaSources, { type: 'plex' }); + const selectedServer = useCurrentMediaSource('plex'); + const selectedLibrary = useCurrentSourceLibrary('plex'); const viewType = useStore((state) => state.theme.programmingSelectorView); const [tabValue, setTabValue] = useState(TabValues.Library); - const [rowSize, setRowSize] = useState(9); - const [modalIndex, setModalIndex] = useState(-1); - const [modalGuid, setModalGuid] = useState(''); const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 }); const [searchVisible, setSearchVisible] = useState(false); const [useAdvancedSearch, setUseAdvancedSearch] = useState(false); - const gridContainerRef = useRef(null); - const gridImageRefs = useRef({}); - const previousModalIndex = usePrevious(modalIndex); - - const [{ width }, setSize] = useState({ - width: undefined, - height: undefined, - }); - - const onResize = useDebounceCallback(setSize, 200); - - useResizeObserver({ - ref: gridContainerRef, - onResize, - }); - - useEffect(() => { - if (viewType === 'grid') { - let imageRef: HTMLDivElement | null = null; - - if (modalGuid === '') { - // Grab the first non-null ref for an image - for (const key in gridImageRefs.current) { - if (gridImageRefs.current[key] !== null) { - imageRef = gridImageRefs.current[key]; - break; - } - } - } else { - imageRef = _.get(gridImageRefs.current, modalGuid); - } - - const imageWidth = imageRef?.getBoundingClientRect().width; - - // 16 is additional padding available in the parent container - const rowSize = getImagesPerRow(width ? width + 16 : 0, imageWidth ?? 0); - setRowSize(rowSize); - setScrollParams(({ max }) => ({ max, limit: rowSize * 4 })); - } - }, [width, tabValue, viewType, modalGuid]); - - useEffect(() => { - setTabValue(0); - }, [selectedLibrary]); - - useEffect(() => { - setModalIndex(-1); - setModalGuid(''); - }, [tabValue, selectedLibrary]); const handleChange = (_: React.SyntheticEvent, newValue: TabValues) => { setTabValue(newValue); }; - const scrollToGridItem = useCallback( - (guid: string, index: number) => { - const selectedElement = gridImageRefs.current[guid]; - const includeModalInHeightCalc = isNewModalAbove( - previousModalIndex, - index, - rowSize, - ); - - if (selectedElement) { - // magic number for top bar padding; to do: calc it off ref - const topBarPadding = 64; - // New modal is opening in a row above previous modal - const modalMovesUp = selectedElement.offsetTop - topBarPadding; - // New modal is opening in the same row or a row below the current modal - const modalMovesDown = - selectedElement.offsetTop - - selectedElement.offsetHeight - - topBarPadding; - - window.scrollTo({ - top: includeModalInHeightCalc ? modalMovesDown : modalMovesUp, - behavior: 'smooth', - }); - } - }, - [previousModalIndex, rowSize], - ); - - // Scroll to new selected item when modalIndex changes - // Doing this on modalIndex change negates the need to calc inline modal height since it's collapsed at this time - useEffect(() => { - scrollToGridItem(modalGuid, modalIndex); - }, [modalGuid, modalIndex, scrollToGridItem]); - - const handleMoveModal = useCallback( - (index: number, item: PlexMedia) => { - if (index === modalIndex) { - setModalIndex(-1); - setModalGuid(''); - } else { - setModalIndex(index); - setModalGuid(item.guid); - } - }, - [modalIndex], - ); - - const { data: directoryChildren } = usePlex( - selectedServer?.name ?? '', - '/library/sections', - !isUndefined(selectedServer), + const { data: directoryChildren } = usePlexLibraries( + selectedServer?.id ?? tag(''), + selectedServer?.type === 'plex', ); const setViewType = (view: ProgramSelectorViewType) => { @@ -225,27 +89,24 @@ export default function PlexProgrammingSelector() { setViewType(newFormats); }; - const { - isLoading: isCollectionLoading, - data: collectionsData, - fetchNextPage: fetchNextCollectionsPage, - isFetchingNextPage: isFetchingNextCollectionsPage, - hasNextPage: hasNextCollectionsPage, - } = usePlexCollectionsInfinite(selectedServer, selectedLibrary, rowSize * 4); - - const { - isLoading: isPlaylistLoading, - data: playlistData, - fetchNextPage: fetchNextPlaylistPage, - isFetchingNextPage: isFetchingNextPlaylistPage, - hasNextPage: hasNextPlaylistPage, - } = usePlexPlaylistsInfinite( + const plexCollectionsQuery = usePlexCollectionsInfinite( selectedServer, selectedLibrary, - rowSize * 4, - // selectedLibrary?.library.type === 'artist', + 24, ); + const { isLoading: isCollectionLoading, data: collectionsData } = + plexCollectionsQuery; + + const plexPlaylistsQuery = usePlexPlaylistsInfinite( + selectedServer, + selectedLibrary, + 24, + ); + + const { isLoading: isPlaylistLoading, data: playlistData } = + plexPlaylistsQuery; + useEffect(() => { // When switching between Libraries, if a collection doesn't exist switch back to 'Library' tab if ( @@ -274,19 +135,15 @@ export default function PlexProgrammingSelector() { ({ plexSearch: plexQuery }) => plexQuery, ); - const { - isLoading: searchLoading, - data: searchData, - fetchNextPage: fetchNextItemsPage, - hasNextPage: hasNextItemsPage, - isFetchingNextPage: isFetchingNextItemsPage, - } = usePlexSearchInfinite( + const plexSearchQuery = usePlexSearchInfinite( selectedServer, selectedLibrary, searchKey, - rowSize * 4, + 24, ); + const { isLoading: searchLoading, data: searchData } = plexSearchQuery; + useEffect(() => { if (searchData?.pages.length === 1) { const size = searchData.pages[0].totalSize ?? searchData.pages[0].size; @@ -309,235 +166,141 @@ export default function PlexProgrammingSelector() { .map((page) => page.Metadata) .flatten() .value(); - addKnownMediaForServer(selectedServer.name, allMedia); + addKnownMediaForPlexServer(selectedServer.id, allMedia); } } - }, [scrollParams, selectedServer, searchData, rowSize]); + }, [scrollParams, selectedServer, searchData]); useEffect(() => { - if ( - isNonEmptyString(selectedServer?.name) && - !isUndefined(collectionsData) - ) { + if (isNonEmptyString(selectedServer?.id) && !isUndefined(collectionsData)) { const allCollections = chain(collectionsData.pages) .reject((page) => page.size === 0) .map((page) => page.Metadata) .compact() .flatten() .value(); - addKnownMediaForServer(selectedServer.name, allCollections); + addKnownMediaForPlexServer(selectedServer.id, allCollections); } - }, [selectedServer?.name, collectionsData]); + }, [selectedServer?.id, collectionsData]); - const { ref } = useIntersectionObserver({ - onChange: (_, entry) => { - if (entry.isIntersecting) { - if (scrollParams.limit < scrollParams.max) { - setScrollParams(({ limit: prevLimit, max }) => ({ - max, - limit: prevLimit + rowSize * 4, - })); - } - - if ( - tabValue === TabValues.Library && - hasNextItemsPage && - !isFetchingNextItemsPage - ) { - fetchNextItemsPage().catch(console.error); - } - - if ( - tabValue === TabValues.Collections && - hasNextCollectionsPage && - !isFetchingNextCollectionsPage - ) { - fetchNextCollectionsPage().catch(console.error); - } - - if ( - tabValue === TabValues.Playlists && - hasNextPlaylistPage && - !isFetchingNextPlaylistPage - ) { - fetchNextPlaylistPage().catch(console.error); - } - } - }, - threshold: 0.5, - }); - - const firstItemInNextLibraryRowIndex = useMemo( - () => - findFirstItemInNextRowIndex( - modalIndex, - rowSize, - sumBy(searchData?.pages, (p) => p.size) ?? 0, - ), - [searchData, rowSize, modalIndex], - ); - - const firstItemInNextCollectionRowIndex = useMemo( - () => - findFirstItemInNextRowIndex( - modalIndex, - rowSize, - sumBy(collectionsData?.pages, (p) => p.size) ?? 0, - ), - [collectionsData, rowSize, modalIndex], - ); - - const firstItemInNextPlaylistRowIndex = useMemo( - () => - findFirstItemInNextRowIndex( - modalIndex, - rowSize, - sumBy(playlistData?.pages, (p) => p.size) ?? 0, - ), - [playlistData, rowSize, modalIndex], - ); - - const renderGridItems = (item: PlexMedia, index: number) => { - let firstItemIndex: number; + const totalItems = useMemo(() => { switch (tabValue) { case TabValues.Library: - firstItemIndex = firstItemInNextLibraryRowIndex; - break; + return first(plexSearchQuery.data?.pages)?.totalSize ?? 0; case TabValues.Collections: - firstItemIndex = firstItemInNextCollectionRowIndex; - break; + return first(collectionsData?.pages)?.totalSize ?? 0; case TabValues.Playlists: - firstItemIndex = firstItemInNextPlaylistRowIndex; - break; + return first(playlistData?.pages)?.totalSize ?? 0; } + }, [ + collectionsData?.pages, + playlistData?.pages, + plexSearchQuery.data?.pages, + tabValue, + ]); - const isOpen = index === firstItemIndex; + const getPlexItemKey = useCallback((item: PlexMedia) => item.guid, []); + + const renderGridItem = ( + gridItemProps: GridItemProps, + modalProps: GridInlineModalProps, + ) => { + const isLast = gridItemProps.index === totalItems - 1; + + const renderModal = + isPlexParentItem(gridItemProps.item) && + ((gridItemProps.index + 1) % modalProps.rowSize === 0 || isLast); return ( - - {isPlexParentItem(item) && - (item.type === 'playlist' ? (item.leafCount ?? 0) < 500 : true) && ( - - )} - {/* TODO: Consider forking this to a separate component for non-parent items, because - currently it erroneously creates a lot of tracked queries in react-query that will never be enabled */} - (gridImageRefs.current[item.guid] = element)} - /> + + + {renderModal && ( + item.guid} + sourceType="plex" + getItemType={(item) => item.type} + getChildItemType={() => 'season'} + /> + )} ); }; - const renderFinalRowInlineModal = (arr: PlexMedia[]) => { - // /This Modal is for last row items because they can't be inserted using the above inline modal - // Check how many items are in the last row - const remainingItems = - arr.length % rowSize === 0 ? rowSize : arr.length % rowSize; - - const open = extractLastIndexes(arr, remainingItems).includes(modalIndex); - - return ( - - ); - }; - - const renderListItems = () => { + const renderPanels = () => { const elements: JSX.Element[] = []; + if ((first(searchData?.pages)?.size ?? 0) > 0) { + elements.push( + + ({ + total: page.totalSize, + size: page.size, + })} + extractItems={(page) => page.Metadata} + getItemKey={getPlexItemKey} + renderGridItem={renderGridItem} + renderListItem={(item) => ( + + )} + infiniteQuery={plexSearchQuery} + /> + , + ); + } if ( - tabValue === TabValues.Collections && - collectionsData && - (first(collectionsData.pages)?.size ?? 0) > 0 + // tabValue === TabValues.Collections && + (first(collectionsData?.pages)?.size ?? 0) > 0 ) { elements.push( - - {map( - compact(flatMap(collectionsData.pages, (page) => page.Metadata)), - (item, index: number) => - viewType === 'list' ? ( - - ) : ( - renderGridItems(item, index) - ), - )} - {renderFinalRowInlineModal( - compact(flatMap(collectionsData.pages, (page) => page.Metadata)), - )} - , + ({ + total: page.totalSize, + size: page.size, + })} + extractItems={(page) => page.Metadata ?? []} + getItemKey={getPlexItemKey} + renderGridItem={renderGridItem} + renderListItem={(item) => ( + + )} + infiniteQuery={plexCollectionsQuery} + /> + , ); - } - if ( - tabValue === TabValues.Playlists && - (first(playlistData?.pages)?.size ?? 0) > 0 - ) { - elements.push( - - {map( - compact(flatMap(playlistData?.pages, (page) => page.Metadata)), - (item, index: number) => - viewType === 'list' ? ( + if ( + // tabValue === TabValues.Collections && + (first(playlistData?.pages)?.size ?? 0) > 0 + ) { + elements.push( + + ({ + total: page.totalSize, + size: page.size, + })} + extractItems={(page) => page.Metadata ?? []} + getItemKey={getPlexItemKey} + renderGridItem={renderGridItem} + renderListItem={(item) => ( - ) : ( - renderGridItems(item, index) - ), - )} - {renderFinalRowInlineModal( - compact(flatMap(playlistData?.pages, (page) => page.Metadata)), - )} - , - ); - } - - if (searchData && (first(searchData.pages)?.size ?? 0) > 0) { - const items = chain(searchData.pages) - .map((page) => page.Metadata) - .flatten() - .value(); - - const totalSearchDataSize = - searchData.pages[0].totalSize || searchData.pages[0].size; - - elements.push( - - {map( - items, - (item: PlexMovie | PlexTvShow | PlexMusicArtist, index: number) => - viewType === 'list' ? ( - - ) : ( - renderGridItems(item, index) - ), - )} - - {items.length >= totalSearchDataSize && - renderFinalRowInlineModal(items)} - , - ); + )} + infiniteQuery={plexPlaylistsQuery} + /> + , + ); + } } return elements; @@ -640,42 +403,25 @@ export default function PlexProgrammingSelector() { - {!isUndefined(collectionsData) && - sumBy(collectionsData.pages, (page) => page.size) > 0 && ( - - )} - {!isUndefined(playlistData) && - sumBy(playlistData.pages, 'size') > 0 && ( - - )} + {sumBy(collectionsData?.pages, (page) => page.size) > 0 && ( + + )} + {sumBy(playlistData?.pages, 'size') > 0 && ( + + )} - - {renderListItems()} - - {!searchLoading &&
    } - {isFetchingNextItemsPage && ( - - )} - {searchData && !hasNextItemsPage && ( - - fin. - - )} - + {renderPanels()} )} diff --git a/web/src/components/channel_config/PlexSortField.tsx b/web/src/components/channel_config/PlexSortField.tsx index 464bc483..5e69ad7b 100644 --- a/web/src/components/channel_config/PlexSortField.tsx +++ b/web/src/components/channel_config/PlexSortField.tsx @@ -8,9 +8,9 @@ import find from 'lodash-es/find'; import isUndefined from 'lodash-es/isUndefined'; import map from 'lodash-es/map'; import { useCallback, useEffect, useState } from 'react'; -import { usePlexFilters } from '../../hooks/plex/usePlexFilters'; -import useStore from '../../store'; +import { useSelectedLibraryPlexFilters } from '../../hooks/plex/usePlexFilters'; import { setPlexSort } from '../../store/programmingSelector/actions'; +import { useCurrentSourceLibrary } from '@/store/programmingSelector/selectors.ts'; type PlexSort = { key: string; @@ -19,10 +19,7 @@ type PlexSort = { }; export function PlexSortField() { - const selectedServer = useStore((s) => s.currentServer); - const selectedLibrary = useStore((s) => - s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, - ); + const selectedLibrary = useCurrentSourceLibrary('plex'); const [sort, setSort] = useState({ key: '', @@ -31,10 +28,7 @@ export function PlexSortField() { }); const { data: plexFilterMetadata, isLoading: filterMetadataLoading } = - usePlexFilters( - selectedServer?.name ?? '', - selectedLibrary?.library.key ?? '', - ); + useSelectedLibraryPlexFilters(); const libraryFilterMetadata = find( plexFilterMetadata?.Type, diff --git a/web/src/components/channel_config/ProgrammingSelector.tsx b/web/src/components/channel_config/ProgrammingSelector.tsx index 1f7d9b54..2805fe2e 100644 --- a/web/src/components/channel_config/ProgrammingSelector.tsx +++ b/web/src/components/channel_config/ProgrammingSelector.tsx @@ -9,20 +9,60 @@ import { Typography, } from '@mui/material'; import { PlexMedia, isPlexDirectory } from '@tunarr/types/plex'; -import { find, isEmpty, isNil, isUndefined, map } from 'lodash-es'; +import { + capitalize, + chain, + find, + isEmpty, + isNil, + isUndefined, + map, + sortBy, +} from 'lodash-es'; import React, { useCallback, useEffect, useState } from 'react'; import { usePlexLibraries } from '../../hooks/plex/usePlex.ts'; -import { usePlexServerSettings } from '../../hooks/settingsHooks.ts'; +import { useMediaSources } from '../../hooks/settingsHooks.ts'; import { useCustomShows } from '../../hooks/useCustomShows.ts'; import useStore from '../../store/index.ts'; import { - addKnownMediaForServer, + addKnownMediaForJellyfinServer, + addKnownMediaForPlexServer, setProgrammingListLibrary, setProgrammingListingServer, } from '../../store/programmingSelector/actions.ts'; import AddPlexServer from '../settings/AddPlexServer.tsx'; import { CustomShowProgrammingSelector } from './CustomShowProgrammingSelector.tsx'; import PlexProgrammingSelector from './PlexProgrammingSelector.tsx'; +import { useJellyfinUserLibraries } from '@/hooks/jellyfin/useJellyfinApi.ts'; +import { JellyfinProgrammingSelector } from './JellyfinProgrammingSelector.tsx'; +import { useKnownMedia } from '@/store/programmingSelector/selectors.ts'; +import { JellyfinItem } from '@tunarr/types/jellyfin'; +import { tag } from '@tunarr/types'; + +const sortJellyfinLibraries = (item: JellyfinItem) => { + if (item.CollectionType) { + switch (item.CollectionType) { + case 'tvshows': + return 0; + case 'movies': + case 'music': + return 1; + case 'unknown': + case 'musicvideos': + case 'trailers': + case 'homevideos': + case 'boxsets': + case 'books': + case 'photos': + case 'livetv': + case 'playlists': + case 'folders': + return 2; + } + } + + return Number.MAX_SAFE_INTEGER; +}; export interface PlexListItemProps { item: T; @@ -32,50 +72,76 @@ export interface PlexListItemProps { parent?: string; } -export default function ProgrammingSelector() { - const { data: plexServers, isLoading: plexServersLoading } = - usePlexServerSettings(); +type Props = { + initialMediaSourceId?: string; + initialLibraryId?: string; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function ProgrammingSelector(_: Props) { + const { data: mediaSources, isLoading: mediaSourcesLoading } = + useMediaSources(); const selectedServer = useStore((s) => s.currentServer); const selectedLibrary = useStore((s) => s.currentLibrary); - const knownMedia = useStore((s) => s.knownMediaByServer); + const knownMedia = useKnownMedia(); const [mediaSource, setMediaSource] = useState(selectedServer?.name); // Convenience sub-selectors for specific library types const selectedPlexLibrary = selectedLibrary?.type === 'plex' ? selectedLibrary.library : undefined; + const selectedJellyfinLibrary = + selectedLibrary?.type === 'jellyfin' ? selectedLibrary.library : undefined; const viewingCustomShows = mediaSource === 'custom-shows'; - /** - * Load Plex libraries - */ const { data: plexLibraryChildren } = usePlexLibraries( - selectedServer?.name ?? '', - !isUndefined(selectedServer), + selectedServer?.id ?? tag(''), + selectedServer?.type === 'plex', + ); + + const { data: jellyfinLibraries } = useJellyfinUserLibraries( + selectedServer?.id ?? '', + selectedServer?.type === 'jellyfin', ); useEffect(() => { const server = - !isUndefined(plexServers) && !isEmpty(plexServers) - ? plexServers[0] + !isUndefined(mediaSources) && !isEmpty(mediaSources) + ? mediaSources[0] : undefined; setProgrammingListingServer(server); - }, [plexServers]); + }, [mediaSources]); useEffect(() => { - if (selectedServer && plexLibraryChildren) { - if (plexLibraryChildren.size > 0) { + if (selectedServer?.type === 'plex' && plexLibraryChildren) { + if ( + plexLibraryChildren.size > 0 && + (!selectedLibrary || selectedLibrary.type !== 'plex') + ) { setProgrammingListLibrary({ type: 'plex', library: plexLibraryChildren.Directory[0], }); } - addKnownMediaForServer(selectedServer.name, [ + addKnownMediaForPlexServer(selectedServer.id, [ ...plexLibraryChildren.Directory, ]); + } else if (selectedServer?.type === 'jellyfin' && jellyfinLibraries) { + if ( + jellyfinLibraries.Items.length > 0 && + (!selectedLibrary || selectedLibrary.type !== 'jellyfin') + ) { + setProgrammingListLibrary({ + type: 'jellyfin', + library: sortBy(jellyfinLibraries.Items, sortJellyfinLibraries)[0], + }); + } + addKnownMediaForJellyfinServer(selectedServer.id, [ + ...jellyfinLibraries.Items, + ]); } - }, [selectedServer, plexLibraryChildren]); + }, [selectedServer, plexLibraryChildren, jellyfinLibraries, selectedLibrary]); /** * Load custom shows @@ -83,44 +149,65 @@ export default function ProgrammingSelector() { const { data: customShows } = useCustomShows(); const onMediaSourceChange = useCallback( - (newMediaSource: string) => { - if (newMediaSource === 'custom-shows') { + (newMediaSourceId: string) => { + if (newMediaSourceId === 'custom-shows') { // Not dealing with a server setProgrammingListLibrary({ type: 'custom-show' }); setProgrammingListingServer(undefined); - setMediaSource(newMediaSource); + setMediaSource(newMediaSourceId); } else { - const server = find(plexServers, { name: newMediaSource }); + const server = find( + mediaSources, + (source) => source.id === newMediaSourceId, + ); if (server) { setProgrammingListingServer(server); setMediaSource(server.name); } } }, - [plexServers], + [mediaSources], ); const onLibraryChange = useCallback( (libraryUuid: string) => { - if (selectedServer) { - const known = knownMedia[selectedServer.name] ?? {}; - const library = known[libraryUuid]; + if (selectedServer?.type === 'plex') { + const library = knownMedia.getMediaOfType( + selectedServer.id, + libraryUuid, + 'plex', + ); if (library && isPlexDirectory(library)) { setProgrammingListLibrary({ type: 'plex', library }); } + } else if (selectedServer?.type === 'jellyfin') { + const library = knownMedia.getMediaOfType( + selectedServer.id, + libraryUuid, + 'jellyfin', + ); + if (library) { + setProgrammingListLibrary({ type: 'jellyfin', library }); + } } }, [knownMedia, selectedServer], ); const renderMediaSourcePrograms = () => { - if (selectedLibrary?.type === 'custom-show') { - return ; - } else if (selectedLibrary?.type === 'plex') { - return ; + if (selectedLibrary) { + switch (selectedLibrary.type) { + case 'plex': + return ; + case 'jellyfin': + return ; + case 'custom-show': + return ; + } } - if (!plexServersLoading && !selectedServer) { + // TODO: change the wording here to not be Plex-specific + if (!mediaSourcesLoading && !selectedServer) { return ( <> @@ -146,8 +233,64 @@ export default function ProgrammingSelector() { return null; }; + const renderLibraryChoices = () => { + if (isUndefined(selectedServer)) { + return; + } + + switch (selectedServer.type) { + case 'plex': { + return ( + !isNil(plexLibraryChildren) && + plexLibraryChildren.size > 0 && + selectedPlexLibrary && ( + + Library + + + ) + ); + } + case 'jellyfin': { + return ( + !isNil(jellyfinLibraries) && + jellyfinLibraries.Items.length > 0 && + selectedJellyfinLibrary && ( + + Library + + + ) + ); + } + } + }; + const hasAnySources = - (plexServers && plexServers.length > 0) || customShows.length > 0; + (mediaSources && mediaSources.length > 0) || customShows.length > 0; return ( @@ -168,15 +311,13 @@ export default function ProgrammingSelector() { onLibraryChange(e.target.value)} - > - {plexLibraryChildren.Directory.map((dir) => ( - - {dir.title} - - ))} - - - )} + {renderLibraryChoices()}
    {renderMediaSourcePrograms()} diff --git a/web/src/components/channel_config/SelectedProgrammingActions.tsx b/web/src/components/channel_config/SelectedProgrammingActions.tsx index aa7528d1..45773abf 100644 --- a/web/src/components/channel_config/SelectedProgrammingActions.tsx +++ b/web/src/components/channel_config/SelectedProgrammingActions.tsx @@ -6,13 +6,14 @@ import { useSnackbar } from 'notistack'; import { useCallback, useState } from 'react'; import useStore from '../../store/index.ts'; import { - addKnownMediaForServer, + addKnownMediaForPlexServer, addPlexSelectedMedia, clearSelectedMedia, } from '../../store/programmingSelector/actions.ts'; import { AddedMedia } from '../../types/index.ts'; import { RotatingLoopIcon } from '../base/LoadingIcon.tsx'; import AddSelectedMediaButton from './AddSelectedMediaButton.tsx'; +import { useCurrentMediaSourceAndLibrary } from '@/store/programmingSelector/selectors.ts'; type Props = { onAddSelectedMedia: (media: AddedMedia[]) => void; @@ -28,10 +29,8 @@ export default function SelectedProgrammingActions({ selectAllEnabled = true, toggleOrSetSelectedProgramsDrawer, // onSelectionModalClose, }: Props) { - const [selectedServer, selectedLibrary] = useStore((s) => [ - s.currentServer, - s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, - ]); + const [selectedServer, selectedLibrary] = + useCurrentMediaSourceAndLibrary('plex'); const { urlFilter: plexSearch } = useStore( ({ plexSearch: plexQuery }) => plexQuery, @@ -64,8 +63,11 @@ export default function SelectedProgrammingActions({ setSelectAllLoading(true); directPlexSearchFn() .then((response) => { - addKnownMediaForServer(selectedServer.name, response.Metadata ?? []); - addPlexSelectedMedia(selectedServer.name, response.Metadata); + addKnownMediaForPlexServer( + selectedServer.id, + response.Metadata ?? [], + ); + addPlexSelectedMedia(selectedServer, response.Metadata); }) .catch((e) => { console.error('Error while attempting to select all Plex items', e); diff --git a/web/src/components/channel_config/SelectedProgrammingList.tsx b/web/src/components/channel_config/SelectedProgrammingList.tsx index 0f2f4541..d1e1649a 100644 --- a/web/src/components/channel_config/SelectedProgrammingList.tsx +++ b/web/src/components/channel_config/SelectedProgrammingList.tsx @@ -4,7 +4,9 @@ import { KeyboardArrowRight, Close as RemoveIcon, } from '@mui/icons-material'; +import JellyfinLogo from '@/assets/jellyfin.svg'; import { + Box, Chip, ClickAwayListener, Drawer, @@ -24,7 +26,14 @@ import { isPlexSeason, isPlexShow, } from '@tunarr/types/plex'; -import { first, groupBy, isUndefined, mapValues, reduce } from 'lodash-es'; +import { + find, + first, + groupBy, + isUndefined, + mapValues, + reduce, +} from 'lodash-es'; import pluralize from 'pluralize'; import { ReactNode, useState } from 'react'; import { FixedSizeList, ListChildComponentProps } from 'react-window'; @@ -36,6 +45,8 @@ import { removeSelectedMedia } from '../../store/programmingSelector/actions.ts' import { AddedMedia } from '../../types/index.ts'; import AddSelectedMediaButton from './AddSelectedMediaButton.tsx'; import SelectedProgrammingActions from './SelectedProgrammingActions.tsx'; +import { useKnownMedia } from '@/store/programmingSelector/selectors.ts'; +import { useMediaSources } from '@/hooks/settingsHooks.ts'; type Props = { onAddSelectedMedia: (media: AddedMedia[]) => void; @@ -48,8 +59,9 @@ export default function SelectedProgrammingList({ onAddMediaSuccess, selectAllEnabled = true, }: Props) { + const { data: mediaSources } = useMediaSources(); const { data: customShows } = useCustomShows(); - const knownMedia = useStore((s) => s.knownMediaByServer); + const knownMedia = useKnownMedia(); const selectedMedia = useStore((s) => s.selectedMedia); const [open, setOpen] = useState(false); const windowSize = useWindowSize(); @@ -74,7 +86,11 @@ export default function SelectedProgrammingList({ [ListChildComponentProps] >({ plex: (selected, { style }) => { - const media = knownMedia[selected.server][selected.guid]; + const media = knownMedia.getMediaOfType( + selected.serverId, + selected.id, + 'plex', + )!; let title: string = media.title; let secondary: ReactNode = null; @@ -105,13 +121,69 @@ export default function SelectedProgrammingList({ } return ( - + + + + removeSelectedMedia([selected])}> + + + + + ); + }, + jellyfin: (selected, { style }) => { + const media = knownMedia.getMediaOfType( + selected.serverId, + selected.id, + 'jellyfin', + )!; + + let title: string = media.Name ?? ''; + let secondary: ReactNode = null; + if (media.Type === 'CollectionFolder') { + // TODO: Show the size + title = `Media - ${media.Name}`; + } else if (media.Type === 'Series') { + secondary = `${media.ChildCount ?? 0} ${pluralize( + 'season', + media.ChildCount ?? 0, + )}, ${media.RecursiveItemCount ?? 0} total ${pluralize( + 'episode', + media.RecursiveItemCount ?? 0, + )}`; + } else if (media.Type === 'Season') { + secondary = `eh help - ${media.Name} (${ + media.ChildCount ?? 0 + } ${pluralize('episode', media.ChildCount ?? 0)})`; + // } else if (media.Type === '') { + // secondary = `${media.title} (${media.childCount} ${pluralize( + // 'item', + // parseInt(media.childCount), + // )})`; + // } + } else if (media.Type === 'Movie') { + secondary = `Movie${ + media.ProductionYear ? ', ' + media.ProductionYear : '' + }`; + } + // else if (isPlexPlaylist(media) && !isUndefined(media.leafCount)) { + // secondary = `Playlist with ${media.leafCount} ${pluralize( + // 'tracks', + // media.leafCount, + // )}`; + // } + + return ( + + + + removeSelectedMedia([selected])}> @@ -154,7 +226,9 @@ export default function SelectedProgrammingList({ const item = data[index]; switch (item.type) { case 'plex': - return item.guid; + return `plex.${item.serverId}.${item.id}`; + case 'jellyfin': + return `jellyfin.${item.serverId}.${item.id}`; case 'custom-show': return `custom_${item.customShowId}_${index}`; } diff --git a/web/src/components/custom-shows/EditCustomShowForm.tsx b/web/src/components/custom-shows/EditCustomShowForm.tsx index 208a095d..06f85284 100644 --- a/web/src/components/custom-shows/EditCustomShowForm.tsx +++ b/web/src/components/custom-shows/EditCustomShowForm.tsx @@ -21,6 +21,8 @@ import { useNavigate, Link } from '@tanstack/react-router'; import { CustomShow } from '@tunarr/types'; import { useEffect, useCallback } from 'react'; import { useForm, SubmitHandler, Controller } from 'react-hook-form'; +import { createExternalId } from '@tunarr/shared'; +import { isNonEmptyString } from '@/helpers/util.ts'; type CustomShowForm = { id?: string; @@ -120,11 +122,21 @@ export function EditCustomShowsForm({ } else { title = p.title; } - id = p.persisted - ? p.id! - : `${p.externalSourceType}|${p.externalSourceName}|${ - p.originalProgram!.key - }`; + if (p.persisted) { + id = p.id!; + } else if ( + isNonEmptyString(p.externalSourceType) && + isNonEmptyString(p.externalSourceName) && + isNonEmptyString(p.externalKey) + ) { + id = createExternalId( + p.externalSourceType, + p.externalSourceName, + p.externalKey, + ); + } else { + id = 'unknown'; + } break; } diff --git a/web/src/components/filler/EditFillerListForm.tsx b/web/src/components/filler/EditFillerListForm.tsx index 194518e0..bb1a122d 100644 --- a/web/src/components/filler/EditFillerListForm.tsx +++ b/web/src/components/filler/EditFillerListForm.tsx @@ -21,6 +21,8 @@ import { Controller, SubmitHandler, useForm } from 'react-hook-form'; import { useTunarrApi } from '../../hooks/useTunarrApi.ts'; import { removeFillerListProgram } from '@/store/entityEditor/util.ts'; import { UIFillerListProgram } from '../../types/index.ts'; +import { isNonEmptyString } from '@/helpers/util.ts'; +import { createExternalId } from '@tunarr/shared'; export type FillerListMutationArgs = { id?: string; @@ -117,11 +119,22 @@ export function EditFillerListForm({ title = p.title; } - id = p.persisted - ? p.id! - : `${p.externalSourceType}|${p.externalSourceName}|${ - p.originalProgram!.key - }`; + if (p.persisted) { + id = p.id!; + } else if ( + isNonEmptyString(p.externalSourceType) && + isNonEmptyString(p.externalSourceName) && + isNonEmptyString(p.externalKey) + ) { + id = createExternalId( + p.externalSourceType, + p.externalSourceName, + p.externalKey, + ); + } else { + id = 'unknown'; + } + break; } diff --git a/web/src/components/settings/AddPlexServer.tsx b/web/src/components/settings/AddPlexServer.tsx index fa5cbc89..436c1e1c 100644 --- a/web/src/components/settings/AddPlexServer.tsx +++ b/web/src/components/settings/AddPlexServer.tsx @@ -1,11 +1,11 @@ import { AddCircle, SvgIconComponent } from '@mui/icons-material'; import { Button } from '@mui/material'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { InsertPlexServerRequest } from '@tunarr/types/api'; -import { checkNewPlexServers, plexLoginFlow } from '../../helpers/plexLogin.ts'; -import { useTunarrApi } from '../../hooks/useTunarrApi.ts'; +import { InsertMediaSourceRequest } from '@tunarr/types/api'; import { isEmpty } from 'lodash-es'; import { useSnackbar } from 'notistack'; +import { checkNewPlexServers, plexLoginFlow } from '../../helpers/plexLogin.ts'; +import { useTunarrApi } from '../../hooks/useTunarrApi.ts'; type AddPlexServer = { title?: string; @@ -20,12 +20,12 @@ export default function AddPlexServer(props: AddPlexServer) { const snackbar = useSnackbar(); const addPlexServerMutation = useMutation({ - mutationFn: (newServer: InsertPlexServerRequest) => { - return apiClient.createPlexServer(newServer); + mutationFn: (newServer: InsertMediaSourceRequest) => { + return apiClient.createMediaSource(newServer); }, onSuccess: () => { return queryClient.invalidateQueries({ - queryKey: ['settings', 'plex-servers'], + queryKey: ['settings', 'media-sources'], }); }, }); @@ -53,6 +53,7 @@ export default function AddPlexServer(props: AddPlexServer) { uri: connection.uri, accessToken: server.accessToken, clientIdentifier: server.clientIdentifier, + type: 'plex', }), ); }) diff --git a/web/src/components/settings/ConnectPlex.tsx b/web/src/components/settings/ConnectPlex.tsx index 0b227e2d..62735c64 100644 --- a/web/src/components/settings/ConnectPlex.tsx +++ b/web/src/components/settings/ConnectPlex.tsx @@ -15,7 +15,7 @@ import { } from '@mui/material'; import { Link as RouterLink } from '@tanstack/react-router'; import plexSvg from '../../assets/plex.svg'; -import { usePlexServerSettings } from '../../hooks/settingsHooks.ts'; +import { useMediaSources } from '../../hooks/settingsHooks.ts'; import AddPlexServer from './AddPlexServer.tsx'; export default function ConnectPlex(props: CardProps) { @@ -28,7 +28,7 @@ export default function ConnectPlex(props: CardProps) { ...restProps } = props; - const { data: plexServers } = usePlexServerSettings(); + const { data: plexServers } = useMediaSources(); const isPlexConnected = plexServers && plexServers.length > 0; const title = isPlexConnected ? 'Add Plex Library' : 'Connect Plex Now'; diff --git a/web/src/components/settings/media_source/JelllyfinServerEditDialog.tsx b/web/src/components/settings/media_source/JelllyfinServerEditDialog.tsx new file mode 100644 index 00000000..108c3197 --- /dev/null +++ b/web/src/components/settings/media_source/JelllyfinServerEditDialog.tsx @@ -0,0 +1,536 @@ +import { isNonEmptyString, isValidUrl, toggle } from '@/helpers/util'; +import { useTunarrApi } from '@/hooks/useTunarrApi'; + +import { RotatingLoopIcon } from '@/components/base/LoadingIcon.tsx'; +import { + CloudDoneOutlined, + CloudOff, + Visibility, + VisibilityOff, +} from '@mui/icons-material'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + FormControl, + FormHelperText, + IconButton, + InputAdornment, + InputLabel, + OutlinedInput, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { JellyfinServerSettings, PlexServerSettings } from '@tunarr/types'; +import { isEmpty, isUndefined } from 'lodash-es'; +import { FormEvent, useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { MarkOptional } from 'ts-essentials'; +import { useDebounceCallback, useDebounceValue } from 'usehooks-ts'; +import { useMediaSourceBackendStatus } from '@/hooks/media-sources/useMediaSourceBackendStatus'; +import { jellyfinLogin } from '@/hooks/jellyfin/useJellyfinLogin.ts'; +import { useSnackbar } from 'notistack'; + +type Props = { + open: boolean; + onClose: () => void; + server?: JellyfinServerSettings; +}; + +type PlexServerSettingsForm = MarkOptional< + Omit, + 'id' +>; + +export type JellyfinServerSettingsForm = MarkOptional< + JellyfinServerSettings, + 'id' +> & { + username?: string; + password?: string; +}; + +export type FormType = { + plex?: PlexServerSettingsForm; + jellyfin?: JellyfinServerSettingsForm; +}; + +const emptyDefaults: JellyfinServerSettingsForm = { + type: 'jellyfin', + uri: '', + name: '', + accessToken: '', + username: '', + password: '', +}; + +export function JellyfinServerEditDialog({ open, onClose, server }: Props) { + const apiClient = useTunarrApi(); + const queryClient = useQueryClient(); + const snackbar = useSnackbar(); + + const [showAccessToken, setShowAccessToken] = useState(false); + + const title = server ? `Editing "${server.name}"` : 'New Media Source'; + + const handleClose = () => { + reset(emptyDefaults); + setShowAccessToken(false); + onClose(); + }; + + const { + control, + watch, + reset, + formState: { isDirty, isValid, defaultValues, errors }, + handleSubmit, + setError, + clearErrors, + getValues, + } = useForm({ + mode: 'onChange', + defaultValues: server ?? emptyDefaults, + }); + + // These are updated in a watch callback, so we debounce them + // along with the details we use to check server status. Otherwise + // setting the error will cause us to check server status on every + // keystroke due to re-renders + const debounceSetError = useDebounceCallback(setError); + const debounceClearError = useDebounceCallback(clearErrors); + + const updateSourceMutation = useMutation({ + mutationFn: async (newOrUpdatedServer: JellyfinServerSettingsForm) => { + if (isNonEmptyString(newOrUpdatedServer.id)) { + await apiClient.updateMediaSource( + { ...newOrUpdatedServer, id: newOrUpdatedServer.id }, + { + params: { id: newOrUpdatedServer.id }, + }, + ); + return { id: newOrUpdatedServer.id }; + } else { + return apiClient.createMediaSource(newOrUpdatedServer); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['settings', 'media-sources'], + }); + handleClose(); + }, + }); + + // const { + // data: derivedAccessToken, + // isLoading: derivedAccessTokenLoading, + // error: derivedAccessTokenError, + // refetch: refetchAccessToken, + // } = useJellyinLogin(serverStatusDetails, false /* TODO is this right */); + + const showErrorSnack = (e: unknown) => { + snackbar.enqueueSnackbar({ + variant: 'error', + message: + 'Error saving new Jellyfin server. See browser console and server logs for details', + }); + console.error(e); + }; + + const onSubmit = async (e: FormEvent) => { + e.stopPropagation(); + + const { accessToken, username, password, uri } = getValues(); + + if (isNonEmptyString(accessToken)) { + void handleSubmit( + (data) => updateSourceMutation.mutate(data), + showErrorSnack, + )(e); + } else if (isNonEmptyString(username) && isNonEmptyString(password)) { + try { + const result = await jellyfinLogin(apiClient, { + username, + password, + uri, + }); + + if (isNonEmptyString(result.accessToken)) { + void handleSubmit( + (data) => + updateSourceMutation.mutate({ + ...data, + accessToken: result.accessToken!, + }), + showErrorSnack, + )(e); + } else { + // Pop snackbar + } + } catch (e) { + showErrorSnack(e); + } + } + }; + + const [showPassword, setShowPassword] = useState(false); + + const [serverStatusDetails, updateServerStatusDetails] = useDebounceValue( + { + id: server?.id && !isDirty ? server.id : undefined, + accessToken: defaultValues?.accessToken ?? '', + uri: defaultValues?.uri ?? '', + // type: 'jellyfin' as const, + }, + 1000, + { + equalityFn: (left, right) => { + return ( + left.id === right.id && + left.uri === right.uri && + left.accessToken === right.accessToken + ); + }, + }, + ); + + // This probably isn't the best way to do this...but it was the only + // way to get it working without infinite re-renders. Idea here is: + // Update the debounced value if relevant details change. Do not rely + // on the debounced value itself in this effect, because then we'll + // just update every time. This watch will fire off every time accessToken + // or URI changes, but the status query will only fire every 500ms + useEffect(() => { + const sub = watch((value, { name }) => { + if ( + isNonEmptyString(value.accessToken) || + (isNonEmptyString(value.username) && isNonEmptyString(value.password)) + ) { + debounceClearError('root.auth'); + } else { + debounceSetError('root.auth', { + message: 'Must provide either access token or username/password', + }); + } + + if (name === 'uri' || name === 'accessToken') { + updateServerStatusDetails({ + id: server?.id && !isDirty ? server.id : undefined, + accessToken: value.accessToken ?? '', + uri: value.uri ?? '', + // type: 'jellyfin' as const, + }); + } + }); + + return () => sub.unsubscribe(); + }, [ + watch, + updateServerStatusDetails, + server?.id, + isDirty, + debounceClearError, + debounceSetError, + errors, + ]); + + // useEffect(() => { + // if ( + // isNonEmptyString(serverStatusDetails.url) && + // isNonEmptyString(serverStatusDetails.username) && + // isNonEmptyString(serverStatusDetails.password) + // ) { + // jellyfinLogin(apiClient, serverStatusDetails) + // .then(({ accessToken }) => { + // if (isNonEmptyString(accessToken) && !accessTokenTouched) { + // setValue('accessToken', derivedAccessToken?.accessToken ?? '', { + // shouldValidate: true, + // }); + // } + // }) + // .catch(console.error); + // } + // }, [ + // serverStatusDetails, + // apiClient, + // accessTokenTouched, + // setValue, + // derivedAccessToken?.accessToken, + // ]); + + const { data: serverStatus, isLoading: serverStatusLoading } = + useMediaSourceBackendStatus( + { ...serverStatusDetails, type: 'jellyfin' }, + open, // && isNonEmptyString(accessToken), + ); + + console.log(serverStatus); + + // useEffect(() => { + // if (!isUndefined(derivedAccessToken) && !accessTokenTouched) { + // setValue('accessToken', derivedAccessToken?.accessToken ?? '', { + // shouldValidate: true, + // }); + // } + // // else if (!isUndefined(serverError) && !accessTokenTouched) { + // // setValue('accessToken', ''); + // // } + // }, [ + // derivedAccessToken, + // derivedAccessTokenError, + // setValue, + // getValues, + // accessTokenTouched, + // ]); + + // TODO: Block creation if an existing server with the same URL/name + // already exist + return ( + onClose()}> + {title} + + + + { + return isValidUrl(value) ? undefined : 'Not a valid URL'; + }, + }, + }} + render={({ field, fieldState: { error } }) => ( + {error.message} + ) : !isUndefined(serverStatus) && + !serverStatus.healthy && + isNonEmptyString(field.value) ? ( + <> + Server is unreachable +
    + + ) : null + } + InputProps={{ + endAdornment: serverStatusLoading ? ( + + ) : !isUndefined(serverStatus) && serverStatus.healthy ? ( + + ) : ( + + ), + }} + /> + )} + /> + ( + + )} + /> + + ( + + + Username{' '} + + + + {error && isNonEmptyString(error.message) && ( + {error.message} + )} + + + )} + /> + ( + + + Password{' '} + + + setShowPassword(toggle)} + edge="end" + > + {showPassword ? : } + + + } + label="Access Token" + {...field} + /> + + {error && isNonEmptyString(error.message) && ( + <> + {error.message} +
    + + )} + {/* + Enter your Jellyfin password to generate a new access token, + or enter the token you want to use below. + */} +
    +
    + )} + /> + + Enter your Jellyfin password to generate a new access token. + +
    + + + OR + + + ( + + Access Token + + setShowAccessToken(toggle)} + edge="end" + > + {showAccessToken ? : } + + + } + label="Access Token" + {...field} + /> + + <> + {error && isNonEmptyString(error.message) && ( + <> + {error.message} +
    + + )} + + Manually add an access token from your Jellyfin server + + +
    +
    + )} + /> +
    +
    +
    + + + + +
    + ); +} diff --git a/web/src/components/settings/plex/PlexServerDeleteDialog.tsx b/web/src/components/settings/media_source/MediaSourceDeleteDialog.tsx similarity index 73% rename from web/src/components/settings/plex/PlexServerDeleteDialog.tsx rename to web/src/components/settings/media_source/MediaSourceDeleteDialog.tsx index 9b50a558..34af9721 100644 --- a/web/src/components/settings/plex/PlexServerDeleteDialog.tsx +++ b/web/src/components/settings/media_source/MediaSourceDeleteDialog.tsx @@ -9,30 +9,30 @@ import { import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTunarrApi } from '@/hooks/useTunarrApi.ts'; -export function PlexServerDeleteDialog({ +export function MediaSourceDeleteDialog({ open, onClose, serverId, }: PlexServerDeleteDialogProps) { const apiClient = useTunarrApi(); const queryClient = useQueryClient(); - const removePlexServerMutation = useMutation({ + const deleteMediaSourceMutation = useMutation({ mutationFn: (id: string) => { - return apiClient.deletePlexServer(null, { params: { id } }); + return apiClient.deleteMediaSource(null, { params: { id } }); }, onSuccess: () => { return queryClient.invalidateQueries({ - queryKey: ['settings', 'plex-servers'], + queryKey: ['settings', 'media-sources'], }); }, }); - const titleId = `delete-plex-server-${serverId}-title`; - const descId = `delete-plex-server-${serverId}-description`; + const titleId = `delete-media-source-${serverId}-title`; + const descId = `delete-media-source-${serverId}-description`; return ( - Delete Plex Server? + Delete Media Source? Deleting a Plex server will remove all programming from your channels @@ -45,7 +45,7 @@ export function PlexServerDeleteDialog({ Cancel - - - - ); - }; - - const removePlexServer = (id: string) => { - removePlexServerMutation.mutate(id); - setDeletePlexConfirmation(undefined); - }; - const getTableRows = () => { return map(servers, (server) => { - return ; + return ; }); }; @@ -214,31 +183,32 @@ export default function PlexSettingsPage() { + Type Name URL - {/* - UI - - - - - - */} - + Healthy? + The connection to the media source +
    + from the Tunarr server. + + } >
    - +
    @@ -290,9 +260,20 @@ export default function PlexSettingsPage() { ); }; + const handleOpenMediaSourceDialog = (source: 'plex' | 'jellyfin') => { + switch (source) { + case 'plex': + setPlexEditDialogOpen(true); + break; + case 'jellyfin': + setJellyfinEditDialogOpen(true); + break; + } + closeManualAddButtonMenu(); + }; + return ( - {renderConfirmationDialog()} - - Plex Servers + ({ + flexGrow: 1, + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + })} + > + Media Sources - + + + + handleOpenMediaSourceDialog('plex')}> + + + + + + Plex + + handleOpenMediaSourceDialog('jellyfin')} + > + + + + + + Jellyfin + + + - Add Plex Servers as content sources for your channel. "Discover" - will use the Plex login flow to discover servers associated with - your account, however you can also manually add Plex server - details using the "Manual Add" button. + Add sources of content for your channels.
    "Discover" will + attempt to automatically find sources and is supported for Plex + only.
    {renderServersTable()}
    - Plex Streaming + Streaming Options @@ -339,7 +363,6 @@ export default function PlexSettingsPage() {