feat!: implement local media libraries (#1406)

Initial implementation of local media libraries. Includes local scanners
for movie and TV library types. Saves extracted metadata locally.

Some things are missing, including:
* Saving all metadata locally, including genres, actors, etc.
* blurhash extraction - this is computationally expensive at scale and
  should be done async
* Hooking up subtitle extraction to new subtitle DB tables
This commit is contained in:
Christian Benincasa
2025-10-14 16:41:58 -04:00
committed by GitHub
parent 9a791467df
commit a748408fcc
255 changed files with 33768 additions and 3379 deletions

File diff suppressed because one or more lines are too long

333
pnpm-lock.yaml generated
View File

@@ -114,6 +114,9 @@ importers:
server:
dependencies:
'@cospired/i18n-iso-languages':
specifier: ^4.2.0
version: 4.2.0
'@dotenvx/dotenvx':
specifier: ^1.49.0
version: 1.49.0
@@ -171,6 +174,9 @@ importers:
better-sqlite3:
specifier: 11.8.1
version: 11.8.1
blurhash:
specifier: ^2.0.5
version: 2.0.5
chalk:
specifier: ^5.6.0
version: 5.6.0
@@ -261,6 +267,9 @@ importers:
retry:
specifier: ^0.13.1
version: 0.13.1
sharp:
specifier: ^0.34.4
version: 0.34.4
split2:
specifier: ^4.2.0
version: 4.2.0
@@ -509,7 +518,7 @@ importers:
version: 11.14.0(@emotion/react@11.14.0(@types/react@18.2.15)(react@18.2.0))(@types/react@18.2.15)(react@18.2.0)
'@hookform/error-message':
specifier: ^2.0.1
version: 2.0.1(react-dom@18.2.0(react@18.2.0))(react-hook-form@7.48.2(react@18.2.0))(react@18.2.0)
version: 2.0.1(react-dom@18.2.0(react@18.2.0))(react-hook-form@7.63.0(react@18.2.0))(react@18.2.0)
'@mui/icons-material':
specifier: ^7.0.2
version: 7.0.2(@mui/material@7.0.2(@emotion/react@11.14.0(@types/react@18.2.15)(react@18.2.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.2.15)(react@18.2.0))(@types/react@18.2.15)(react@18.2.0))(@types/react@18.2.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@types/react@18.2.15)(react@18.2.0)
@@ -595,8 +604,8 @@ importers:
specifier: ^6.0.0
version: 6.0.0(react@18.2.0)
react-hook-form:
specifier: ^7.48.2
version: 7.48.2(react@18.2.0)
specifier: ^7.63.0
version: 7.63.0(react@18.2.0)
react-markdown:
specifier: ^9.0.3
version: 9.0.3(@types/react@18.2.15)(react@18.2.0)
@@ -1106,6 +1115,9 @@ packages:
peerDependencies:
'@noble/ciphers': ^1.0.0
'@emnapi/runtime@1.5.0':
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
'@emotion/babel-plugin@11.13.5':
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
@@ -1879,6 +1891,132 @@ packages:
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
engines: {node: '>=18.18'}
'@img/colour@1.0.0':
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.4':
resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.4':
resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.3':
resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.3':
resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.3':
resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.2.3':
resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.2.3':
resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.2.3':
resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.2.3':
resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.34.4':
resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.34.4':
resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-ppc64@0.34.4':
resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-s390x@0.34.4':
resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.34.4':
resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.34.4':
resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.34.4':
resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.34.4':
resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.4':
resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.4':
resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.4':
resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@inversifyjs/common@1.4.0':
resolution: {integrity: sha512-qfRJ/3iOlCL/VfJq8+4o5X4oA14cZSBbpAmHsYj8EsIit1xDndoOl0xKOyglKtQD4u4gdNVxMHx4RWARk/I4QA==}
@@ -3728,6 +3866,9 @@ packages:
bluebird@3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
blurhash@2.0.5:
resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==}
bottleneck@2.19.5:
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
@@ -4341,6 +4482,10 @@ packages:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
detect-libc@2.1.1:
resolution: {integrity: sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==}
engines: {node: '>=8'}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -7145,11 +7290,11 @@ packages:
peerDependencies:
react: '>=16.13.1'
react-hook-form@7.48.2:
resolution: {integrity: sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==}
engines: {node: '>=12.22.0'}
react-hook-form@7.63.0:
resolution: {integrity: sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18
react: ^16.8.0 || ^17 || ^18 || ^19
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -7478,6 +7623,10 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sharp@0.34.4:
resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@1.2.0:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'}
@@ -8934,7 +9083,7 @@ snapshots:
outdent: 0.5.0
prettier: 2.8.8
resolve-from: 5.0.0
semver: 7.5.4
semver: 7.7.2
'@changesets/assemble-release-plan@6.0.3':
dependencies:
@@ -8944,7 +9093,7 @@ snapshots:
'@changesets/should-skip-package': 0.1.0
'@changesets/types': 6.0.0
'@manypkg/get-packages': 1.1.3
semver: 7.5.4
semver: 7.7.2
'@changesets/changelog-git@0.2.0':
dependencies:
@@ -9005,7 +9154,7 @@ snapshots:
'@manypkg/get-packages': 1.1.3
chalk: 2.4.2
fs-extra: 7.0.1
semver: 7.5.4
semver: 7.7.2
'@changesets/get-release-plan@4.0.3':
dependencies:
@@ -9243,6 +9392,11 @@ snapshots:
dependencies:
'@noble/ciphers': 1.3.0
'@emnapi/runtime@1.5.0':
dependencies:
tslib: 2.8.1
optional: true
'@emotion/babel-plugin@11.13.5':
dependencies:
'@babel/helper-module-imports': 7.24.7
@@ -9669,7 +9823,7 @@ snapshots:
debug: 4.4.1
espree: 10.3.0
globals: 14.0.0
ignore: 5.3.1
ignore: 5.3.2
import-fresh: 3.3.0
js-yaml: 4.1.0
minimatch: 3.1.2
@@ -9784,11 +9938,11 @@ snapshots:
dependencies:
'@hey-api/openapi-ts': 0.80.16(magicast@0.3.5)(typescript@5.7.3)
'@hookform/error-message@2.0.1(react-dom@18.2.0(react@18.2.0))(react-hook-form@7.48.2(react@18.2.0))(react@18.2.0)':
'@hookform/error-message@2.0.1(react-dom@18.2.0(react@18.2.0))(react-hook-form@7.63.0(react@18.2.0))(react@18.2.0)':
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-hook-form: 7.48.2(react@18.2.0)
react-hook-form: 7.63.0(react@18.2.0)
'@humanfs/core@0.19.1': {}
@@ -9803,6 +9957,94 @@ snapshots:
'@humanwhocodes/retry@0.4.1': {}
'@img/colour@1.0.0': {}
'@img/sharp-darwin-arm64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.3
optional: true
'@img/sharp-darwin-x64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.3
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.3':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.3':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.3':
optional: true
'@img/sharp-libvips-linux-arm@1.2.3':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.3':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.3':
optional: true
'@img/sharp-libvips-linux-x64@1.2.3':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
optional: true
'@img/sharp-linux-arm64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.3
optional: true
'@img/sharp-linux-arm@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.3
optional: true
'@img/sharp-linux-ppc64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.3
optional: true
'@img/sharp-linux-s390x@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.3
optional: true
'@img/sharp-linux-x64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.3
optional: true
'@img/sharp-linuxmusl-arm64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3
optional: true
'@img/sharp-linuxmusl-x64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.3
optional: true
'@img/sharp-wasm32@0.34.4':
dependencies:
'@emnapi/runtime': 1.5.0
optional: true
'@img/sharp-win32-arm64@0.34.4':
optional: true
'@img/sharp-win32-ia32@0.34.4':
optional: true
'@img/sharp-win32-x64@0.34.4':
optional: true
'@inversifyjs/common@1.4.0': {}
'@inversifyjs/core@1.3.5(reflect-metadata@0.2.2)':
@@ -10492,7 +10734,7 @@ snapshots:
read-pkg: 9.0.1
registry-auth-token: 5.1.0
semantic-release: 24.2.7(typescript@5.7.3)
semver: 7.6.3
semver: 7.7.2
tempy: 3.1.0
'@semantic-release/release-notes-generator@14.0.3(semantic-release@24.2.7(typescript@5.7.3))':
@@ -11287,7 +11529,7 @@ snapshots:
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.7.3)
eslint: 9.17.0(jiti@2.4.1)
eslint-scope: 5.1.1
semver: 7.5.4
semver: 7.7.2
transitivePeerDependencies:
- supports-color
- typescript
@@ -11541,9 +11783,9 @@ snapshots:
dependencies:
acorn: 8.11.3
acorn-jsx@5.3.2(acorn@8.14.1):
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.14.1
acorn: 8.15.0
acorn-walk@8.3.4:
dependencies:
@@ -11850,6 +12092,8 @@ snapshots:
bluebird@3.7.2: {}
blurhash@2.0.5: {}
bottleneck@2.19.5: {}
bowser@2.11.0: {}
@@ -12222,7 +12466,7 @@ snapshots:
conventional-commits-filter: 5.0.0
handlebars: 4.7.8
meow: 13.2.0
semver: 7.6.3
semver: 7.7.2
conventional-commits-filter@5.0.0: {}
@@ -12460,6 +12704,8 @@ snapshots:
detect-libc@2.0.4: {}
detect-libc@2.1.1: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
@@ -13010,7 +13256,7 @@ snapshots:
eslint@9.17.0(jiti@2.4.1):
dependencies:
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@2.4.1))
'@eslint-community/eslint-utils': 4.7.0(eslint@9.17.0(jiti@2.4.1))
'@eslint-community/regexpp': 4.12.1
'@eslint/config-array': 0.19.1
'@eslint/core': 0.9.1
@@ -13020,7 +13266,7 @@ snapshots:
'@humanfs/node': 0.16.6
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.1
'@types/estree': 1.0.6
'@types/estree': 1.0.8
'@types/json-schema': 7.0.15
ajv: 6.12.6
chalk: 4.1.2
@@ -13028,7 +13274,7 @@ snapshots:
debug: 4.4.1
escape-string-regexp: 4.0.0
eslint-scope: 8.2.0
eslint-visitor-keys: 4.2.0
eslint-visitor-keys: 4.2.1
espree: 10.3.0
esquery: 1.5.0
esutils: 2.0.3
@@ -13036,7 +13282,7 @@ snapshots:
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.1
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
@@ -13057,9 +13303,9 @@ snapshots:
espree@10.3.0:
dependencies:
acorn: 8.14.1
acorn-jsx: 5.3.2(acorn@8.14.1)
eslint-visitor-keys: 4.2.0
acorn: 8.15.0
acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 4.2.1
esprima@4.0.1: {}
@@ -14978,7 +15224,7 @@ snapshots:
normalize-package-data@6.0.2:
dependencies:
hosted-git-info: 7.0.2
semver: 7.6.3
semver: 7.7.2
validate-npm-package-license: 3.0.4
normalize-path@3.0.0: {}
@@ -15547,7 +15793,7 @@ snapshots:
'@babel/runtime': 7.27.1
react: 18.2.0
react-hook-form@7.48.2(react@18.2.0):
react-hook-form@7.63.0(react@18.2.0):
dependencies:
react: 18.2.0
@@ -15963,7 +16209,7 @@ snapshots:
semver-diff@4.0.0:
dependencies:
semver: 7.6.3
semver: 7.7.2
semver-regex@4.0.5: {}
@@ -16005,6 +16251,35 @@ snapshots:
setprototypeof@1.2.0: {}
sharp@0.34.4:
dependencies:
'@img/colour': 1.0.0
detect-libc: 2.1.1
semver: 7.7.2
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.4
'@img/sharp-darwin-x64': 0.34.4
'@img/sharp-libvips-darwin-arm64': 1.2.3
'@img/sharp-libvips-darwin-x64': 1.2.3
'@img/sharp-libvips-linux-arm': 1.2.3
'@img/sharp-libvips-linux-arm64': 1.2.3
'@img/sharp-libvips-linux-ppc64': 1.2.3
'@img/sharp-libvips-linux-s390x': 1.2.3
'@img/sharp-libvips-linux-x64': 1.2.3
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3
'@img/sharp-libvips-linuxmusl-x64': 1.2.3
'@img/sharp-linux-arm': 0.34.4
'@img/sharp-linux-arm64': 0.34.4
'@img/sharp-linux-ppc64': 0.34.4
'@img/sharp-linux-s390x': 0.34.4
'@img/sharp-linux-x64': 0.34.4
'@img/sharp-linuxmusl-arm64': 0.34.4
'@img/sharp-linuxmusl-x64': 0.34.4
'@img/sharp-wasm32': 0.34.4
'@img/sharp-win32-arm64': 0.34.4
'@img/sharp-win32-ia32': 0.34.4
'@img/sharp-win32-x64': 0.34.4
shebang-command@1.2.0:
dependencies:
shebang-regex: 1.0.0
@@ -16076,7 +16351,7 @@ snapshots:
simple-update-notifier@2.0.0:
dependencies:
semver: 7.5.4
semver: 7.7.2
skin-tone@2.0.0:
dependencies:

View File

@@ -22,12 +22,13 @@
"kysely": "dotenv -e .env.development -- kysely",
"preinstall": "npx only-allow pnpm",
"run-fixer": "dotenv -e .env.development -- tsx src/index.ts fixer",
"test:watch": "vitest --watch",
"test": "vitest --run",
"test:watch": "vitest --typecheck.tsconfig tsconfig.test.json --watch",
"test": "vitest --typecheck.tsconfig tsconfig.test.json --run",
"tunarr": "dotenv -e .env.development -- tsx src/index.ts",
"typecheck": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.build.json --noEmit"
},
"dependencies": {
"@cospired/i18n-iso-languages": "^4.2.0",
"@dotenvx/dotenvx": "^1.49.0",
"@fastify/cors": "^10.1.0",
"@fastify/error": "^4.2.0",
@@ -47,6 +48,7 @@
"axios": "^1.11.0",
"base32": "^0.0.7",
"better-sqlite3": "11.8.1",
"blurhash": "^2.0.5",
"chalk": "^5.6.0",
"cron-parser": "^4.9.0",
"dayjs": "^1.11.14",
@@ -77,6 +79,7 @@
"random-js": "2.1.0",
"reflect-metadata": "^0.2.2",
"retry": "^0.13.1",
"sharp": "^0.34.4",
"split2": "^4.2.0",
"ts-pattern": "^5.8.0",
"tslib": "^2.8.1",

View File

@@ -14,12 +14,14 @@ import { TranscodeConfigDB } from './db/TranscodeConfigDB.ts';
import { ProgramConverter } from './db/converters/ProgramConverter.ts';
import { MediaSourceDB } from './db/mediaSourceDB.ts';
import { DB } from './db/schema/db.ts';
import { DrizzleDBAccess } from './db/schema/index.ts';
import { MediaSourceApiFactory } from './external/MediaSourceApiFactory.ts';
import { IWorkerPool } from './interfaces/IWorkerPool.ts';
import { EventService } from './services/EventService.ts';
import { FileCacheService } from './services/FileCacheService.ts';
import { HdhrService } from './services/HDHRService.ts';
import { HealthCheckService } from './services/HealthCheckService.js';
import { ImageCache } from './services/ImageCache.ts';
import { M3uService } from './services/M3UService.ts';
import { MediaSourceLibraryRefresher } from './services/MediaSourceLibraryRefresher.ts';
import { MeilisearchService } from './services/MeilisearchService.ts';
@@ -70,6 +72,9 @@ export class ServerContext {
@inject(KEYS.DatabaseFactory)
public readonly databaseFactory!: interfaces.AutoFactory<Kysely<DB>>;
@inject(KEYS.DrizzleDatabaseFactory)
public readonly drizzleFactory!: interfaces.AutoFactory<DrizzleDBAccess>;
@inject(KEYS.WorkerPool)
public readonly workerPool: IWorkerPool;
@@ -77,10 +82,13 @@ export class ServerContext {
public readonly searchService!: MeilisearchService;
@inject(MediaSourceScanCoordinator)
public readonly mediaSourceScanCoordinator: MediaSourceScanCoordinator;
public readonly mediaSourceScanCoordinator!: MediaSourceScanCoordinator;
@inject(MediaSourceLibraryRefresher)
public readonly mediaSourceLibraryRefresher: MediaSourceLibraryRefresher;
public readonly mediaSourceLibraryRefresher!: MediaSourceLibraryRefresher;
@inject(ImageCache)
public readonly imageCache!: ImageCache;
}
export class ServerRequestContext {

View File

@@ -70,7 +70,10 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
id: customShow.uuid,
name: customShow.name,
contentCount: customShow.customShowContent.length,
totalDuration: sumBy(customShow.customShowContent, (c) => c.duration),
totalDuration: sumBy(
customShow.customShowContent,
(c) => c.duration ?? 0,
),
});
},
);
@@ -102,7 +105,10 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
id: customShow.uuid,
name: customShow.name,
contentCount: customShow.customShowContent.length,
totalDuration: sumBy(customShow.customShowContent, (c) => c.duration),
totalDuration: sumBy(
customShow.customShowContent,
(c) => c.duration ?? 0,
),
});
},
);

View File

@@ -1,20 +1,9 @@
import { FfmpegStreamFactory } from '@/ffmpeg/FfmpegStreamFactory.js';
import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.js';
import { FfprobeStreamDetails } from '@/stream/FfprobeStreamDetails.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { tag } from '@tunarr/types';
import dayjs from 'dayjs';
import { z } from 'zod/v4';
import { container } from '../../container.ts';
import type { ContentBackedStreamLineupItem } from '../../db/derived_types/StreamLineup.ts';
import { isContentBackedLineupItem } from '../../db/derived_types/StreamLineup.ts';
import type { FFmpegFactory } from '../../ffmpeg/FFmpegModule.ts';
import type { FfmpegEncoder } from '../../ffmpeg/ffmpegInfo.ts';
import { FfmpegInfo } from '../../ffmpeg/ffmpegInfo.ts';
import { ExternalStreamDetailsFetcherFactory } from '../../stream/StreamDetailsFetcher.ts';
import type { ProgramStreamResult } from '../../stream/types.ts';
import { KEYS } from '../../types/inject.ts';
import type { Nullable } from '../../types/util.ts';
export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
fastify,
@@ -56,118 +45,4 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
),
});
});
fastify.get(
'/ffmpeg/pipeline',
{
schema: {
tags: ['Debug'],
querystring: z.object({
channel: z.coerce.number().or(z.string()),
path: z.string().optional(),
}),
},
},
async (req, res) => {
const channel = await req.serverCtx.channelDB.getChannel(
req.query.channel,
);
if (!channel) {
return res.status(404).send();
}
const transcodeConfig =
await req.serverCtx.transcodeConfigDB.getChannelConfig(channel.uuid);
let streamDetails: Nullable<ProgramStreamResult>;
let lineupItem: ContentBackedStreamLineupItem;
if (req.query.path) {
streamDetails = await container
.get<FfprobeStreamDetails>(FfprobeStreamDetails)
.getStream({ path: req.query.path });
lineupItem = {
duration: +dayjs.duration({ seconds: 30 }),
contentDuration: +dayjs.duration({ seconds: 30 }),
infiniteLoop: false,
streamDuration: +dayjs.duration({ seconds: 30 }),
externalKey: 'none',
externalSource: 'emby',
externalSourceId: tag('none'),
programBeginMs: 0,
programId: '',
programType: 'movie',
type: 'program',
title: req.query.path,
};
} else {
const lineupItemResult =
await req.serverCtx.streamProgramCalculator.getCurrentLineupItem({
allowSkip: false,
channelId: channel.uuid,
startTime: +dayjs(),
});
if (lineupItemResult.isFailure()) {
return res.status(500).send();
}
const item = lineupItemResult.get().lineupItem;
if (!isContentBackedLineupItem(item)) {
return res.status(500).send();
}
const server = await req.serverCtx.mediaSourceDB.getById(
item.externalSourceId,
);
if (!server) {
return res
.status(500)
.send('No server id = ' + item.externalSourceId);
}
lineupItem = item;
streamDetails = await container
.get<ExternalStreamDetailsFetcherFactory>(
ExternalStreamDetailsFetcherFactory,
)
.getStream({
lineupItem: {
...item,
externalFilePath: item.plexFilePath ?? undefined,
},
server,
});
}
if (!streamDetails) {
return res.status(500).send();
}
const ffmpeg = container.getNamed<FFmpegFactory>(
KEYS.FFmpegFactory,
FfmpegStreamFactory.name,
)(transcodeConfig, channel, channel.streamMode);
const session = await ffmpeg.createStreamSession({
stream: {
details: streamDetails.streamDetails,
source: streamDetails.streamSource,
},
lineupItem,
options: {
duration: dayjs.duration({ seconds: 30 }),
outputFormat: MpegTsOutputFormat,
realtime: false,
startTime: dayjs.duration(0),
watermark: channel.watermark ?? undefined,
streamMode: channel.streamMode,
},
});
return res.send({
args: session?.process.args.join(' '),
});
},
);
};

View File

@@ -1,4 +1,5 @@
import { container } from '@/container.js';
import { MediaSourceType } from '@/db/schema/base.js';
import type { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
import { JellyfinItemFinder } from '@/external/jellyfin/JellyfinItemFinder.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
@@ -7,7 +8,6 @@ import { tag } from '@tunarr/types';
import { isNil } from 'lodash-es';
import { v4 } from 'uuid';
import { z } from 'zod/v4';
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
import type { MediaSourceApiClientFactory } from '../../external/MediaSourceApiClient.ts';
import { KEYS } from '../../types/inject.ts';
@@ -40,6 +40,8 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
username: null,
libraries: [],
type: 'jellyfin',
mediaType: null,
paths: [],
},
});
@@ -78,6 +80,8 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
username: null,
libraries: [],
type: 'jellyfin',
mediaType: null,
paths: [],
},
});

View File

@@ -37,25 +37,20 @@ export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
if (!program) {
return res.status(400).send('No program');
} else if (!program.mediaSourceId) {
return res.status(400).send('No media source ID');
} else if (program.sourceType !== 'plex') {
return res.status(400).send('Not a plex item');
}
const contentProgram =
req.serverCtx.programConverter.programDaoToContentProgram(program);
if (!contentProgram) {
return res.status(500).send();
}
const mediaSourceId = program.mediaSourceId;
const streamDetails = await container.get(PlexStreamDetails).getStream({
server: mediaSource,
lineupItem: {
...contentProgram,
programId: contentProgram.id,
externalKey: req.query.key,
programType: contentProgram.subtype,
externalSource: 'plex',
duration: contentProgram.duration,
externalFilePath: contentProgram.serverFilePath,
...program,
sourceType: 'plex',
mediaSourceId,
},
});

View File

@@ -1,10 +1,8 @@
import { container } from '@/container.js';
import type { ProgramStreamLineupItem } from '@/db/derived_types/StreamLineup.js';
import type { StreamLineupItem } from '@/db/derived_types/StreamLineup.js';
import { createOfflineStreamLineupItem } from '@/db/derived_types/StreamLineup.js';
import type { Channel } from '@/db/schema/Channel.js';
import { AllChannelTableKeys } from '@/db/schema/Channel.js';
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import type { ProgramDao } from '@/db/schema/Program.js';
import { ProgramType } from '@/db/schema/Program.js';
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import { AllTranscodeConfigColumns } from '@/db/schema/TranscodeConfig.js';
@@ -17,7 +15,9 @@ import dayjs from '@/util/dayjs.js';
import { jsonObjectFrom } from 'kysely/helpers/sqlite';
import { isNumber, isUndefined, nth, random } from 'lodash-es';
import { PassThrough } from 'node:stream';
import type { MarkRequired } from 'ts-essentials';
import { z } from 'zod/v4';
import type { ProgramWithRelationsOrm } from '../../db/schema/derivedTypes.ts';
import type { ProgramStreamFactory } from '../../stream/ProgramStreamFactory.ts';
import { isNonEmptyString } from '../../util/index.ts';
@@ -142,13 +142,17 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
fastify.get('/streams/random', async (req, res) => {
const program = await req.serverCtx
.databaseFactory()
.selectFrom('program')
.orderBy((ob) => ob.fn('random'))
.where('type', '=', ProgramType.Episode)
.limit(1)
.selectAll()
.executeTakeFirstOrThrow();
.drizzleFactory()
.query.program.findFirst({
where: (fields, ops) => ops.eq(fields.type, ProgramType.Episode),
orderBy: (_, { sql }) => sql`random()`,
with: {
externalIds: true,
},
});
if (!program) {
return res.status(404).send();
}
const channels = await req.serverCtx
.databaseFactory()
@@ -279,12 +283,23 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
);
async function initStream(
program: ProgramDao,
program: MarkRequired<ProgramWithRelationsOrm, 'externalIds'>,
channel: Channel,
transcodeConfig: TranscodeConfig,
startTime: number = 0,
) {
const lineupItem = createStreamItemFromProgram(program);
if (!program.mediaSourceId) {
throw new Error('');
}
const mediaSourceId = program.mediaSourceId;
const lineupItem: StreamLineupItem = {
type: 'program',
program: { ...program, mediaSourceId },
duration: program.duration,
infiniteLoop: false,
programBeginMs: +dayjs(),
streamDuration: program.duration,
};
lineupItem.startOffset = startTime;
const ctx = new PlayerContext(
lineupItem,
@@ -308,23 +323,3 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
return out;
}
};
function createStreamItemFromProgram(
program: ProgramDao,
): ProgramStreamLineupItem {
return {
...program,
type: 'program',
programType: program.type,
programId: program.uuid,
// HACK
externalSource: z.nativeEnum(MediaSourceType).parse(program.sourceType),
plexFilePath: program.plexFilePath ?? undefined,
filePath: program.filePath ?? undefined,
programBeginMs: +dayjs(),
contentDuration: program.duration,
streamDuration: program.duration,
infiniteLoop: false,
externalSourceId: program.mediaSourceId!,
};
}

View File

@@ -1,21 +1,18 @@
import { DebugPlexApiRouter } from '@/api/debug/debugPlexApi.js';
import type { ArchiveDatabaseBackupFactory } from '@/db/backup/ArchiveDatabaseBackup.js';
import { ArchiveDatabaseBackupKey } from '@/db/backup/ArchiveDatabaseBackup.js';
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import { LineupCreator } from '@/services/dynamic_channels/LineupCreator.js';
import { PlexTaskQueue } from '@/tasks/TaskQueue.js';
import { SavePlexProgramExternalIdsTask } from '@/tasks/plex/SavePlexProgramExternalIdsTask.js';
import { DateTimeRange } from '@/types/DateTimeRange.js';
import { OpenDateTimeRange } from '@/types/OpenDateTimeRange.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { enumValues } from '@/util/enumUtil.js';
import { ifDefined } from '@/util/index.js';
import { tag } from '@tunarr/types';
import { ChannelLineupQuery } from '@tunarr/types/api';
import { ChannelLineupSchema } from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
import { isUndefined, map, reject, some } from 'lodash-es';
import { isUndefined } from 'lodash-es';
import os from 'node:os';
import z from 'zod/v4';
import { container } from '../container.ts';
@@ -331,59 +328,6 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
},
);
fastify.get(
'/debug/db/test_direct_access',
{
schema: {
tags: ['Debug'],
querystring: z.object({
id: z.string(),
}),
},
},
async (req, res) => {
const mediaSource = (await req.serverCtx.mediaSourceDB.getById(
tag(req.query.id),
))!;
const knownProgramIds = await req.serverCtx
.databaseFactory()
.selectFrom('programExternalId as p1')
.where(({ eb }) =>
eb.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);
},
);
fastify.get(
'/debug/subprocess/status',
{
@@ -416,4 +360,33 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
return res.send(response);
},
);
fastify.get(
'/debug/media_sources/:mediaSourceId/scan',
{
schema: {
params: z.object({
mediaSourceId: z.uuid(),
}),
querystring: z.object({
pathFilter: z.string().optional(),
}),
},
},
async (req, res) => {
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
tag(req.params.mediaSourceId),
);
if (!mediaSource) {
return res.status(404).send();
}
const scanRes = await req.serverCtx.mediaSourceScanCoordinator.addLocal({
forceScan: true,
mediaSourceId: tag(req.params.mediaSourceId),
pathFilter: req.query.pathFilter,
});
return res.send(scanRes);
},
);
};

View File

@@ -1,4 +1,4 @@
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import { MediaSourceType } from '@/db/schema/base.js';
import { EmbyApiClient } from '@/external/emby/EmbyApiClient.js';
import { TruthyQueryParam } from '@/types/schemas.js';
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';

View File

@@ -1,12 +1,13 @@
import type { FfmpegEncoder } from '@/ffmpeg/ffmpegInfo.js';
import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.js';
import { serverOptions } from '@/globals.js';
import { globalOptions, serverOptions } from '@/globals.js';
import { GlobalScheduler } from '@/services/Scheduler.js';
import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { fileExists } from '@/util/fsUtil.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { getTunarrVersion } from '@/util/version.js';
import fpStatic from '@fastify/static';
import { VersionApiResponseSchema } from '@tunarr/types/api';
import { fileTypeFromStream } from 'file-type';
import { isEmpty } from 'lodash-es';
@@ -48,6 +49,11 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
});
await fastify
.register(fpStatic, {
root: globalOptions().databaseDirectory,
serve: false,
decorateReply: true,
})
.register(tasksApiRouter)
.register(channelsApi)
.register(customShowsApiV2)

View File

@@ -1,4 +1,4 @@
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import { MediaSourceType } from '@/db/schema/base.js';
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
import { mediaSourceParamsSchema, TruthyQueryParam } from '@/types/schemas.js';
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';

View File

@@ -1,10 +1,11 @@
import { GlobalScheduler } from '@/services/Scheduler.js';
import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { nullToUndefined } from '@/util/index.js';
import { nullToUndefined, run } from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { numberToBoolean } from '@/util/sqliteUtil.js';
import { seq } from '@tunarr/shared/util';
import type { LocalMediaSource } from '@tunarr/types';
import {
tag,
type MediaSourceLibrary,
@@ -25,12 +26,12 @@ import {
} from '@tunarr/types/api';
import {
ContentProgramSchema,
ExternalSourceTypeSchema,
MediaSourceLibrarySchema,
MediaSourceSettingsSchema,
} from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import { isEmpty, isError, isNil, isNull } from 'lodash-es';
import type { MarkOptional } from 'ts-essentials';
import type { MarkOptional, StrictExtract } from 'ts-essentials';
import { match, P } from 'ts-pattern';
import { v4 } from 'uuid';
import z from 'zod/v4';
@@ -40,6 +41,7 @@ import { EntityMutex } from '../services/EntityMutex.ts';
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
import { MediaSourceProgressService } from '../services/scanner/MediaSourceProgressService.ts';
import { TruthyQueryParam } from '../types/schemas.ts';
import { fileExists } from '../util/fsUtil.ts';
export const mediaSourceRouter: RouterPluginAsyncCallback = async (
fastify,
@@ -78,6 +80,41 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
},
);
fastify.get(
'/media-sources/:mediaSourceId',
{
schema: {
tags: ['Media Source'],
params: z.object({
mediaSourceId: z.uuid(),
}),
response: {
200: MediaSourceSettingsSchema,
404: z.void(),
500: z.string(),
},
},
},
async (req, res) => {
try {
const source = await req.serverCtx.mediaSourceDB.getById(
tag(req.params.mediaSourceId),
);
if (!source) {
return res.status(404).send();
}
const entityLocker = container.get<EntityMutex>(EntityMutex);
const dto = convertToApiMediaSource(entityLocker, source);
return res.send(dto);
} catch (err) {
logger.error(err);
return res.status(500).send('error');
}
},
);
fastify.get(
'/media-sources/:id/libraries',
{
@@ -86,6 +123,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
params: BasicIdParamSchema,
response: {
200: z.array(MediaSourceLibrarySchema),
400: z.void(),
404: z.void(),
500: z.string(),
},
@@ -100,6 +138,10 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
return res.status(404).send();
}
if (mediaSource.type === 'local') {
return res.status(400).send();
}
const entityLocker = container.get<EntityMutex>(EntityMutex);
const apiMediaSource = convertToApiMediaSource(entityLocker, mediaSource);
if (isNull(apiMediaSource)) {
@@ -108,20 +150,28 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
.send('Invalid media source type: ' + mediaSource.type);
}
return res.send(
mediaSource.libraries.map(
(library) =>
({
...library,
id: library.uuid,
type: mediaSource.type,
enabled: numberToBoolean(library.enabled),
lastScannedAt: nullToUndefined(library.lastScannedAt),
isLocked: entityLocker.isLibraryLocked(library),
mediaSource: apiMediaSource,
}) satisfies MediaSourceLibrary,
),
const libraries = mediaSource.libraries.map(
(library) =>
({
...library,
id: library.uuid,
type: mediaSource.type,
enabled: library.enabled,
lastScannedAt: library.lastScannedAt
? +dayjs(library.lastScannedAt)
: undefined,
isLocked:
entityLocker.isLibraryLocked(library) ||
entityLocker.isMediaSourceLocked(mediaSource),
mediaSource: apiMediaSource,
}) satisfies MediaSourceLibrary,
);
// const localLibraries = mediaSource.paths.map(path => ({
// id: path.
// } satisfies MediaSourceLibrary))
return res.send(libraries);
},
);
@@ -136,7 +186,8 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
body: UpdateMediaSourceLibraryRequest,
response: {
200: MediaSourceLibrarySchema,
404: z.void(),
400: z.string(),
404: z.string(),
500: z.string(),
},
},
@@ -147,7 +198,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
);
if (!mediaSource) {
return res.status(404).send();
return res
.status(404)
.send(`Media source with ID ${req.params.id} not found`);
}
if (mediaSource.type === 'local') {
return res
.status(400)
.send('Local media sources do not support libraries.');
}
const entityLocker = container.get(EntityMutex);
@@ -184,7 +243,9 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
type: mediaSource.type,
enabled: numberToBoolean(updatedLibrary.enabled),
lastScannedAt: nullToUndefined(updatedLibrary.lastScannedAt),
isLocked: entityLocker.isLibraryLocked(updatedLibrary),
isLocked:
entityLocker.isLibraryLocked(updatedLibrary) ||
entityLocker.isMediaSourceLocked(mediaSource),
mediaSource: apiMediaSource,
});
},
@@ -218,19 +279,18 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
const entityLocker = container.get<EntityMutex>(EntityMutex);
return res.send({
...library,
// ...library,
id: library.uuid,
type: library.mediaSource.type,
enabled: numberToBoolean(library.enabled),
lastScannedAt: nullToUndefined(library.lastScannedAt),
isLocked: entityLocker.isLibraryLocked(library),
mediaSource: convertToApiMediaSource(
entityLocker,
library.mediaSource,
)!,
// TODO this is dumb
} satisfies MediaSourceLibrary & {
mediaSource: MediaSourceSettings;
enabled: library.enabled,
lastScannedAt: library.lastScannedAt?.valueOf(),
isLocked:
entityLocker.isLibraryLocked(library) ||
entityLocker.isMediaSourceLocked(library.mediaSource),
name: library.name,
mediaType: library.mediaType,
externalKey: library.externalKey,
mediaSource: convertToApiMediaSource(entityLocker, library.mediaSource),
});
},
);
@@ -275,14 +335,16 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
);
fastify.get(
'/media-libraries/:libraryId/status',
'/media-sources/:mediaSourceId/:libraryId/status',
{
schema: {
params: z.object({
mediaSourceId: z.string(),
libraryId: z.string(),
}),
response: {
200: ScanProgressSchema,
404: z.void(),
},
},
},
@@ -290,8 +352,27 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
const progressService = container.get<MediaSourceProgressService>(
MediaSourceProgressService,
);
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
tag(req.params.mediaSourceId),
);
const progress = progressService.getScanProgress(req.params.libraryId);
if (!mediaSource) {
return res.status(404).send();
}
if (req.params.libraryId !== 'all') {
const lib = mediaSource.libraries.find(
(lib) => lib.uuid === req.params.libraryId,
);
if (!lib) {
return res.status(404).send();
}
}
const progress =
req.params.libraryId === 'all'
? progressService.getScanProgress(req.params.mediaSourceId)
: progressService.getScanProgress(req.params.libraryId);
const response = match(progress)
.returnType<ScanProgress>()
@@ -338,6 +419,35 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
},
);
fastify.post(
'/media-sources/:id/scan',
{
schema: {
tags: ['Media Source'],
params: BasicIdParamSchema.extend({
libraryId: z.string(),
}),
querystring: z.object({
forceScan: TruthyQueryParam.optional(),
}),
response: {
202: z.void(),
404: z.void(),
501: z.void(),
},
},
},
async (req, res) => {
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
tag(req.params.id),
);
if (!mediaSource) {
return res.status(404).send();
}
},
);
fastify.post(
'/media-sources/:id/libraries/:libraryId/scan',
{
@@ -377,17 +487,30 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
return res.status(501);
}
for (const library of libraries) {
const result = await req.serverCtx.mediaSourceScanCoordinator.add({
libraryId: library.uuid,
if (mediaSource.type === 'local') {
const result = await req.serverCtx.mediaSourceScanCoordinator.addLocal({
forceScan: !!req.query.forceScan,
mediaSourceId: mediaSource.uuid,
});
if (!result) {
logger.error(
'Unable to schedule library ID %s for scanning',
library.uuid,
'Unable to schedule local media source ID %s for scanning',
mediaSource.uuid,
);
}
} else {
for (const library of libraries) {
const result = await req.serverCtx.mediaSourceScanCoordinator.add({
libraryId: library.uuid,
forceScan: !!req.query.forceScan,
});
if (!result) {
logger.error(
'Unable to schedule library ID %s for scanning',
library.uuid,
);
}
}
}
return res.status(202).send();
@@ -420,6 +543,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
}
const healthyPromise = match(server)
.returnType<Promise<MediaSourceStatus>>()
.with({ type: 'plex' }, async (server) => {
return (
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
@@ -441,6 +565,21 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
)
).ping();
})
.with({ type: 'local' }, async (source) => {
// TODO: Check all paths.
let ok = true;
for (const mediaPath of source.paths) {
ok &&= await fileExists(mediaPath.path);
if (!ok) {
break;
}
}
if (ok) {
return { healthy: true };
} else {
return { healthy: false, status: 'unreachable' };
}
})
.exhaustive();
const status = await Promise.race([
@@ -465,13 +604,20 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
{
schema: {
tags: ['Media Source'],
body: z.object({
name: z.string().optional(),
accessToken: z.string(),
uri: z.string(),
type: ExternalSourceTypeSchema,
username: z.string().optional(),
}),
body: z
.object({
name: z.string().optional(),
accessToken: z.string(),
uri: z.string(),
type: z.enum(['plex', 'jellyfin', 'emby']),
username: z.string().optional(),
})
.or(
z.object({
type: z.literal('local'),
paths: z.string().array().nonempty(),
}),
),
response: {
200: MediaSourceStatusSchema,
404: z.void(),
@@ -493,6 +639,8 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
name: tag(req.body.name ?? 'unknown'),
uuid: tag(v4()),
libraries: [],
paths: [],
mediaType: null,
},
});
@@ -510,6 +658,8 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
name: tag(req.body.name ?? 'unknown'),
uuid: tag(v4()),
libraries: [],
paths: [],
mediaType: null,
},
});
@@ -527,12 +677,32 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
name: tag(req.body.name ?? 'unknown'),
uuid: tag(v4()),
libraries: [],
paths: [],
mediaType: null,
},
});
healthyPromise = emby.ping();
break;
}
case 'local': {
// TODO: Check all paths.
const paths = req.body.paths;
healthyPromise = run<Promise<MediaSourceStatus>>(async () => {
let ok = true;
for (const mediaPath of paths) {
ok &&= await fileExists(mediaPath);
if (!ok) {
break;
}
}
if (ok) {
return { healthy: true };
} else {
return { healthy: false, status: 'unreachable' };
}
});
}
}
const status = await Promise.race([
@@ -717,10 +887,10 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
// TODO put this in its own class.
function convertToApiMediaSource(
entityLocker: EntityMutex,
source: MarkOptional<MediaSourceWithLibraries, 'libraries'>,
): MediaSourceSettings | null {
source: MarkOptional<MediaSourceWithLibraries, 'libraries' | 'paths'>,
): MediaSourceSettings {
return match(source)
.returnType<MediaSourceSettings | null>()
.returnType<MediaSourceSettings>()
.with(
{ type: P.union('plex', 'jellyfin', 'emby') },
(source) =>
@@ -732,20 +902,53 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
name: source.name,
accessToken: source.accessToken,
clientIdentifier: nullToUndefined(source.clientIdentifier),
sendChannelUpdates: numberToBoolean(source.sendChannelUpdates),
sendGuideUpdates: numberToBoolean(source.sendGuideUpdates),
sendChannelUpdates: source.sendChannelUpdates ?? false,
sendGuideUpdates: source.sendGuideUpdates ?? false,
libraries: (source.libraries ?? []).map((library) => ({
...library,
id: library.uuid,
type: source.type,
enabled: numberToBoolean(library.enabled),
lastScannedAt: nullToUndefined(library.lastScannedAt),
isLocked: entityLocker.isLibraryLocked(library),
enabled: library.enabled,
lastScannedAt: nullToUndefined(library.lastScannedAt)?.valueOf(),
isLocked:
entityLocker.isLibraryLocked(library) ||
entityLocker.isMediaSourceLocked(source),
name: library.name,
externalKey: library.externalKey,
mediaType: library.mediaType,
})),
userId: source.userId,
username: source.username,
}) satisfies MediaSourceSettings,
}) satisfies StrictExtract<
MediaSourceSettings,
{ type: 'plex' | 'jellyfin' | 'emby' }
>,
)
.otherwise(() => null);
.with(
{ type: 'local', mediaType: P.nonNullable },
(source) =>
({
id: source.uuid,
type: source.type,
name: source.name,
mediaType: source.mediaType,
paths: source.libraries?.map((path) => path.externalKey) ?? [],
libraries: (source.libraries ?? []).map((library) => ({
id: library.uuid,
type: source.type,
enabled: library.enabled,
lastScannedAt: nullToUndefined(library.lastScannedAt)?.valueOf(),
isLocked:
entityLocker.isLibraryLocked(library) ||
entityLocker.isMediaSourceLocked(source),
name: library.name,
externalKey: library.externalKey,
mediaType: library.mediaType,
})),
}) satisfies LocalMediaSource,
)
.otherwise(() => {
logger.error('Encountered invalid media source: %O', source);
throw new Error('Invalid media source: ' + JSON.stringify(source));
});
}
};

View File

@@ -23,7 +23,7 @@ import {
ProgramSourceType,
programSourceTypeFromString,
} from '../db/custom_types/ProgramSourceType.ts';
import type { MediaSourceId } from '../db/schema/base.ts';
import type { MediaSourceId } from '../db/schema/base.js';
import { getServerContext } from '../ServerContext.ts';
const externalIdSchema = z

View File

@@ -1,3 +1,4 @@
import { MediaSourceType } from '@/db/schema/base.js';
import { tag, type Library } from '@tunarr/types';
import { PagedResult } from '@tunarr/types/api';
import {
@@ -16,7 +17,6 @@ import { isNil } from 'lodash-es';
import { z } from 'zod/v4';
import type { PageParams } from '../db/interfaces/IChannelDB.ts';
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
import { MediaSourceType } from '../db/schema/MediaSource.ts';
import { ServerRequestContext } from '../ServerContext.ts';
import { mediaSourceParamsSchema } from '../types/schemas.ts';
import type {

View File

@@ -1,14 +1,11 @@
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import type {
MediaSource,
MediaSourceLibrary,
MediaSourceLibraryOrm,
MediaSourceOrm,
} from '@/db/schema/MediaSource.js';
import { ProgramType } from '@/db/schema/Program.js';
import type { ProgramGrouping as ProgramGroupingDao } from '@/db/schema/ProgramGrouping.js';
import {
AllProgramGroupingFields,
ProgramGroupingType,
} from '@/db/schema/ProgramGrouping.js';
import { ProgramGroupingType } from '@/db/schema/ProgramGrouping.js';
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
import { PagingParams, TruthyQueryParam } from '@/types/schemas.js';
@@ -42,6 +39,7 @@ import {
} from '@tunarr/types/api';
import {
ContentProgramSchema,
ProgramGroupingSchema,
TerminalProgramSchema,
} from '@tunarr/types/schemas';
import axios, { AxiosHeaders, isAxiosError } from 'axios';
@@ -58,6 +56,7 @@ import {
isUndefined,
map,
omitBy,
trimStart,
values,
} from 'lodash-es';
import type stream from 'node:stream';
@@ -69,13 +68,19 @@ import {
programSourceTypeFromString,
} from '../db/custom_types/ProgramSourceType.ts';
import type { ProgramGroupingChildCounts } from '../db/interfaces/IProgramDB.ts';
import { AllProgramFields } from '../db/programQueryHelpers.ts';
import type { MediaSourceId } from '../db/schema/base.ts';
import {
AllProgramFields,
AllProgramGroupingFields,
} from '../db/programQueryHelpers.ts';
import type { Artwork } from '../db/schema/Artwork.ts';
import { ArtworkTypes } from '../db/schema/Artwork.ts';
import type { MediaSourceId } from '../db/schema/base.js';
import type {
MediaSourceWithLibraries,
ProgramWithRelations,
ProgramWithRelationsOrm,
} from '../db/schema/derivedTypes.js';
import type { DrizzleDBAccess } from '../db/schema/index.ts';
import { globalOptions } from '../globals.ts';
import type {
ProgramGroupingSearchDocument,
ProgramSearchDocument,
@@ -127,19 +132,19 @@ function isProgramGroupingDocument(
function convertProgramSearchResult(
doc: TerminalProgramSearchDocument,
program: ProgramWithRelations,
program: ProgramWithRelationsOrm,
mediaSource: MediaSourceWithLibraries,
mediaLibrary: MediaSourceLibrary,
mediaLibrary: MediaSourceLibraryOrm,
): TerminalProgram {
if (!program.canonicalId) {
throw new Error('');
throw new Error('Program did not have a canonical ID');
}
const externalId = doc.externalIds.find(
(eid) => eid.source === mediaSource.type,
)?.id;
if (!externalId) {
throw new Error('');
if (!externalId && program.sourceType !== 'local') {
throw new Error('No external Id found');
}
const base = {
@@ -150,8 +155,9 @@ function convertProgramSearchResult(
releaseDateString: doc.originalReleaseDate
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
: null,
externalId,
externalId: externalId ?? program.externalKey,
sourceType: mediaSource.type,
sortTitle: '',
};
const identifiers = doc.externalIds.map((eid) => ({
@@ -188,6 +194,7 @@ function convertProgramSearchResult(
identifiers,
episodeNumber: ep.index ?? 0,
canonicalId: program.canonicalId!,
sortTitle: '',
// mediaItem: {
// displayAspectRatio: '',
// duration: doc.duration,
@@ -248,7 +255,10 @@ function convertProgramSearchResult(
.otherwise(() => null);
if (!result) {
throw new Error('');
throw new Error(
'Could not convert program result for incoming document: ' +
JSON.stringify(doc),
);
}
return result;
@@ -259,10 +269,10 @@ function convertProgramGroupingSearchResult(
grouping: ProgramGroupingDao,
childCounts: Maybe<ProgramGroupingChildCounts>,
mediaSource: MediaSourceWithLibraries,
mediaLibrary: MediaSourceLibrary,
mediaLibrary: MediaSourceLibraryOrm,
) {
if (!grouping.canonicalId) {
throw new Error('');
throw new Error(`No canonical id for grouping ${grouping.uuid}`);
}
const childCount = childCounts?.childCount;
@@ -282,11 +292,13 @@ function convertProgramGroupingSearchResult(
const externalId = doc.externalIds.find(
(eid) => eid.source === mediaSource.type,
)?.id;
if (!externalId) {
if (!externalId && mediaSource.type !== 'local') {
throw new Error('');
}
const base = {
sortTitle: '',
mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
externalLibraryId: mediaLibrary.externalKey,
@@ -294,7 +306,7 @@ function convertProgramGroupingSearchResult(
releaseDateString: doc.originalReleaseDate
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
: null,
externalId,
externalId: externalId ?? grouping.externalKey ?? '',
sourceType: mediaSource.type,
};
@@ -390,6 +402,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
offset: req.body.page ?? 1,
limit: req.body.limit ?? 20,
},
mediaSourceId: req.body.mediaSourceId,
libraryId: req.body.libraryId,
// TODO not a great cast...
restrictSearchTo: req.body.query
@@ -427,6 +440,8 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
req.serverCtx.programDB.getProgramGroupingChildCounts(groupingIds),
]);
console.log(groupings, groupingIds);
const results = seq.collect(result.hits, (program) => {
const mediaSourceId = decodeCaseSensitiveId(program.mediaSourceId);
const mediaSource = allMediaSourcesById[mediaSourceId];
@@ -495,7 +510,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
if (program) {
return res.send(
compact([
req.serverCtx.programConverter.convertProgramWithExternalIds(
req.serverCtx.programConverter.programOrmToContentProgram(
program,
),
]),
@@ -512,7 +527,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
);
const apiPrograms = seq.collect(programs, (program) =>
req.serverCtx.programConverter.convertProgramWithExternalIds(program),
req.serverCtx.programConverter.programOrmToContentProgram(program),
);
return res.send(apiPrograms);
@@ -528,7 +543,8 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
}),
querystring: z.object({
facetQuery: z.string().optional(),
libraryId: z.string().uuid().optional(),
mediaSourceId: z.uuid().optional(),
libraryId: z.uuid().optional(),
}),
response: {
200: z.object({
@@ -543,6 +559,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
{
facetQuery: req.query.facetQuery,
facetName: req.params.facetName,
mediaSourceId: req.query.mediaSourceId,
libraryId: req.query.libraryId,
},
);
@@ -627,6 +644,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
with: {
mediaStreams: true,
chapters: true,
mediaFiles: true,
},
},
},
@@ -649,6 +667,126 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
},
);
fastify.get(
'/program_groupings/:id',
{
schema: {
tags: ['Programs'],
params: BasicIdParamSchema,
response: {
200: ProgramGroupingSchema,
404: z.void(),
500: z.void(),
},
},
},
async (req, res) => {
const db = container.get<DrizzleDBAccess>(KEYS.DrizzleDB);
const dbRes = await db.query.programGrouping.findFirst({
where: (program, { eq }) => eq(program.uuid, req.params.id),
with: {
show: true,
artist: true,
externalIds: true,
artwork: true,
},
});
if (!dbRes) {
return res.status(404).send();
}
const groupingCounts =
await req.serverCtx.programDB.getProgramGroupingChildCounts([
dbRes.uuid,
]);
const searchDoc = await req.serverCtx.searchService.getProgram(
dbRes.uuid,
);
if (!searchDoc || !isProgramGroupingDocument(searchDoc)) {
return res.status(404).send();
}
const mediaSourceId = decodeCaseSensitiveId(searchDoc.mediaSourceId);
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
tag(mediaSourceId),
);
if (!mediaSource) {
return;
}
const libraryId = decodeCaseSensitiveId(searchDoc.libraryId);
const library = await req.serverCtx.mediaSourceDB.getLibrary(libraryId);
if (!library) {
return;
}
if (dbRes.canonicalId) {
const converted = convertProgramGroupingSearchResult(
searchDoc,
dbRes,
groupingCounts[dbRes.uuid],
mediaSource,
library,
);
if (!converted) {
return res.status(404).send();
}
return res.send(converted);
}
},
);
fastify.get(
'/programs/:id/artwork/:artworkType',
{
schema: {
produces: ['image/jpeg', 'image/png'],
params: z.object({
id: z.uuid(),
// TODO: use API schema
artworkType: z.enum(ArtworkTypes),
}),
response: {
200: z.any(),
404: z.void(),
},
},
},
async (req, res) => {
let program: Maybe<{ artwork?: Artwork[] }> =
await req.serverCtx.programDB.getProgramById(req.params.id);
if (!program) {
program = await req.serverCtx.programDB.getProgramGrouping(
req.params.id,
);
if (!program) {
return res.status(404).send();
}
}
const art = program.artwork?.find(
(art) => art.artworkType === req.params.artworkType,
);
if (!art) {
return res.status(404).send();
}
const path = req.serverCtx.imageCache.getImagePath(
art.cachePath,
art.artworkType,
);
return res.sendFile(
trimStart(path.replace(globalOptions().databaseDirectory, ''), '/'),
{ contentType: true },
);
},
);
fastify.get(
'/programs/:id/stream_details',
{
@@ -669,6 +807,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
.status(404)
.send('Program has no associated media source ID');
}
const mediaSourceId = program.mediaSourceId;
const server = await req.serverCtx.mediaSourceDB.findByType(
program.sourceType,
@@ -690,15 +829,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
ExternalStreamDetailsFetcherFactory,
)
.getStream({
lineupItem: {
externalKey: program.externalKey,
externalSource: program.sourceType,
externalSourceId: program.mediaSourceId ?? program.externalSourceId,
duration: program.duration,
externalFilePath: program.plexFilePath ?? undefined,
programId: program.uuid,
programType: program.type,
},
lineupItem: { ...program, mediaSourceId },
server,
});
@@ -844,7 +975,10 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.status(404).send('ID not found');
}
const handleResult = async (mediaSource: MediaSource, result: string) => {
const handleResult = async (
mediaSource: MediaSourceOrm,
result: string,
) => {
if (req.query.method === 'proxy') {
try {
logger.debug('Proxying response to %s', result);
@@ -1144,7 +1278,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
}
const converted =
req.serverCtx.programConverter.programDaoToContentProgram(program);
req.serverCtx.programConverter.programOrmToContentProgram(program);
if (!converted) {
return res
@@ -1185,7 +1319,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.send(
groupByUniq(
seq.collect(results, (p) =>
req.serverCtx.programConverter.programDaoToContentProgram(p),
req.serverCtx.programConverter.programOrmToContentProgram(p),
),
(p) => p.id,
),

View File

@@ -240,13 +240,10 @@ export const systemApiRouter: RouterPluginAsyncCallback = async (
),
(
await Result.attemptAsync(() =>
new ChildProcessHelper().getStdout(
'nvidia-smi',
[],
true,
{},
false,
),
new ChildProcessHelper().getStdout('nvidia-smi', [], {
swallowError: true,
isPath: false,
}),
)
).either(identity, (err) => err.message),
]);

View File

@@ -1,3 +1,5 @@
import languages from '@cospired/i18n-iso-languages';
import en from '@cospired/i18n-iso-languages/langs/en.json' with { type: 'json' };
import fs from 'node:fs/promises';
import path from 'node:path';
import type { DeepPartial } from 'ts-essentials';
@@ -46,6 +48,8 @@ export async function bootstrapTunarr(
opts: GlobalOptions = globalOptions(),
initialSettings?: DeepPartial<SettingsFile>,
) {
languages.registerLocale(en);
const hasTunarrDb = await fileExists(opts.databaseDirectory);
if (!hasTunarrDb) {
RootLogger.info(

View File

@@ -50,6 +50,7 @@ import { LoadChannelCacheStartupTask } from './services/startup/LoadChannelCache
import { ScheduleJobsStartupTask } from './services/startup/ScheduleJobsStartupTask.ts';
import { SeedFfmpegInfoCache } from './services/startup/SeedFfmpegInfoCache.ts';
import { SeedSystemDevicesStartupTask } from './services/startup/SeedSystemDevicesStartupTask.ts';
import { StreamCacheMigratorStartupTask } from './services/startup/StreamCacheMigratorStartupTask.ts';
import { ChannelCache } from './stream/ChannelCache.ts';
import { FixerRunner } from './tasks/fixers/FixerRunner.ts';
import { ChildProcessHelper } from './util/ChildProcessHelper.ts';
@@ -142,6 +143,7 @@ const RootModule = new ContainerModule((bind) => {
bind(KEYS.StartupTask).to(FixerRunner).inSingletonScope();
bind(KEYS.StartupTask).to(GenerateGuideStartupTask).inSingletonScope();
bind(KEYS.StartupTask).to(LoadChannelCacheStartupTask).inSingletonScope();
bind(KEYS.StartupTask).to(StreamCacheMigratorStartupTask).inSingletonScope();
if (getBooleanEnvVar(USE_WORKER_POOL_ENV_VAR, false)) {
bind(KEYS.WorkerPool).toService(TunarrWorkerPool);

View File

@@ -41,7 +41,6 @@ import {
filter,
forEach,
groupBy,
identity,
isEmpty,
isNil,
isNull,
@@ -100,6 +99,7 @@ import {
import { SchemaBackedDbAdapter } from './json/SchemaBackedJsonDBAdapter.ts';
import { calculateStartTimeOffsets } from './lineupUtil.ts';
import {
AllProgramGroupingFields,
withFallbackPrograms,
withMusicArtistAlbums,
withProgramExternalIds,
@@ -112,15 +112,15 @@ import {
withTvShowSeasons,
} from './programQueryHelpers.ts';
import {
Channel,
ChannelUpdate,
NewChannel,
NewChannelFillerShow,
NewChannelProgram,
Channel as RawChannel,
} from './schema/Channel.ts';
import { NewChannelFillerShow } from './schema/ChannelFillerShow.ts';
import { NewChannelProgram } from './schema/ChannelPrograms.ts';
import { ProgramType } from './schema/Program.ts';
import {
AllProgramGroupingFields,
MinimalProgramGroupingFields,
ProgramGroupingType,
} from './schema/ProgramGrouping.ts';
@@ -130,12 +130,14 @@ import {
} from './schema/SubtitlePreferences.ts';
import { DB } from './schema/db.ts';
import {
ChannelOrmWithRelations,
ChannelWithPrograms,
ChannelWithRelations,
MusicArtistWithExternalIds,
ProgramWithRelations,
TvShowWithExternalIds,
} from './schema/derivedTypes.js';
import { DrizzleDBAccess } from './schema/index.ts';
// We use this to chunk super huge channel / program relation updates because
// of the way that mikro-orm generates these (e.g. "delete from XYZ where () or () ...").
@@ -235,6 +237,7 @@ export class ChannelDB implements IChannelDB {
@inject(KEYS.WorkerPoolFactory)
private workerPoolProvider: interfaces.AutoFactory<IWorkerPool>,
@inject(FileSystemService) private fileSystemService: FileSystemService,
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
) {}
async channelExists(channelId: string) {
@@ -251,10 +254,28 @@ export class ChannelDB implements IChannelDB {
id: string | number,
includeFiller: true,
): Promise<Maybe<MarkRequired<ChannelWithRelations, 'fillerShows'>>>;
getChannel(
async getChannel(
id: string | number,
includeFiller: boolean = false,
): Promise<Maybe<ChannelWithRelations>> {
// return this.drizzleDB.query.channels.findFirst({
// where: (fields, { eq }) => {
// if (isString(id)) {
// return eq(fields.uuid, id);
// } else {
// return eq(fields.number, id);
// }
// },
// with: {
// channelFillerShow: includeFiller
// ? {
// with: {
// filler: true,
// },
// }
// : undefined,
// },
// });
return this.db
.selectFrom('channel')
.$if(isString(id), (eb) => eb.where('channel.uuid', '=', id as string))
@@ -282,9 +303,43 @@ export class ChannelDB implements IChannelDB {
return ChannelQueryBuilder.createForIdOrNumber(this.db, id);
}
getChannelAndPrograms(
async getChannelAndPrograms(
uuid: string,
): Promise<Maybe<MarkRequired<ChannelOrmWithRelations, 'programs'>>> {
const channelsAndPrograms = await this.drizzleDB.query.channels.findFirst({
where: (fields, { eq }) => eq(fields.uuid, uuid),
with: {
channelPrograms: {
with: {
program: {
with: {
show: true,
season: true,
artist: true,
album: true,
externalIds: true,
},
},
},
},
},
orderBy: (fields, { asc }) => asc(fields.number),
});
if (channelsAndPrograms) {
return {
...channelsAndPrograms,
programs: channelsAndPrograms.channelPrograms.map(
({ program }) => program,
),
} satisfies MarkRequired<ChannelOrmWithRelations, 'programs'>;
}
return;
}
async getChannelAndProgramsOld(
uuid: string,
typeFilter?: ContentProgramType,
): Promise<ChannelWithPrograms | undefined> {
return this.db
.selectFrom('channel')
@@ -296,41 +351,35 @@ export class ChannelDB implements IChannelDB {
'channelPrograms.channelUuid',
)
.select((eb) =>
withPrograms(
eb,
{
joins: {
customShows: true,
tvShow: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
tvSeason: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
trackArtist: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
trackAlbum: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
},
withPrograms(eb, {
joins: {
customShows: true,
tvShow: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
tvSeason: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
trackArtist: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
trackAlbum: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
},
typeFilter
? (eb) => eb.where('program.type', '=', typeFilter)
: identity,
),
}),
)
.groupBy('channel.uuid')
.orderBy('channel.number asc')
@@ -839,7 +888,7 @@ export class ChannelDB implements IChannelDB {
}
}
getAllChannels(pageParams?: PageParams) {
getAllChannels(pageParams?: PageParams): Promise<Channel[]> {
return this.db
.selectFrom('channel')
.selectAll()
@@ -1266,7 +1315,7 @@ export class ChannelDB implements IChannelDB {
async loadChannelAndLineup(
channelId: string,
): Promise<{ channel: RawChannel; lineup: Lineup } | null> {
): Promise<{ channel: Channel; lineup: Lineup } | null> {
const channel = await this.getChannel(channelId);
if (isNil(channel)) {
return null;
@@ -1281,7 +1330,7 @@ export class ChannelDB implements IChannelDB {
async loadChannelWithProgamsAndLineup(
channelId: string,
): Promise<{ channel: ChannelWithPrograms; lineup: Lineup } | null> {
const channel = await this.getChannelAndPrograms(channelId);
const channel = await this.getChannelAndProgramsOld(channelId);
if (isNil(channel)) {
return null;
}
@@ -1302,7 +1351,7 @@ export class ChannelDB implements IChannelDB {
offset: number = 0,
limit: number = -1,
): Promise<ChannelProgramming | null> {
const channel = await this.getChannelAndPrograms(channelId);
const channel = await this.getChannelAndProgramsOld(channelId);
if (isNil(channel)) {
return null;
}
@@ -1668,7 +1717,7 @@ export class ChannelDB implements IChannelDB {
}
private async buildCondensedLineup(
channel: RawChannel,
channel: Channel,
dbProgramIds: Set<string>,
lineup: LineupItem[],
): Promise<{ lineup: CondensedChannelProgram[]; offsets: number[] }> {

View File

@@ -18,10 +18,8 @@ import {
AllProgramJoins,
withCustomShowPrograms,
} from './programQueryHelpers.ts';
import type {
NewCustomShow,
NewCustomShowContent,
} from './schema/CustomShow.ts';
import type { NewCustomShow } from './schema/CustomShow.ts';
import type { NewCustomShowContent } from './schema/CustomShowContent.ts';
import { DB } from './schema/db.ts';
@injectable()

View File

@@ -25,6 +25,9 @@ const DBModule = new ContainerModule((bind) => {
bind<interfaces.Factory<Kysely<DB>>>(KEYS.DatabaseFactory).toAutoFactory(
KEYS.Database,
);
bind<interfaces.Factory<DrizzleDBAccess>>(
KEYS.DrizzleDatabaseFactory,
).toAutoFactory(KEYS.DrizzleDB);
bind(KEYS.FillerListDB).to(FillerDB).inSingletonScope();
bind(ProgramDaoMinter).toSelf();

View File

@@ -0,0 +1,50 @@
import type { SQL } from 'drizzle-orm';
import { sql, type AnyColumn } from 'drizzle-orm';
type SQLExpression<T = unknown> =
| SQL<T>
| SQL.Aliased<T>
| AnyColumn<{ data: T }>;
export class SQLCaseWhen<T = never> {
cases: SQL<T>;
constructor(init?: SQL<T> | SQLCaseWhen<T>) {
// Clone the initial cases to enable re-use.
this.cases = init
? sql`${init instanceof SQLCaseWhen ? init.cases : init}`
: sql<T>`CASE`;
}
/**
* Add a case to the case expression.
*/
when<Then>(
whenExpr: SQLExpression,
thenExpr: SQLExpression<Then>,
): SQLCaseWhen<T | Then> {
this.cases.append(sql` WHEN ${whenExpr} THEN ${thenExpr}`);
return this;
}
/**
* Add the else clause to the case expression.
*/
else<Else>(elseExpr: SQLExpression<Else>): SQL<T | Else> {
return sql`${this.cases} ELSE ${elseExpr} END`;
}
/**
* Finish the case expression without an else clause, which will
* return `null` if no case matches.
*/
elseNull(): SQL<T | null> {
return sql`${this.cases} END`;
}
}
export function caseWhen<Then>(
whenExpr: SQLExpression,
thenExpr: SQLExpression<Then>,
) {
return new SQLCaseWhen().when(whenExpr, thenExpr);
}

View File

@@ -40,11 +40,9 @@ import {
} from './interfaces/IFillerListDB.ts';
import { createPendingProgramIndexMap } from './programHelpers.ts';
import { withFillerPrograms } from './programQueryHelpers.ts';
import { ChannelFillerShow } from './schema/Channel.ts';
import type {
NewFillerShow,
NewFillerShowContent,
} from './schema/FillerShow.ts';
import { ChannelFillerShow } from './schema/ChannelFillerShow.ts';
import type { NewFillerShow } from './schema/FillerShow.ts';
import type { NewFillerShowContent } from './schema/FillerShowContent.ts';
import { DB } from './schema/db.ts';
import type { ChannelFillerShowWithContent } from './schema/derivedTypes.ts';

View File

@@ -0,0 +1,162 @@
import { MediaSourceId } from '@tunarr/shared';
import { isNonEmptyString } from '@tunarr/shared/util';
import { eq } from 'drizzle-orm';
import { inject, injectable } from 'inversify';
import { head, isEmpty } from 'lodash-es';
import { v4 } from 'uuid';
import { KEYS } from '../types/inject.ts';
import { Maybe } from '../types/util.ts';
import { Artwork, NewArtwork } from './schema/Artwork.ts';
import { DrizzleDBAccess } from './schema/index.ts';
import {
LocalMediaFolder,
LocalMediaFolderOrm,
NewLocalMediaFolderOrm,
} from './schema/LocalMediaFolder.ts';
import { MediaSourceLibraryOrm } from './schema/MediaSource.ts';
import { ProgramType } from './schema/Program.ts';
@injectable()
export class LocalMediaDB {
constructor(@inject(KEYS.DrizzleDB) private db: DrizzleDBAccess) {}
async findFolder(
library: MediaSourceLibraryOrm,
parentPath: string,
): Promise<Maybe<LocalMediaFolderOrm>> {
if (isEmpty(parentPath)) {
return;
}
return await this.db.query.localMediaFolder.findFirst({
where: (fields, { eq, and }) =>
and(eq(fields.libraryId, library.uuid), eq(fields.path, parentPath)),
});
}
async upsertFolder(
library: MediaSourceLibraryOrm,
knownParentId: Maybe<string>,
folderName: string,
canonicalId: string,
): Promise<UpsertFolderResult> {
const existingFolder = await this.db.query.localMediaFolder.findFirst({
where: (fields, { and, eq }) =>
and(eq(fields.libraryId, library.uuid), eq(fields.path, folderName)),
with: {
parent: true,
},
});
if (existingFolder) {
if (
isNonEmptyString(knownParentId) &&
existingFolder.parentId !== knownParentId
) {
await this.db.update(LocalMediaFolder).set({
parentId: knownParentId,
});
}
return {
isNew: false,
folder: existingFolder,
};
} else {
const newFolder: NewLocalMediaFolderOrm = {
canonicalId,
libraryId: library.uuid,
path: folderName,
uuid: v4(),
parentId: knownParentId,
};
const allInserted = await this.db
.insert(LocalMediaFolder)
.values(newFolder)
.returning();
const inserted = head(allInserted);
if (!inserted) {
throw new Error('Expected exactly one inserted local media folder');
}
return {
isNew: true,
folder: inserted,
};
}
}
async setCanonicalId(folderId: string, canonicalId: string) {
return this.db
.update(LocalMediaFolder)
.set({
canonicalId,
})
.where(eq(LocalMediaFolder.uuid, folderId))
.limit(1);
}
async insertArtwork(artwork: NewArtwork): Promise<Maybe<Artwork>> {
return this.db
.insert(Artwork)
.values(artwork)
.returning()
.then((_) => head(_));
}
async updateArtwork(
artworkId: string,
artwork: NewArtwork,
): Promise<Maybe<Artwork>> {
return this.db
.update(Artwork)
.set(artwork)
.where(eq(Artwork.uuid, artworkId))
.returning()
.then((_) => head(_));
}
async getArtworkForProgram(programId: string) {
return this.db.query.artwork.findMany({
where: (fields, { eq }) => eq(fields.programId, programId),
});
}
async findExistingLocalProgram(
mediaSourceId: MediaSourceId,
libraryId: string,
filePath: string,
programType?: ProgramType,
) {
return this.db.query.program.findFirst({
where: (fields, { eq, and }) => {
const clauses = [
eq(fields.mediaSourceId, mediaSourceId),
eq(fields.libraryId, libraryId),
eq(fields.externalKey, filePath),
];
if (programType) {
clauses.push(eq(fields.type, programType));
}
return and(...clauses);
},
with: {
localMediaFolder: true,
versions: {
with: {
chapters: true,
mediaStreams: true,
},
},
artwork: true,
},
});
}
}
type UpsertFolderResult = {
isNew: boolean;
folder: LocalMediaFolderOrm;
};

View File

@@ -1,9 +1,8 @@
import type {
GetOrInsertResult,
IProgramDB,
ProgramGroupingChildCounts,
ProgramGroupingExternalIdLookup,
ProgramUpsertRequest,
UpsertResult,
WithChannelIdFilter,
} from '@/db/interfaces/IProgramDB.js';
import { GlobalScheduler } from '@/services/Scheduler.js';
@@ -32,6 +31,7 @@ import {
} from '@tunarr/types';
import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { inject, injectable, interfaces } from 'inversify';
import {
CaseWhenBuilder,
@@ -43,6 +43,7 @@ import {
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite';
import {
chunk,
compact,
concat,
difference,
filter,
@@ -52,6 +53,7 @@ import {
forEach,
groupBy,
head,
isArray,
isEmpty,
isNil,
isNull,
@@ -69,13 +71,18 @@ import {
uniq,
uniqBy,
} from 'lodash-es';
import { Dictionary, MarkOptional, MarkRequired } from 'ts-essentials';
import {
Dictionary,
MarkOptional,
MarkRequired,
StrictExclude,
} from 'ts-essentials';
import { match, P } from 'ts-pattern';
import { v4 } from 'uuid';
import { typedProperty } from '../types/path.ts';
import { getNumericEnvVar, TUNARR_ENV_VARS } from '../util/env.ts';
import {
flatMapAsyncSeq,
groupByFunc,
groupByUniq,
groupByUniqProp,
isDefined,
@@ -96,7 +103,7 @@ import {
import { PageParams } from './interfaces/IChannelDB.ts';
import {
AllProgramFields,
AllProgramJoins,
AllProgramGroupingFields,
ProgramUpsertFields,
selectProgramsBuilder,
withProgramByExternalId,
@@ -107,7 +114,8 @@ import {
withTvSeason,
withTvShow,
} from './programQueryHelpers.ts';
import { MediaSourceType } from './schema/MediaSource.ts';
import { Artwork, NewArtwork } from './schema/Artwork.ts';
import { RemoteMediaSourceType } from './schema/MediaSource.ts';
import {
NewProgramDao,
ProgramDao,
@@ -120,12 +128,11 @@ import {
NewProgramExternalId,
NewSingleOrMultiExternalId,
ProgramExternalId,
ProgramExternalIdKeys,
toInsertableProgramExternalId,
} from './schema/ProgramExternalId.ts';
import {
AllProgramGroupingFields,
NewProgramGrouping,
ProgramGrouping,
ProgramGroupingType,
ProgramGroupingUpdate,
} from './schema/ProgramGrouping.ts';
@@ -136,22 +143,37 @@ import {
ProgramGroupingExternalIdFieldsWithAlias,
toInsertableProgramGroupingExternalId,
} from './schema/ProgramGroupingExternalId.ts';
import {
NewProgramMediaFile,
ProgramMediaFile,
} from './schema/ProgramMediaFile.ts';
import {
NewProgramMediaStream,
ProgramMediaStream,
} from './schema/ProgramMediaStream.ts';
import {
NewProgramSubtitles,
ProgramSubtitles,
} from './schema/ProgramSubtitles.ts';
import { ProgramVersion } from './schema/ProgramVersion.ts';
import { MediaSourceId, MediaSourceName } from './schema/base.ts';
import {
MediaSourceId,
MediaSourceName,
MediaSourceType,
} from './schema/base.js';
import { DB } from './schema/db.ts';
import type {
MusicAlbumWithExternalIds,
NewProgramGroupingWithExternalIds,
NewProgramGroupingWithRelations,
NewProgramVersion,
NewProgramWithRelations,
ProgramGroupingWithExternalIds,
ProgramWithExternalIds,
ProgramWithRelations,
ProgramWithRelationsOrm,
TvSeasonWithExternalIds,
} from './schema/derivedTypes.ts';
import { DrizzleDBAccess } from './schema/index.ts';
type MintedNewProgramInfo = {
program: NewProgramDao;
@@ -197,17 +219,28 @@ export class ProgramDB implements IProgramDB {
@inject(KEYS.Database) private db: Kysely<DB>,
@inject(KEYS.ProgramDaoMinterFactory)
private programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
) {
this.timer = new Timer(this.logger);
}
async getProgramById(id: string) {
return this.db
.selectFrom('program')
.selectAll()
.select((eb) => withProgramExternalIds(eb, ProgramExternalIdKeys))
.where('program.uuid', '=', id)
.executeTakeFirst();
async getProgramById(
id: string,
): Promise<Maybe<MarkRequired<ProgramWithRelationsOrm, 'externalIds'>>> {
return this.drizzleDB.query.program.findFirst({
where: (fields, { eq }) => eq(fields.uuid, id),
with: {
externalIds: true,
artwork: true,
subtitles: true,
versions: {
with: {
mediaStreams: true,
mediaFiles: true,
},
},
},
});
}
async getProgramExternalIds(
@@ -248,6 +281,28 @@ export class ProgramDB implements IProgramDB {
async getProgramsByIds(
ids: string[],
batchSize: number = 500,
): Promise<ProgramWithRelationsOrm[]> {
const results: ProgramWithRelationsOrm[] = [];
for (const idChunk of chunk(ids, batchSize)) {
const res = await this.drizzleDB.query.program.findMany({
where: (fields, { inArray }) => inArray(fields.uuid, idChunk),
with: {
album: true,
artist: true,
season: true,
show: true,
externalIds: true,
artwork: true,
},
});
results.push(...res);
}
return results;
}
async getProgramsByIdsOld(
ids: string[],
batchSize: number = 500,
): Promise<ProgramWithRelations[]> {
const results: ProgramWithRelations[] = [];
for (const idChunk of chunk(ids, batchSize)) {
@@ -267,12 +322,13 @@ export class ProgramDB implements IProgramDB {
}
async getProgramGrouping(id: string) {
return this.db
.selectFrom('programGrouping')
.selectAll()
.select(withProgramGroupingExternalIds)
.where('uuid', '=', id)
.executeTakeFirst();
return this.drizzleDB.query.programGrouping.findFirst({
where: (fields, { eq }) => eq(fields.uuid, id),
with: {
externalIds: true,
artwork: true,
},
});
}
async getProgramGroupings(ids: string[]) {
@@ -462,13 +518,14 @@ export class ProgramDB implements IProgramDB {
)
.executeTakeFirstOrThrow(),
baseQuery
.selectAll()
.selectAll('programGrouping')
.orderBy(childType === 'season' ? 'title asc' : 'year asc')
.select(withProgramGroupingExternalIds)
.$if(!!params && params.limit >= 0, (eb) =>
eb.offset(params.offset),
)
.$if(!!params && params.limit >= 0, (eb) => eb.limit(params.limit))
.$narrowType<ProgramGroupingWithExternalIds[]>()
.execute(),
]);
@@ -532,39 +589,39 @@ export class ProgramDB implements IProgramDB {
chunkSize: number = 200,
) {
const allIds = [...ids];
const programs: ProgramWithRelations[] = [];
const programs: MarkRequired<ProgramWithRelationsOrm, 'externalIds'>[] = [];
for (const idChunk of chunk(allIds, chunkSize)) {
programs.push(
...(await this.db
.selectFrom('programExternalId')
.select((eb) =>
withProgramByExternalId(eb, { joins: AllProgramJoins }),
)
.where((eb) =>
eb.or(
map(idChunk, ([ps, es, ek]) =>
eb.and([
eb('programExternalId.externalKey', '=', ek),
eb('programExternalId.mediaSourceId', '=', es),
eb(
'programExternalId.sourceType',
'=',
programSourceTypeFromString(ps)!,
),
]),
),
const results = await this.drizzleDB.query.programExternalId.findMany({
where: (fields, { or, and, eq }) => {
const ands = idChunk.map(([ps, es, ek]) =>
and(
eq(fields.externalKey, ek),
eq(fields.sourceType, programSourceTypeFromString(ps)!),
eq(fields.mediaSourceId, es),
),
)
.execute()
.then((_) => seq.collect(_, (eid) => eid.program))),
);
);
return or(...ands);
},
with: {
program: {
with: {
album: true,
artist: true,
season: true,
show: true,
externalIds: true,
},
},
},
});
programs.push(...seq.collect(results, (r) => r.program));
}
return programs;
}
async lookupByMediaSource(
sourceType: MediaSourceType,
sourceType: RemoteMediaSourceType,
sourceId: MediaSourceId,
programType: Maybe<ProgramType>,
chunkSize: number = 200,
@@ -888,10 +945,19 @@ export class ProgramDB implements IProgramDB {
return upsertedPrograms;
}
upsertPrograms(
request: NewProgramWithRelations,
): Promise<ProgramWithExternalIds>;
upsertPrograms(
programs: NewProgramWithRelations[],
programUpsertBatchSize?: number,
): Promise<ProgramWithExternalIds[]>;
async upsertPrograms(
requests: ProgramUpsertRequest[],
requests: NewProgramWithRelations | NewProgramWithRelations[],
programUpsertBatchSize: number = 100,
) {
): Promise<ProgramWithExternalIds | ProgramWithExternalIds[]> {
const wasSingleRequest = !isArray(requests);
requests = isArray(requests) ? requests : [requests];
if (isEmpty(requests)) {
return [];
}
@@ -901,19 +967,12 @@ export class ProgramDB implements IProgramDB {
// Group related items by canonicalId because the UUID we get back
// from the upsert may not be the one we generated (if an existing entry)
// already exists
const externalIdsByProgramCanonicalId = groupByFunc(
const requestsByCanonicalId = groupByUniq(
requests,
({ program }) => program.canonicalId,
(program) => program.externalIds,
);
const programVersionsByCanonicalId = groupByFunc(
requests,
({ program }) => program.canonicalId,
(request) => request.versions,
);
return await Promise.all(
const result = await Promise.all(
chunk(requests, programUpsertBatchSize).map(async (c) => {
const chunkResult = await db.transaction().execute((tx) =>
tx
@@ -936,28 +995,43 @@ export class ProgramDB implements IProgramDB {
);
const allExternalIds = flatten(c.map((program) => program.externalIds));
const versionsToInsert: NewProgramVersion[] = [];
const artworkToInsert: NewArtwork[] = [];
const subtitlesToInsert: NewProgramSubtitles[] = [];
for (const program of chunkResult) {
const key = program.canonicalId;
const eids = externalIdsByProgramCanonicalId[key] ?? [];
const request: Maybe<NewProgramWithRelations> =
requestsByCanonicalId[key];
const eids = request?.externalIds ?? [];
for (const eid of eids) {
eid.programUuid = program.uuid;
}
for (const version of request?.versions ?? []) {
version.programId = program.uuid;
versionsToInsert.push(version);
}
for (const art of request?.artwork ?? []) {
art.programId = program.uuid;
artworkToInsert.push(art);
}
for (const subtitle of request?.subtitles ?? []) {
subtitle.programId = program.uuid;
subtitlesToInsert.push(subtitle);
}
}
const externalIdsByProgramId =
await this.upsertProgramExternalIds(allExternalIds);
const versionsToInsert: NewProgramVersion[] = [];
for (const program of chunkResult) {
for (const version of programVersionsByCanonicalId[
program.canonicalId
] ?? []) {
version.programId = program.uuid;
versionsToInsert.push(version);
}
}
await this.upsertProgramVersions(versionsToInsert);
await this.upsertArtwork(artworkToInsert);
await this.upsertSubtitles(subtitlesToInsert);
return chunkResult.map(
(upsertedProgram) =>
({
@@ -967,6 +1041,12 @@ export class ProgramDB implements IProgramDB {
);
}),
).then(flatten);
if (wasSingleRequest) {
return head(result)!;
} else {
return result;
}
}
private async upsertProgramVersions(versions: NewProgramVersion[]) {
@@ -991,21 +1071,25 @@ export class ProgramDB implements IProgramDB {
.insertInto('programVersion')
.values(
versionBatch.map((version) =>
omit(version, ['chapters', 'mediaStreams']),
omit(version, ['chapters', 'mediaStreams', 'mediaFiles']),
),
)
.returningAll()
.execute();
await Promise.all([
this.upsertProgramMediaStreams(
await this.upsertProgramMediaStreams(
versionBatch.flatMap(({ mediaStreams }) => mediaStreams),
tx,
),
this.upsertProgramChapters(
await this.upsertProgramChapters(
versionBatch.flatMap(({ chapters }) => chapters ?? []),
tx,
),
await this.upsertProgramMediaFiles(
versionBatch.flatMap(({ mediaFiles }) => mediaFiles),
tx,
),
]);
insertedVersions.push(...insertResult);
@@ -1065,6 +1149,207 @@ export class ProgramDB implements IProgramDB {
return inserted;
}
private async upsertProgramMediaFiles(
files: NewProgramMediaFile[],
tx: Kysely<DB> = this.db,
) {
if (files.length === 0) {
this.logger.warn('No media files passed for version');
return [];
}
const byVersionId = groupBy(files, (stream) => stream.programVersionId);
const inserted: ProgramMediaFile[] = [];
for (const batch of chunk(Object.entries(byVersionId), 50)) {
const [_, files] = unzip(batch);
// TODO: Do we need to delete first?
// await tx.deleteFrom('programMediaStream').where('programVersionId', 'in', versionIds).executeTakeFirstOrThrow();
inserted.push(
...(await tx
.insertInto('programMediaFile')
.values(flatten(files))
.returningAll()
.execute()),
);
}
return inserted;
}
private async upsertArtwork(artwork: NewArtwork[]) {
if (artwork.length === 0) {
return;
}
const programArt = groupBy(
artwork.filter((art) => isNonEmptyString(art.programId)),
(art) => art.programId,
);
const groupArt = groupBy(
artwork.filter((art) => isNonEmptyString(art.groupingId)),
(art) => art.groupingId,
);
return await this.drizzleDB.transaction(async (tx) => {
for (const batch of chunk(keys(programArt), 50)) {
await tx.delete(Artwork).where(inArray(Artwork.programId, batch));
}
for (const batch of chunk(keys(groupArt), 50)) {
await tx.delete(Artwork).where(inArray(Artwork.groupingId, batch));
}
const inserted: Artwork[] = [];
for (const batch of chunk(artwork, 50)) {
const batchResult = await this.drizzleDB
.insert(Artwork)
.values(batch)
.onConflictDoUpdate({
target: Artwork.uuid,
set: {
cachePath: sql`excluded.cache_path`,
groupingId: sql`excluded.grouping_id`,
programId: sql`excluded.program_id`,
updatedAt: sql`excluded.updated_at`,
sourcePath: sql`excluded.source_path`,
},
})
.returning();
inserted.push(...batchResult);
}
return inserted;
});
}
private async upsertSubtitles(subtitles: NewProgramSubtitles[]) {
if (subtitles.length === 0) {
return;
}
const grouped = groupBy(subtitles, (sub) => sub.programId);
for (const [programId, programSubtitles] of Object.entries(grouped)) {
const existingSubsForProgram =
await this.drizzleDB.query.programSubtitles.findMany({
where: (fields, { eq }) => eq(fields.programId, programId),
});
// Embedded subtitles are unique by stream index
// Sidecar are unique by path.
const [existingEmbedded, _] = partition(
existingSubsForProgram,
(sub) => !isNil(sub.streamIndex),
);
const [incomingEmbedded, incomingExternal] = partition(
programSubtitles,
(sub) => !isNil(sub.streamIndex),
);
const existingIndexes = new Set(
seq.collect(existingEmbedded, (sub) => sub.streamIndex),
);
const incomingIndexes = new Set(
seq.collect(incomingEmbedded, (sub) => sub.streamIndex),
);
const newIndexes = incomingIndexes.difference(existingIndexes);
const removedIndexes = existingIndexes.difference(newIndexes);
const updatedIndexes = incomingIndexes.difference(
newIndexes.union(removedIndexes),
);
const inserts = incomingEmbedded.filter((s) =>
newIndexes.has(s.streamIndex!),
);
const removes = existingEmbedded.filter((s) =>
removedIndexes.has(s.streamIndex!),
);
const updates: ProgramSubtitles[] = [];
for (const updatedIndex of updatedIndexes.values()) {
const incoming = incomingEmbedded.find(
(s) => s.streamIndex === updatedIndex,
);
const existing = existingEmbedded.find(
(s) => s.streamIndex === updatedIndex,
);
if (!existing || !incoming) {
continue; // Shouldn't happen
}
if (existing.isExtracted) {
const needsExtraction =
existing.subtitleType !== incoming.subtitleType ||
existing.codec !== incoming.subtitleType ||
existing.language !== incoming.language ||
existing.forced !== incoming.forced ||
existing.sdh !== incoming.sdh ||
existing.default !== incoming.default;
if (needsExtraction) {
existing.isExtracted = false;
existing.path = incoming.path ?? null;
} else if (
isNonEmptyString(incoming.path) &&
existing.path !== incoming.path
) {
existing.isExtracted = false;
existing.path = incoming.path;
}
}
existing.codec = incoming.codec;
existing.language = incoming.language;
existing.subtitleType = incoming.subtitleType;
existing.updatedAt = incoming.updatedAt;
if (isDefined(incoming.default)) {
existing.default = incoming.default;
}
if (isDefined(incoming.sdh)) {
existing.sdh = incoming.sdh;
}
if (isDefined(incoming.forced)) {
existing.forced = incoming.forced;
}
updates.push(existing);
}
await this.drizzleDB.transaction(async (tx) => {
if (inserts.length > 0) {
await tx.insert(ProgramSubtitles).values(inserts);
}
if (removes.length > 0) {
await tx.delete(ProgramSubtitles).where(
inArray(
ProgramSubtitles.uuid,
removes.map((s) => s.uuid),
),
);
}
if (updates.length > 0) {
for (const update of updates) {
await tx
.update(ProgramSubtitles)
.set(update)
.where(eq(ProgramSubtitles.uuid, update.uuid));
}
}
await tx
.delete(ProgramSubtitles)
.where(
and(
eq(ProgramSubtitles.subtitleType, 'sidecar'),
eq(ProgramSubtitles.programId, programId),
),
);
if (incomingExternal.length > 0) {
await tx.insert(ProgramSubtitles).values(incomingExternal);
}
});
}
}
async upsertProgramExternalIds(
externalIds: NewSingleOrMultiExternalId[],
chunkSize: number = 100,
@@ -1234,7 +1519,7 @@ export class ProgramDB implements IProgramDB {
async getProgramGroupingCanonicalIds(
mediaSourceLibraryId: string,
type: ProgramGroupingType,
sourceType: MediaSourceType,
sourceType: StrictExclude<MediaSourceType, 'local'>,
) {
return this.db
.selectFrom('programGrouping')
@@ -1287,11 +1572,12 @@ export class ProgramDB implements IProgramDB {
.execute();
}
async getOrInsertProgramGrouping(
dao: NewProgramGroupingWithExternalIds,
async upsertProgramGrouping(
newGroupingAndRelations: NewProgramGroupingWithRelations,
externalId: ProgramGroupingExternalIdLookup,
forceUpdate: boolean = false,
): Promise<GetOrInsertResult<ProgramGroupingWithExternalIds>> {
): Promise<UpsertResult<ProgramGroupingWithExternalIds>> {
const { programGrouping: dao, externalIds } = newGroupingAndRelations;
const existing = await this.getProgramGroupingByExternalId(externalId);
if (existing) {
let wasUpdated = false;
@@ -1307,14 +1593,18 @@ export class ProgramDB implements IProgramDB {
forceUpdate || differentVersion || missingAssociation;
if (shouldUpdate) {
dao.uuid = existing.uuid;
dao.externalIds.forEach((externalId) => {
externalIds.forEach((externalId) => {
externalId.groupUuid = existing.uuid;
});
await this.db.transaction().execute(async (tx) => {
await this.updateProgramGrouping(dao, existing, tx);
await this.updateProgramGrouping(
newGroupingAndRelations,
existing,
tx,
);
await this.updateProgramGroupingExternalIds(
existing.externalIds,
dao.externalIds,
externalIds,
tx,
);
});
@@ -1335,16 +1625,15 @@ export class ProgramDB implements IProgramDB {
.values(omit(dao, 'externalIds'))
.returningAll()
.executeTakeFirstOrThrow();
const externalIds: ProgramGroupingExternalId[] = [];
if (dao.externalIds.length > 0) {
externalIds.push(
const insertedExternalIds: ProgramGroupingExternalId[] = [];
if (insertedExternalIds.length > 0) {
insertedExternalIds.push(
...(await tx
.insertInto('programGroupingExternalId')
.values(
dao.externalIds.map((eid) => ({
...omit(eid, 'type'),
groupUuid: grouping.uuid,
})),
externalIds.map((eid) =>
this.singleOrMultiProgramGroupingExternalIdToDao(eid),
),
)
.returningAll()
.execute()),
@@ -1355,14 +1644,173 @@ export class ProgramDB implements IProgramDB {
wasUpdated: false,
entity: {
...grouping,
externalIds,
externalIds: insertedExternalIds,
} satisfies ProgramGroupingWithExternalIds,
};
});
}
async upsertLocalProgramGrouping(
newGroupingAndRelations: NewProgramGroupingWithRelations,
libraryId: string,
): Promise<UpsertResult<ProgramGroupingWithExternalIds>> {
const incomingYear = newGroupingAndRelations.programGrouping.year;
const existingGrouping =
await this.drizzleDB.query.programGrouping.findFirst({
where: (fields, { eq, and, isNull }) => {
const parentClause = match(newGroupingAndRelations.programGrouping)
.with({ type: 'season', showUuid: P.nonNullable }, (season) =>
compact([
eq(fields.showUuid, season.showUuid),
season.index ? eq(fields.index, season.index) : null,
]),
)
.with({ type: 'album', artistUuid: P.nonNullable }, (album) => [
eq(fields.artistUuid, album.artistUuid),
])
.otherwise(() => []);
return and(
eq(fields.libraryId, libraryId),
eq(fields.title, newGroupingAndRelations.programGrouping.title),
eq(fields.type, newGroupingAndRelations.programGrouping.type),
eq(fields.sourceType, 'local'),
isNil(incomingYear)
? isNull(fields.year)
: eq(fields.year, incomingYear),
...parentClause,
);
},
with: {
externalIds: true,
},
});
if (existingGrouping) {
newGroupingAndRelations.programGrouping.uuid = existingGrouping.uuid;
newGroupingAndRelations.externalIds.forEach((eid) => {
eid.groupUuid = existingGrouping.uuid;
});
newGroupingAndRelations.artwork.forEach((art) => {
art.groupingId = existingGrouping.uuid;
});
const [grouping, externalIds] = await this.drizzleDB.transaction(
async (tx) => {
const grouping = head(
await tx
.update(ProgramGrouping)
.set(
omit(newGroupingAndRelations.programGrouping, [
'uuid',
'createdAt',
]),
)
.where(eq(ProgramGrouping.uuid, existingGrouping.uuid))
.returning(),
)!;
await tx
.delete(ProgramGroupingExternalId)
.where(
eq(ProgramGroupingExternalId.groupUuid, existingGrouping.uuid),
);
const externalIds =
newGroupingAndRelations.externalIds.length > 0
? await tx
.insert(ProgramGroupingExternalId)
.values(newGroupingAndRelations.externalIds)
.returning()
: [];
return [grouping, externalIds];
},
);
newGroupingAndRelations.artwork.forEach((art) => {
art.groupingId = grouping.uuid;
});
await this.upsertArtwork(newGroupingAndRelations.artwork);
return {
entity: {
...grouping,
externalIds,
},
wasInserted: false,
wasUpdated: true,
};
}
const [grouping, externalIds] = await this.drizzleDB.transaction(
async (tx) => {
const grouping = head(
await tx
.insert(ProgramGrouping)
.values(newGroupingAndRelations.programGrouping)
.returning(),
)!;
let externalIds: ProgramGroupingExternalId[] = [];
if (newGroupingAndRelations.externalIds.length > 0) {
newGroupingAndRelations.externalIds.forEach((eid) => {
eid.groupUuid = grouping.uuid;
});
externalIds = await tx
.insert(ProgramGroupingExternalId)
.values(newGroupingAndRelations.externalIds)
.returning();
}
return [grouping, externalIds];
},
);
newGroupingAndRelations.artwork.forEach((art) => {
art.groupingId = grouping.uuid;
});
await this.upsertArtwork(newGroupingAndRelations.artwork);
return {
entity: {
...grouping,
externalIds,
},
wasInserted: true,
wasUpdated: false,
};
}
private singleOrMultiProgramGroupingExternalIdToDao(
externalId: NewSingleOrMultiProgramGroupingExternalId,
): NewProgramGroupingExternalId {
switch (externalId.type) {
case 'single':
return {
externalKey: externalId.externalKey,
groupUuid: externalId.groupUuid,
sourceType: externalId.sourceType,
uuid: externalId.uuid,
createdAt: externalId.createdAt,
externalFilePath: externalId.externalFilePath,
libraryId: externalId.libraryId,
updatedAt: externalId.updatedAt,
};
case 'multi':
return {
externalKey: externalId.externalKey,
groupUuid: externalId.groupUuid,
sourceType: externalId.sourceType,
uuid: externalId.uuid,
createdAt: externalId.createdAt,
externalFilePath: externalId.externalFilePath,
externalSourceId: externalId.externalSourceId,
libraryId: externalId.libraryId,
mediaSourceId: externalId.mediaSourceId,
updatedAt: externalId.updatedAt,
};
}
}
private async updateProgramGrouping(
incoming: NewProgramGroupingWithExternalIds,
{ programGrouping: incoming }: NewProgramGroupingWithRelations,
existing: ProgramGroupingWithExternalIds,
tx: Kysely<DB> = this.db,
) {
@@ -1533,33 +1981,56 @@ export class ProgramDB implements IProgramDB {
groupId: string,
groupTypeHint?: ProgramGroupingType,
) {
return this.db
.selectFrom('program')
.$if(isUndefined(groupTypeHint), (qb) =>
qb.where((eb) =>
eb.or([
eb('program.tvShowUuid', '=', groupId),
eb('program.albumUuid', '=', groupId),
eb('program.seasonUuid', '=', groupId),
eb('program.artistUuid', '=', groupId),
]),
),
)
.$if(isDefined(groupTypeHint), (qb) => {
switch (groupTypeHint!) {
case 'show':
return qb.where('program.tvShowUuid', '=', groupId);
case 'season':
return qb.where('program.seasonUuid', '=', groupId);
case 'artist':
return qb.where('program.artistUuid', '=', groupId);
case 'album':
return qb.where('program.albumUuid', '=', groupId);
return this.drizzleDB.query.program.findMany({
where: (fields, { or, eq }) => {
if (groupTypeHint) {
switch (groupTypeHint) {
case 'show':
return eq(fields.tvShowUuid, groupId);
case 'season':
return eq(fields.seasonUuid, groupId);
case 'artist':
return eq(fields.artistUuid, groupId);
case 'album':
return eq(fields.albumUuid, groupId);
}
} else {
return or(
eq(fields.albumUuid, groupId),
eq(fields.artistUuid, groupId),
eq(fields.tvShowUuid, groupId),
eq(fields.seasonUuid, groupId),
);
}
})
.selectAll()
.select(withProgramExternalIds)
.execute();
},
with: {
album:
isUndefined(groupTypeHint) ||
groupTypeHint === 'album' ||
groupTypeHint === 'artist'
? true
: undefined,
artist:
isUndefined(groupTypeHint) ||
groupTypeHint === 'album' ||
groupTypeHint === 'artist'
? true
: undefined,
season:
isUndefined(groupTypeHint) ||
groupTypeHint === 'show' ||
groupTypeHint === 'season'
? true
: undefined,
show:
isUndefined(groupTypeHint) ||
groupTypeHint === 'show' ||
groupTypeHint === 'season'
? true
: undefined,
externalIds: true,
},
});
}
private async handleProgramGroupings(
@@ -1691,30 +2162,17 @@ export class ProgramDB implements IProgramDB {
const existingGroupings = await this.timer.timeAsync(
`selecting grouping external ids (${allGroupingKeys.length})`,
() =>
this.db
.selectFrom('programGroupingExternalId')
.where((eb) => {
return eb.and([
eb('programGroupingExternalId.sourceType', '=', mediaSourceType),
eb('programGroupingExternalId.mediaSourceId', '=', mediaSourceId),
eb(
'programGroupingExternalId.externalKey',
'in',
allGroupingKeys,
),
]);
})
.innerJoin(
'programGrouping',
'programGroupingExternalId.groupUuid',
'programGrouping.uuid',
)
.selectAll()
.groupBy([
'programGroupingExternalId.externalKey',
'programGrouping.uuid',
])
.execute(),
this.drizzleDB.query.programGroupingExternalId.findMany({
where: (fields, { eq, and, inArray }) =>
and(
eq(fields.sourceType, mediaSourceType),
eq(fields.mediaSourceId, mediaSourceId),
inArray(fields.externalKey, allGroupingKeys),
),
with: {
grouping: true,
},
}),
);
const foundGroupingRatingKeys = map(existingGroupings, 'externalKey');

View File

@@ -13,6 +13,7 @@ import {
ExternalId,
FlexProgram,
Identifier,
MediaItem,
MediaStream,
MusicAlbumContentProgram,
MusicArtistContentProgram,
@@ -33,7 +34,9 @@ import { find, first, isNil, omitBy, orderBy } from 'lodash-es';
import { isPromise } from 'node:util/types';
import { DeepNullable, DeepPartial, MarkRequired } from 'ts-essentials';
import { match } from 'ts-pattern';
import { MarkNonNullable, Nullable } from '../../types/util.ts';
import { MediaLocation } from '../../types/Media.ts';
import { MarkNonNullable } from '../../types/util.ts';
import { titleToSortTitle } from '../../util/programs.ts';
import {
LineupItem,
OfflineItem,
@@ -117,26 +120,6 @@ export class ProgramConverter {
return null;
}
convertProgramWithExternalIds(
program: MarkNonNullable<
MarkRequired<ProgramWithRelations, 'externalIds'>,
'mediaSourceId'
>,
): MarkRequired<ContentProgram, 'id'>;
convertProgramWithExternalIds(
program: MarkRequired<ProgramWithRelations, 'externalIds'>,
): MarkRequired<ContentProgram, 'id'> | null;
convertProgramWithExternalIds(
program:
| MarkRequired<ProgramWithRelations, 'externalIds'>
| MarkRequired<
MarkNonNullable<ProgramWithRelations, 'mediaSourceId'>,
'externalIds'
>,
): Nullable<MarkRequired<ContentProgram, 'id'>> {
return this.programDaoToContentProgram(program);
}
programDaoToTerminalProgram(
program: ProgramWithRelationsOrm,
): TerminalProgram | null {
@@ -155,6 +138,7 @@ export class ProgramConverter {
const base = {
...program,
sortTitle: titleToSortTitle(program.title),
type: program.type,
mediaSourceId: untag(program.mediaSourceId),
canonicalId: program.canonicalId,
@@ -234,8 +218,15 @@ export class ProgramConverter {
],
['asc', 'asc'],
),
locations: [],
};
locations:
version.mediaFiles?.map(
(file) =>
({
type: 'local',
path: file.path,
}) satisfies MediaLocation,
) ?? [],
} satisfies MediaItem;
}
return typed;
@@ -375,6 +366,137 @@ export class ProgramConverter {
};
}
// TEMP during migrations
programOrmToContentProgram(
program: MarkNonNullable<ProgramWithRelationsOrm, 'mediaSourceId'>,
externalIds?: MinimalProgramExternalId[],
): MarkRequired<ContentProgram, 'id'>;
programOrmToContentProgram(
program: ProgramWithRelationsOrm,
externalIds?: MinimalProgramExternalId[],
): MarkRequired<ContentProgram, 'id'> | null;
programOrmToContentProgram(
program:
| ProgramWithRelationsOrm
| MarkNonNullable<ProgramWithRelationsOrm, 'mediaSourceId'>,
externalIds: MinimalProgramExternalId[] = program.externalIds ?? [],
): MarkRequired<ContentProgram, 'id'> | null {
if (!program.mediaSourceId) {
return null;
}
let extraFields: Partial<ContentProgram> = {};
if (program.type === ProgramType.Episode) {
extraFields = {
...extraFields,
icon: nullToUndefined(program.episodeIcon ?? program.showIcon),
showId: nullToUndefined(program.show?.uuid ?? program.tvShowUuid),
seasonId: nullToUndefined(program.season?.uuid ?? program.seasonUuid),
// Fallback to the denormalized field, for now
seasonNumber: nullToUndefined(
program.season?.index ?? program.seasonNumber,
),
episodeNumber: nullToUndefined(program.episode),
title: program.title,
parent: {
type: 'season',
id: nullToUndefined(program.season?.uuid ?? program.seasonUuid),
index: nullToUndefined(program.season?.index),
title: nullToUndefined(program.season?.title ?? program.showTitle),
year: nullToUndefined(program.season?.year),
externalKey: nullToUndefined(
find(
program.season?.externalIds ?? [],
(eid) => eid.externalSourceId === program.externalSourceId,
)?.externalKey,
),
externalIds: seq.collect(program.season?.externalIds, (eid) =>
this.toGroupingExternalId(eid),
),
},
grandparent: {
type: 'show',
id: nullToUndefined(program.show?.uuid ?? program.tvShowUuid),
index: nullToUndefined(program.show?.index),
title: nullToUndefined(program.show?.title),
externalKey: nullToUndefined(
find(
program.show?.externalIds ?? [],
(eid) => eid.externalSourceId === program.externalSourceId,
)?.externalKey,
),
year: nullToUndefined(program.show?.year),
externalIds: seq.collect(program.show?.externalIds, (eid) =>
this.toGroupingExternalId(eid),
),
},
index: nullToUndefined(program.episode),
};
} else if (program.type === ProgramType.Track.toString()) {
extraFields = {
parent: {
type: 'album',
id: nullToUndefined(program.album?.uuid ?? program.albumUuid),
index: nullToUndefined(program.album?.index),
title: nullToUndefined(program.albumName ?? program.album?.title),
externalKey: nullToUndefined(
find(
program.album?.externalIds ?? [],
(eid) => eid.externalSourceId === program.externalSourceId,
)?.externalKey,
),
year: nullToUndefined(program.album?.year),
externalIds: seq.collect(program.album?.externalIds, (eid) =>
this.toGroupingExternalId(eid),
),
},
grandparent: {
type: 'artist',
id: nullToUndefined(program.artist?.uuid ?? program.artistUuid),
index: nullToUndefined(program.artist?.index),
title: nullToUndefined(program.artist?.title),
externalKey: nullToUndefined(
find(
program.artist?.externalIds ?? [],
(eid) => eid.externalSourceId === program.externalSourceId,
)?.externalKey,
),
year: nullToUndefined(program.artist?.year),
externalIds: seq.collect(program.artist?.externalIds, (eid) =>
this.toGroupingExternalId(eid),
),
},
albumId: nullToUndefined(program.album?.uuid ?? program.albumUuid),
artistId: nullToUndefined(program.artist?.uuid ?? program.artistUuid),
// HACK: Tracks save their index under the episode field
index: nullToUndefined(program.episode),
};
}
return {
persisted: true, // Explicit since we're dealing with db loaded entities
uniqueId: program.uuid,
summary: nullToUndefined(program.summary),
date: nullToUndefined(program.originalAirDate),
rating: nullToUndefined(program.rating),
icon: nullToUndefined(program.icon),
title: program.title,
duration: program.duration,
type: 'content',
id: program.uuid,
subtype: program.type,
externalIds: seq.collect(program.externalIds ?? externalIds, (eid) =>
this.toExternalId(eid),
),
externalKey: program.externalKey,
externalSourceId: program.mediaSourceId,
externalSourceName: program.externalSourceId,
externalSourceType: program.sourceType,
canonicalId: nullToUndefined(program.canonicalId),
...omitBy(extraFields, isNil),
};
}
programGroupingDaoToDto(program: TvShowWithExternalIds): TvShowContentProgram;
programGroupingDaoToDto(
program: TvSeasonWithExternalIds,

View File

@@ -2,7 +2,13 @@ import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.j
import type { NewSingleOrMultiProgramGroupingExternalId } from '@/db/schema/ProgramGroupingExternalId.js';
import { isNonEmptyString } from '@/util/index.js';
import { seq } from '@tunarr/shared/util';
import type { ContentProgram } from '@tunarr/types';
import {
tag,
type ContentProgram,
type Identifier,
type Season,
type Show,
} from '@tunarr/types';
import {
isValidMultiExternalIdType,
isValidSingleExternalIdType,
@@ -15,19 +21,14 @@ import { v4 } from 'uuid';
import {
MediaSourceMusicAlbum,
MediaSourceMusicArtist,
MediaSourceSeason,
MediaSourceShow,
} from '../../types/Media.ts';
import type { Nullable } from '../../types/util.ts';
import { MediaSourceId, MediaSourceName } from '../schema/base.ts';
import type { Nilable, Nullable } from '../../types/util.ts';
import { MediaSourceId, MediaSourceName } from '../schema/base.js';
import { NewProgramGroupingWithRelations } from '../schema/derivedTypes.js';
import {
NewMusicAlbum,
NewMusicArtist,
NewProgramGroupingWithExternalIds,
NewTvSeason,
NewTvShow,
} from '../schema/derivedTypes.js';
import { MediaSource, MediaSourceLibrary } from '../schema/MediaSource.ts';
MediaSourceLibraryOrm,
MediaSourceOrm,
} from '../schema/MediaSource.ts';
import {
ProgramGroupingType,
type NewProgramGrouping,
@@ -98,7 +99,7 @@ export class ProgramGroupingMinter {
return null;
}
if (!item.canonicalId || !item.libraryId) {
if (!item.canonicalId || !item.libraryId || !item.grandparent.externalKey) {
return null;
}
@@ -120,6 +121,9 @@ export class ProgramGroupingMinter {
year: item.grandparent.year,
canonicalId: item.canonicalId,
libraryId: item.libraryId,
sourceType: item.externalSourceType,
mediaSourceId: tag(item.externalSourceId),
externalKey: item.grandparent.externalKey,
};
}
@@ -130,7 +134,7 @@ export class ProgramGroupingMinter {
return null;
}
if (!item.canonicalId || !item.libraryId) {
if (!item.canonicalId || !item.libraryId || !item.parent.externalKey) {
return null;
}
@@ -152,170 +156,153 @@ export class ProgramGroupingMinter {
year: item.parent.year,
canonicalId: item.canonicalId,
libraryId: item.libraryId,
sourceType: item.externalSourceType,
mediaSourceId: tag(item.externalSourceId),
externalKey: item.parent.externalKey,
} satisfies NewProgramGrouping;
}
mintForMediaSourceShow(
mediaSource: MediaSource,
mediaSourceLibrary: MediaSourceLibrary,
show: MediaSourceShow,
): NewTvShow {
mediaSource: MediaSourceOrm,
mediaSourceLibrary: MediaSourceLibraryOrm,
show: Show,
): NewProgramGroupingWithRelations<'show'> {
const now = +dayjs();
const groupingId = v4();
const externalIds = seq.collect(show.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiProgramGroupingExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
return {
type: 'multi',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
} satisfies NewSingleOrMultiProgramGroupingExternalId;
}
return;
});
return {
uuid: groupingId,
type: ProgramGroupingType.Show,
createdAt: now,
updatedAt: now,
// index: show.index,
title: show.title,
summary: show.summary,
year: show.year,
libraryId: mediaSourceLibrary.uuid,
canonicalId: show.canonicalId,
externalIds,
} satisfies NewProgramGroupingWithExternalIds;
programGrouping: {
uuid: groupingId,
type: ProgramGroupingType.Show,
createdAt: now,
updatedAt: now,
// index: show.index,
title: show.title,
summary: show.summary ?? show.plot,
year: show.year,
libraryId: mediaSourceLibrary.uuid,
canonicalId: show.canonicalId,
sourceType: mediaSource.type,
mediaSourceId: mediaSource.uuid,
externalKey: show.externalId,
},
externalIds: this.mintExternalIdsFromIdentifiers(
mediaSource,
groupingId,
show.identifiers,
now,
),
artwork: [],
};
}
mintForMediaSourceArtist(
mediaSource: MediaSource,
mediaSourceLibrary: MediaSourceLibrary,
mediaSource: MediaSourceOrm,
mediaSourceLibrary: MediaSourceLibraryOrm,
artist: MediaSourceMusicArtist,
): NewMusicArtist {
): NewProgramGroupingWithRelations<'artist'> {
const now = +dayjs();
const groupingId = v4();
const externalIds = seq.collect(artist.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiProgramGroupingExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
return {
type: 'multi',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
} satisfies NewSingleOrMultiProgramGroupingExternalId;
}
return;
});
return {
uuid: groupingId,
type: ProgramGroupingType.Artist,
createdAt: now,
updatedAt: now,
// index: show.index,
title: artist.title,
summary: artist.summary,
year: null,
libraryId: mediaSourceLibrary.uuid,
canonicalId: artist.canonicalId,
externalIds,
} satisfies NewMusicArtist;
programGrouping: {
uuid: groupingId,
type: ProgramGroupingType.Artist,
createdAt: now,
updatedAt: now,
// index: show.index,
title: artist.title,
summary: artist.summary,
year: null,
libraryId: mediaSourceLibrary.uuid,
canonicalId: artist.canonicalId,
sourceType: mediaSource.type,
mediaSourceId: mediaSource.uuid,
externalKey: artist.externalId,
},
externalIds: this.mintExternalIdsFromIdentifiers(
mediaSource,
groupingId,
artist.identifiers,
now,
),
artwork: [],
};
}
mintSeason(
mediaSource: MediaSource,
mediaSourceLibrary: MediaSourceLibrary,
season: MediaSourceSeason,
): NewTvSeason {
mediaSource: MediaSourceOrm,
mediaSourceLibrary: MediaSourceLibraryOrm,
season: Season,
): NewProgramGroupingWithRelations<'season'> {
const now = +dayjs();
const groupingId = v4();
const externalIds = seq.collect(season.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiProgramGroupingExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
return {
type: 'multi',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
} satisfies NewSingleOrMultiProgramGroupingExternalId;
}
return;
});
return {
uuid: groupingId,
type: ProgramGroupingType.Season,
createdAt: now,
updatedAt: now,
index: season.index,
title: season.title,
summary: season.summary,
libraryId: mediaSourceLibrary.uuid,
canonicalId: season.canonicalId,
externalIds,
} satisfies NewProgramGroupingWithExternalIds;
programGrouping: {
uuid: groupingId,
type: ProgramGroupingType.Season,
createdAt: now,
updatedAt: now,
index: season.index,
title: season.title,
summary: season.summary,
libraryId: mediaSourceLibrary.uuid,
canonicalId: season.canonicalId,
sourceType: mediaSource.type,
mediaSourceId: mediaSource.uuid,
externalKey: season.externalId,
showUuid: season.show?.uuid,
},
externalIds: this.mintExternalIdsFromIdentifiers(
mediaSource,
groupingId,
season.identifiers,
now,
),
artwork: [],
};
}
mintMusicAlbum(
mediaSource: MediaSource,
mediaSourceLibrary: MediaSourceLibrary,
mediaSource: MediaSourceOrm,
mediaSourceLibrary: MediaSourceLibraryOrm,
album: MediaSourceMusicAlbum,
): NewMusicAlbum {
): NewProgramGroupingWithRelations<'album'> {
const now = +dayjs();
const groupingId = v4();
return {
programGrouping: {
uuid: groupingId,
type: ProgramGroupingType.Album,
createdAt: now,
updatedAt: now,
index: album.index,
title: album.title,
summary: album.summary,
libraryId: mediaSourceLibrary.uuid,
canonicalId: album.canonicalId,
sourceType: mediaSource.type,
mediaSourceId: mediaSource.uuid,
externalKey: album.externalId,
},
externalIds: this.mintExternalIdsFromIdentifiers(
mediaSource,
groupingId,
album.identifiers,
now,
),
artwork: [],
};
}
const externalIds = seq.collect(album.identifiers, (id) => {
mintExternalIdsFromIdentifiers(
mediaSource: MediaSourceOrm,
groupingId: string,
identifiers: Nilable<Identifier[]>,
now: number = +dayjs(),
) {
return seq.collect(identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
@@ -342,18 +329,5 @@ export class ProgramGroupingMinter {
return;
});
return {
uuid: groupingId,
type: ProgramGroupingType.Album,
createdAt: now,
updatedAt: now,
index: album.index,
title: album.title,
summary: album.summary,
libraryId: mediaSourceLibrary.uuid,
canonicalId: album.canonicalId,
externalIds,
} satisfies NewMusicAlbum;
}
}

View File

@@ -6,6 +6,7 @@ import type {
} from '@/db/schema/ProgramExternalId.js';
import { seq } from '@tunarr/shared/util';
import {
Episode,
isTerminalItemType,
ProgramLike,
tag,
@@ -32,7 +33,6 @@ import { match, P } from 'ts-pattern';
import { v4 } from 'uuid';
import { Canonicalizer } from '../../services/Canonicalizer.ts';
import {
MediaSourceEpisode,
MediaSourceMovie,
MediaSourceMusicTrack,
MediaSourceOtherVideo,
@@ -43,11 +43,16 @@ import { parsePlexGuid } from '../../util/externalIds.ts';
import { isNonEmptyString } from '../../util/index.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { booleanToNumber } from '../../util/sqliteUtil.ts';
import { MediaSource, MediaSourceLibrary } from '../schema/MediaSource.ts';
import {
MediaSourceLibraryOrm,
MediaSourceOrm,
} from '../schema/MediaSource.ts';
import type { NewProgramDao } from '../schema/Program.ts';
import { ProgramType } from '../schema/Program.ts';
import { NewProgramMediaFile } from '../schema/ProgramMediaFile.ts';
import { NewProgramMediaStream } from '../schema/ProgramMediaStream.ts';
import { MediaSourceId, MediaSourceName } from '../schema/base.ts';
import { NewProgramSubtitles } from '../schema/ProgramSubtitles.ts';
import { MediaSourceId, MediaSourceName } from '../schema/base.js';
import {
NewEpisodeProgram,
NewMovieProgram,
@@ -58,16 +63,6 @@ import {
NewProgramWithRelations,
} from '../schema/derivedTypes.js';
// type MovieMintRequest =
// | { sourceType: 'plex'; program: PlexMovie }
// | { sourceType: 'jellyfin'; program: SpecificJellyfinType<'Movie'> }
// | { sourceType: 'emby'; program: SpecificEmbyType<'Movie'> };
// type EpisodeMintRequest =
// | { sourceType: 'plex'; program: PlexEpisode }
// | { sourceType: 'jellyfin'; program: SpecificJellyfinType<'Episode'> }
// | { sourceType: 'emby'; program: SpecificEmbyType<'Episode'> };
/**
* Generates Program DB entities for Plex media
*/
@@ -121,8 +116,8 @@ export class ProgramDaoMinter {
}
mint(
mediaSource: MediaSource,
library: MediaSourceLibrary,
mediaSource: MediaSourceOrm,
library: MediaSourceLibraryOrm,
program: ContentProgramOriginalProgram,
): NewProgramWithExternalIds {
const ret = match(program)
@@ -173,20 +168,20 @@ export class ProgramDaoMinter {
}
mintMovie(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
mediaSource: MediaSourceOrm,
mediaLibrary: MediaSourceLibraryOrm,
movie: MediaSourceMovie,
now: number = +dayjs(),
): NewProgramWithRelations<'movie'> {
const programId = v4();
const now = +dayjs();
const newMovie = {
uuid: programId,
sourceType: movie.sourceType,
externalKey: movie.externalKey,
externalKey: movie.externalId,
originalAirDate: movie.releaseDate
? dayjs(movie.releaseDate)?.format()
: null,
duration: movie.duration,
duration: movie.duration ?? 0,
// filePath: file?.file ?? null,
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
@@ -207,6 +202,7 @@ export class ProgramDaoMinter {
program: newMovie,
externalIds: this.mintExternalIdsNew(programId, movie, mediaSource, now),
versions: this.mintVersions(programId, movie, now),
subtitles: this.mintSubtitles(programId, movie),
};
}
@@ -237,37 +233,48 @@ export class ProgramDaoMinter {
} satisfies NewProgramMediaStream;
});
const version: NewProgramVersion = {
uuid: versionId,
createdAt: now,
updatedAt: now,
programId,
displayAspectRatio: item.mediaItem.displayAspectRatio,
duration: item.mediaItem.duration,
frameRate: match(item.mediaItem.frameRate)
.with(P.string, (str) => str)
.with(P.number, (num) => num.toString())
.with(P.nullish, (nil) => nil)
.exhaustive(),
sampleAspectRatio: item.mediaItem.sampleAspectRatio,
height: item.mediaItem.resolution?.heightPx,
width: item.mediaItem.resolution?.widthPx,
mediaStreams: streams,
chapters: item.mediaItem.chapters?.map((chapter) => {
return {
index: chapter.index,
programVersionId: versionId,
chapterType: chapter.chapterType,
uuid: v4(),
title: chapter.title,
startTime: chapter.startTime,
endTime: chapter.endTime,
};
}),
// TODO: scanKind: movie.mediaItem.
};
const files = item.mediaItem.locations.map((loc) => {
return {
path: loc.path,
programVersionId: versionId,
uuid: v4(),
} satisfies NewProgramMediaFile;
});
versions.push(version);
if (item.mediaItem.resolution) {
const version: NewProgramVersion = {
uuid: versionId,
createdAt: now,
updatedAt: now,
programId,
displayAspectRatio: item.mediaItem.displayAspectRatio,
duration: item.mediaItem.duration,
frameRate: match(item.mediaItem.frameRate)
.with(P.string, (str) => str)
.with(P.number, (num) => num.toString())
.with(P.nullish, (nil) => nil)
.exhaustive(),
sampleAspectRatio: item.mediaItem.sampleAspectRatio,
height: item.mediaItem.resolution?.heightPx,
width: item.mediaItem.resolution?.widthPx,
mediaStreams: streams,
mediaFiles: files,
chapters: item.mediaItem.chapters?.map((chapter) => {
return {
index: chapter.index,
programVersionId: versionId,
chapterType: chapter.chapterType,
uuid: v4(),
title: chapter.title,
startTime: chapter.startTime,
endTime: chapter.endTime,
};
}),
scanKind: item.mediaItem.scanKind ?? 'unknown',
};
versions.push(version);
}
}
return versions;
@@ -276,7 +283,7 @@ export class ProgramDaoMinter {
mintExternalIdsNew(
programId: string,
item: ProgramLike,
mediaSource: MediaSource,
mediaSource: MediaSourceOrm,
now: number = +dayjs(),
) {
return seq.collect(item.identifiers, (id) => {
@@ -319,22 +326,73 @@ export class ProgramDaoMinter {
});
}
mintSubtitles(
programId: string,
item: TerminalProgram,
): NewProgramSubtitles[] {
const subtitleStreams =
item.mediaItem?.streams.filter(
(s) =>
s.streamType === 'subtitles' || s.streamType === 'external_subtitles',
) ?? [];
const additionalSubtitles = item.externalSubtitles ?? [];
const now = dayjs().toDate();
const mappedStreams = subtitleStreams.map((subtitle) => {
return {
uuid: v4(),
programId,
createdAt: now,
updatedAt: now, // Do we need to use mtime?
language: subtitle.languageCodeISO6392 ?? 'unknown',
subtitleType:
subtitle.streamType === 'subtitles' ? 'embedded' : 'sidecar',
default: subtitle.default ?? false,
forced: subtitle.forced ?? false,
path: subtitle.fileName,
sdh: subtitle.sdh ?? false,
streamIndex:
subtitle.streamType === 'external_subtitles' ? null : subtitle.index,
codec: subtitle.codec,
} satisfies NewProgramSubtitles;
});
const mappedAdditional = additionalSubtitles.map((subtitle) => {
return {
codec: subtitle.codec,
createdAt: now,
updatedAt: now, // Do we need to use mtime?
language: subtitle.language,
subtitleType: subtitle.subtitleType,
default: subtitle.default ?? false,
forced: subtitle.forced ?? false,
path: subtitle.path,
sdh: subtitle.sdh ?? false,
streamIndex: subtitle.streamIndex,
uuid: v4(),
programId,
} satisfies NewProgramSubtitles;
});
return [...mappedStreams, ...mappedAdditional];
}
mintEpisode(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
episode: MediaSourceEpisode,
mediaSource: MediaSourceOrm,
mediaLibrary: MediaSourceLibraryOrm,
episode: Episode,
now: number = +dayjs(),
): NewProgramWithRelations<'episode'> {
const programId = v4();
const now = +dayjs();
const newEpisode = {
uuid: programId,
sourceType: episode.sourceType,
externalKey: episode.externalKey,
externalKey: episode.externalId,
originalAirDate: episode.releaseDate
? dayjs(episode.releaseDate).format()
: null,
duration: episode.duration,
duration: episode.duration ?? 0,
// filePath: file?.file ?? null,
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
@@ -365,21 +423,21 @@ export class ProgramDaoMinter {
}
mintMusicTrack(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
mediaSource: MediaSourceOrm,
mediaLibrary: MediaSourceLibraryOrm,
track: MediaSourceMusicTrack,
now: number = +dayjs(),
): NewProgramWithRelations<'track'> {
const programId = v4();
const now = +dayjs();
const newTrack = {
uuid: programId,
sourceType: track.sourceType,
externalKey: track.externalKey,
externalKey: track.externalId,
originalAirDate: track.releaseDate
? dayjs(track.releaseDate)?.format()
: null,
duration: track.duration,
duration: track.duration ?? 0,
// filePath: file?.file ?? null,
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
@@ -404,8 +462,8 @@ export class ProgramDaoMinter {
}
mintOtherVideo(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
mediaSource: MediaSourceOrm,
mediaLibrary: MediaSourceLibraryOrm,
video: MediaSourceOtherVideo,
): NewProgramWithRelations<'other_video'> {
const programId = v4();
@@ -413,11 +471,11 @@ export class ProgramDaoMinter {
const newVideo = {
uuid: programId,
sourceType: video.sourceType,
externalKey: video.externalKey,
externalKey: video.externalId,
originalAirDate: video.releaseDate
? dayjs(video.releaseDate)?.format()
: null,
duration: video.duration,
duration: video.duration ?? 0,
// filePath: file?.file ?? null,
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
@@ -442,8 +500,8 @@ export class ProgramDaoMinter {
}
private mintProgramForPlexMovie(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
mediaSource: MediaSourceOrm,
mediaLibrary: MediaSourceLibraryOrm,
plexMovie: ApiPlexMovie,
): NewProgramDao {
const file = first(first(plexMovie.Media)?.Part ?? []);
@@ -471,7 +529,7 @@ export class ProgramDaoMinter {
}
private mintProgramForJellyfinItem(
mediaSource: MediaSource,
mediaSource: MediaSourceOrm,
item: Omit<JellyfinItem, 'Type'> & {
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
},
@@ -523,8 +581,8 @@ export class ProgramDaoMinter {
}
private mintProgramForPlexEpisode(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
mediaSource: MediaSourceOrm,
mediaLibrary: MediaSourceLibraryOrm,
plexEpisode: PlexEpisode,
): NewProgramDao {
const file = first(first(plexEpisode.Media)?.Part ?? []);
@@ -558,8 +616,8 @@ export class ProgramDaoMinter {
}
private mintProgramForPlexTrack(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
mediaSource: MediaSourceOrm,
mediaLibrary: MediaSourceLibraryOrm,
plexTrack: PlexMusicTrack,
): NewProgramDao {
const file = first(first(plexTrack.Media)?.Part ?? []);
@@ -608,6 +666,7 @@ export class ProgramDaoMinter {
.with({ externalSourceType: 'emby' }, () =>
this.mintEmbyExternalIds(serverName, serverId, programId, program),
)
.with({ externalSourceType: 'local' }, () => [])
.exhaustive();
}

View File

@@ -2,33 +2,27 @@
// but contain a bit more context and are used during an
// active streaming session
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import { tag } from '@tunarr/types';
import { ContentProgramTypeSchema } from '@tunarr/types/schemas';
import type { StrictOmit } from 'ts-essentials';
import { z } from 'zod/v4';
import type { MarkRequired, StrictOmit } from 'ts-essentials';
import type { EmbyT, JellyfinT } from '../../types/internal.ts';
import type { MediaSourceId } from '../schema/base.ts';
import type { MarkNotNilable } from '../../types/util.ts';
import { MediaSourceType } from '../schema/base.js';
import type {
ProgramWithRelationsOrm,
SpecificProgramSourceOrmType,
} from '../schema/derivedTypes.ts';
import type { ProgramType } from '../schema/Program.ts';
const baseStreamLineupItemSchema = z.object({
streamDuration: z
.number()
.nonnegative()
.describe('The amount of time left in the stream'),
// beginningOffset: z.number().nonnegative().optional(),
title: z.string().optional(),
startOffset: z
.number()
.nonnegative()
.optional()
.describe('How far into the stream item'),
programBeginMs: z
.number()
.nonnegative()
.describe('The time the stream item started'),
duration: z.number().nonnegative().describe('The whole duration of the item'),
});
type BaseStreamLineupItem = {
streamDuration: number;
startOffset?: number;
programBeginMs: number;
duration: number;
};
export type StreamLineupProgram = MarkNotNilable<
MarkRequired<ProgramWithRelationsOrm, 'externalIds'>,
'mediaSourceId'
>;
export function isOfflineLineupItem(
item: StreamLineupItem,
@@ -59,7 +53,7 @@ export function isPlexBackedLineupItem(
): item is PlexBackedStreamLineupItem {
return (
isContentBackedLineupItem(item) &&
item.externalSource === MediaSourceType.Plex
item.program.sourceType === MediaSourceType.Plex
);
}
@@ -68,7 +62,7 @@ export function isJellyfinBackedLineupItem(
): item is SpecificSourceContentBackedStreamLineupItem<JellyfinT> {
return (
isContentBackedLineupItem(item) &&
item.externalSource === MediaSourceType.Jellyfin
item.program.sourceType === MediaSourceType.Jellyfin
);
}
@@ -77,7 +71,7 @@ export function isEmnyBackedLineupItem(
): item is SpecificSourceContentBackedStreamLineupItem<EmbyT> {
return (
isContentBackedLineupItem(item) &&
item.externalSource === MediaSourceType.Emby
item.program.sourceType === MediaSourceType.Emby
);
}
@@ -97,8 +91,8 @@ export type MinimalContentStreamLineupItem = {
export type SpecificSourceContentBackedStreamLineupItem<
Typ extends MediaSourceType,
> = StrictOmit<ContentBackedStreamLineupItem, 'externalSource'> & {
externalSource: Typ;
> = StrictOmit<ContentBackedStreamLineupItem, 'program'> & {
program: SpecificProgramSourceOrmType<Typ, StreamLineupProgram>;
};
export type PlexBackedStreamLineupItem =
@@ -110,93 +104,47 @@ export type SpecificMinimalContentStreamLineupItem<
externalSource: Typ;
};
export type MinimalPlexBackedStreamLineupItem =
SpecificMinimalContentStreamLineupItem<typeof MediaSourceType.Plex>;
export const OfflineStreamLineupItemSchema = baseStreamLineupItemSchema.extend({
type: z.literal('offline'),
});
export type OfflineStreamLineupItem = z.infer<
typeof OfflineStreamLineupItemSchema
export type MinimalPlexBackedStreamLineupItem = SpecificProgramSourceOrmType<
typeof MediaSourceType.Plex,
StreamLineupProgram
>;
const BaseContentBackedStreamLineupItemSchema =
baseStreamLineupItemSchema.extend({
// ID in the program DB table
programId: z.uuid(),
// These are taken from the Program DB entity
plexFilePath: z.string().optional(),
externalSourceId: z.string().transform((s) => tag<MediaSourceId>(s)),
filePath: z.string().optional(),
externalKey: z.string(),
programType: ContentProgramTypeSchema,
externalSource: z.enum(MediaSourceType),
infiniteLoop: z.boolean(),
contentDuration: z.number().describe('The duration of the content itself'),
});
export type OfflineStreamLineupItem = BaseStreamLineupItem & {
type: 'offline';
duration: number;
};
const CommercialStreamLineupItemSchema =
BaseContentBackedStreamLineupItemSchema.extend({
type: z.literal('commercial'),
fillerId: z.string(),
});
type BaseContentBackedStreamLineupItem = BaseStreamLineupItem & {
program: StreamLineupProgram;
infiniteLoop: boolean;
};
export type CommercialStreamLineupItem = z.infer<
typeof CommercialStreamLineupItemSchema
>;
export type CommercialStreamLineupItem = BaseContentBackedStreamLineupItem & {
type: 'commercial';
fillerId: string;
};
const ProgramStreamLineupItemSchema =
BaseContentBackedStreamLineupItemSchema.extend({
type: z.literal('program'),
}).required({ title: true });
export type ProgramStreamLineupItem = BaseContentBackedStreamLineupItem & {
type: 'program';
};
export type ProgramStreamLineupItem = z.infer<
typeof ProgramStreamLineupItemSchema
>;
export type RedirectStreamLineupItem = BaseStreamLineupItem & {
type: 'redirect';
channel: string;
duration: number;
};
export const RedirectStreamLineupItemSchema = baseStreamLineupItemSchema.extend(
{
type: z.literal('redirect'),
channel: z.string().uuid(),
duration: z.number().positive(),
},
);
export type ErrorStreamLineupItem = BaseStreamLineupItem & {
type: 'error';
error: Error | string | boolean;
};
export const ErrorStreamLineupItemSchema = baseStreamLineupItemSchema.extend({
type: z.literal('error'),
error: z.instanceof(Error).or(z.string()).or(z.boolean()),
});
export type RedirectStreamLineupItem = z.infer<
typeof RedirectStreamLineupItemSchema
>;
export const StreamLineupItemSchema = z.discriminatedUnion('type', [
ProgramStreamLineupItemSchema,
CommercialStreamLineupItemSchema,
OfflineStreamLineupItemSchema,
RedirectStreamLineupItemSchema,
ErrorStreamLineupItemSchema,
]);
export type StreamLineupItem = z.infer<typeof StreamLineupItemSchema>;
// Subset of StreamLineupItem that only includes valid lineup.json item
// types with additional details + error type.
// This is still a little messy because we have a lot of very similar
// versions of the same type flying around -- a remnant of the untyped
// nature of the original DTV -- this can slowly be unraveled and/or
// consolidated as we rewrite pieces of the streaming pipeline.
export const EnrichedLineupItemSchema = z.discriminatedUnion('type', [
ProgramStreamLineupItemSchema,
CommercialStreamLineupItemSchema,
OfflineStreamLineupItemSchema,
RedirectStreamLineupItemSchema,
ErrorStreamLineupItemSchema,
]);
export type EnrichedLineupItem = z.infer<typeof EnrichedLineupItemSchema>;
export type StreamLineupItem =
| ProgramStreamLineupItem
| CommercialStreamLineupItem
| OfflineStreamLineupItem
| RedirectStreamLineupItem
| ErrorStreamLineupItem;
export function createOfflineStreamLineupItem(
duration: number,

View File

@@ -8,7 +8,7 @@ import type { Channel } from '@/db/schema/Channel.js';
import type { ProgramDao } from '@/db/schema/Program.js';
import type { ProgramExternalId } from '@/db/schema/ProgramExternalId.js';
import type {
ChannelWithPrograms,
ChannelOrmWithRelations,
ChannelWithRelations,
MusicArtistWithExternalIds,
ProgramWithRelations,
@@ -57,7 +57,7 @@ export interface IChannelDB {
getChannelAndPrograms(
uuid: string,
typeFilter?: ContentProgramType,
): Promise<ChannelWithPrograms | undefined>;
): Promise<Maybe<MarkRequired<ChannelOrmWithRelations, 'programs'>>>;
getChannelTvShows(
id: string,

View File

@@ -14,23 +14,35 @@ import type {
import type { ProgramExternalIdSourceType } from '@/db/schema/base.js';
import type {
MusicAlbumWithExternalIds,
NewProgramGroupingWithExternalIds,
NewProgramGroupingWithRelations,
NewProgramVersion,
NewProgramWithRelations,
ProgramGroupingOrmWithRelations,
ProgramGroupingWithExternalIds,
ProgramWithExternalIds,
ProgramWithRelations,
ProgramWithRelationsOrm,
TvSeasonWithExternalIds,
} from '@/db/schema/derivedTypes.js';
import type { MarkNonNullable, Maybe, PagedResult } from '@/types/util.js';
import type { ChannelProgram } from '@tunarr/types';
import type { Dictionary, MarkOptional } from 'ts-essentials';
import type { MediaSourceType } from '../schema/MediaSource.ts';
import type {
Dictionary,
MarkOptional,
MarkRequired,
StrictExclude,
} from 'ts-essentials';
import type { NewArtwork } from '../schema/Artwork.ts';
import type { RemoteMediaSourceType } from '../schema/MediaSource.ts';
import type { ProgramGroupingType } from '../schema/ProgramGrouping.ts';
import type { MediaSourceId } from '../schema/base.ts';
import type { MediaSourceId, MediaSourceType } from '../schema/base.js';
import type { PageParams } from './IChannelDB.ts';
export interface IProgramDB {
getProgramById(id: string): Promise<Maybe<ProgramWithExternalIds>>;
// TODO: Allow null narrowing on mediaSourceId
getProgramById(
id: string,
): Promise<Maybe<MarkRequired<ProgramWithRelationsOrm, 'externalIds'>>>;
getProgramExternalIds(
id: string,
@@ -44,11 +56,16 @@ export interface IProgramDB {
getProgramsByIds(
ids: string[],
batchSize?: number,
): Promise<ProgramWithRelationsOrm[]>;
getProgramsByIdsOld(
ids: string[],
batchSize?: number,
): Promise<ProgramWithRelations[]>;
getProgramGrouping(
id: string,
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
): Promise<Maybe<ProgramGroupingOrmWithRelations>>;
getProgramGroupings(
ids: string[],
@@ -89,17 +106,17 @@ export interface IProgramDB {
sourceType: ProgramSourceType;
externalSourceId: string;
externalKey: string;
}): Promise<Maybe<ProgramWithRelations>>;
}): Promise<Maybe<MarkRequired<ProgramWithRelationsOrm, 'externalIds'>>>;
lookupByExternalIds(
ids:
| Set<[string, MediaSourceId, string]>
| Set<readonly [string, MediaSourceId, string]>,
chunkSize?: number,
): Promise<ProgramWithRelations[]>;
): Promise<MarkRequired<ProgramWithRelationsOrm, 'externalIds'>[]>;
lookupByMediaSource(
sourceType: MediaSourceType,
sourceType: RemoteMediaSourceType,
sourceId: MediaSourceId,
mediaType?: ProgramType,
chunkSize?: number,
@@ -136,9 +153,16 @@ export interface IProgramDB {
): Promise<MarkNonNullable<ProgramDao, 'mediaSourceId'>[]>;
upsertPrograms(
programs: ProgramUpsertRequest[],
program: NewProgramWithRelations,
): Promise<ProgramWithExternalIds>;
upsertPrograms(
programs: NewProgramWithRelations | NewProgramWithRelations[],
programUpsertBatchSize?: number,
): Promise<ProgramWithExternalIds[]>;
upsertPrograms(
programs: NewProgramWithRelations | NewProgramWithRelations[],
programUpsertBatchSize?: number,
): Promise<NewProgramWithRelations | ProgramWithExternalIds[]>;
programIdsByExternalIds(
ids: Set<[string, string, string]>,
@@ -167,14 +191,19 @@ export interface IProgramDB {
getProgramGroupingCanonicalIds(
mediaSourceLibraryId: string,
type: ProgramGroupingType,
sourceType: MediaSourceType,
sourceType: StrictExclude<MediaSourceType, 'local'>,
): Promise<Dictionary<ProgramGroupingCanonicalIdLookupResult>>;
getOrInsertProgramGrouping(
dao: NewProgramGroupingWithExternalIds,
upsertProgramGrouping(
newGroupingAndRelations: NewProgramGroupingWithRelations,
externalId: ProgramGroupingExternalIdLookup,
forceUpdate?: boolean,
): Promise<GetOrInsertResult<ProgramGroupingWithExternalIds>>;
): Promise<UpsertResult<ProgramGroupingWithExternalIds>>;
upsertLocalProgramGrouping(
newGroupingAndRelations: NewProgramGroupingWithRelations,
libraryId: string,
): Promise<UpsertResult<ProgramGroupingWithExternalIds>>;
getShowSeasons(showUuid: string): Promise<ProgramGroupingWithExternalIds[]>;
@@ -189,7 +218,7 @@ export interface IProgramDB {
getProgramGroupingDescendants(
groupId: string,
groupTypeHint?: ProgramGroupingType,
): Promise<ProgramWithExternalIds[]>;
): Promise<ProgramWithRelationsOrm[]>;
}
export type WithChannelIdFilter<T> = T & {
@@ -215,7 +244,7 @@ export type ProgramGroupingExternalIdLookup = {
externalSourceId: MediaSourceId;
};
export type GetOrInsertResult<Entity> = {
export type UpsertResult<Entity> = {
wasInserted: boolean;
wasUpdated: boolean;
entity: Entity;
@@ -231,4 +260,5 @@ export type ProgramUpsertRequest = {
program: NewProgramDao;
externalIds: NewSingleOrMultiExternalId[];
versions: NewProgramVersion[];
artwork?: NewArtwork[];
};

View File

@@ -7,10 +7,10 @@ import type {
import dayjs from 'dayjs';
import {
chunk,
differenceWith,
first,
isEmpty,
isNil,
isUndefined,
keys,
map,
mapValues,
@@ -22,20 +22,22 @@ import { v4 } from 'uuid';
import { type IChannelDB } from '@/db/interfaces/IChannelDB.js';
import { KEYS } from '@/types/inject.js';
import { booleanToNumber } from '@/util/sqliteUtil.js';
import { retag, tag } from '@tunarr/types';
import { tag } from '@tunarr/types';
import { inject, injectable, interfaces } from 'inversify';
import { Kysely } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/sqlite';
import { MarkRequired } from 'ts-essentials';
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.ts';
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
import { withLibraries } from './mediaSourceQueryHelpers.ts';
import {
withProgramChannels,
withProgramCustomShows,
withProgramFillerShows,
} from './programQueryHelpers.ts';
import { MediaSourceId, MediaSourceName } from './schema/base.ts';
import {
MediaSourceId,
MediaSourceName,
MediaSourceType,
} from './schema/base.js';
import { DB } from './schema/db.ts';
import {
EmbyMediaSource,
@@ -43,12 +45,10 @@ import {
MediaSourceWithLibraries,
PlexMediaSource,
} from './schema/derivedTypes.js';
import { DrizzleDBAccess } from './schema/index.ts';
import {
MediaSource,
MediaSourceFields,
MediaSourceLibrary,
MediaSourceLibraryUpdate,
MediaSourceType,
MediaSourceOrm,
MediaSourceUpdate,
NewMediaSourceLibrary,
} from './schema/MediaSource.ts';
@@ -76,47 +76,40 @@ export class MediaSourceDB {
@inject(KEYS.Database) private db: Kysely<DB>,
@inject(KEYS.MediaSourceLibraryRefresher)
private mediaSourceLibraryRefresher: interfaces.AutoFactory<MediaSourceLibraryRefresher>,
@inject(KEYS.DrizzleDB)
private drizzleDB: DrizzleDBAccess,
) {}
async getAll(): Promise<MediaSourceWithLibraries[]> {
return this.db
.selectFrom('mediaSource')
.select(withLibraries)
.selectAll()
.execute();
return this.drizzleDB.query.mediaSource.findMany({
with: {
libraries: true,
paths: true,
},
});
}
async getById(id: MediaSourceId): Promise<Maybe<MediaSourceWithLibraries>> {
return this.db
.selectFrom('mediaSource')
.select(withLibraries)
.selectAll()
.where('mediaSource.uuid', '=', id)
.executeTakeFirst();
return this.drizzleDB.query.mediaSource.findFirst({
where: (ms, { eq }) => eq(ms.uuid, id),
with: {
libraries: true,
paths: true,
},
});
}
async getLibrary(id: string) {
return (
this.db
.selectFrom('mediaSourceLibrary')
.where('uuid', '=', id)
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('mediaSource')
.whereRef(
'mediaSource.uuid',
'=',
'mediaSourceLibrary.mediaSourceId',
)
.select(MediaSourceFields),
).as('mediaSource'),
)
.selectAll()
// Should be safe before of referential integrity of foreign keys
.$narrowType<{ mediaSource: MediaSource }>()
.executeTakeFirst()
);
return this.drizzleDB.query.mediaSourceLibrary.findFirst({
where: (lib, { eq }) => eq(lib.uuid, id),
with: {
mediaSource: {
with: {
paths: true,
},
},
},
});
}
async findByType(
@@ -140,15 +133,19 @@ export class MediaSourceDB {
type: MediaSourceType,
nameOrId?: MediaSourceId,
): Promise<MediaSourceWithLibraries[] | Maybe<MediaSourceWithLibraries>> {
const found = await this.db
.selectFrom('mediaSource')
.selectAll()
.select(withLibraries)
.where('mediaSource.type', '=', type)
.$if(isNonEmptyString(nameOrId), (qb) =>
qb.where('mediaSource.uuid', '=', retag<MediaSourceId>(nameOrId!)),
)
.execute();
const found = await this.drizzleDB.query.mediaSource.findMany({
where: (ms, { eq, and }) => {
if (isNonEmptyString(nameOrId)) {
return and(eq(ms.type, type), eq(ms.uuid, nameOrId));
} else {
return eq(ms.type, type);
}
},
with: {
libraries: true,
paths: true,
},
});
if (isNonEmptyString(nameOrId)) {
return first(found);
@@ -189,39 +186,96 @@ export class MediaSourceDB {
return { deletedServer };
}
async updateMediaSource(server: UpdateMediaSourceRequest) {
const id = server.id;
async updateMediaSource(updateReq: UpdateMediaSourceRequest) {
const id = tag<MediaSourceId>(updateReq.id);
const mediaSource = await this.getById(tag(id));
const mediaSource = await this.getById(id);
if (isNil(mediaSource)) {
throw new Error("Server doesn't exist.");
}
const sendGuideUpdates =
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
const sendChannelUpdates =
server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
if (updateReq.type === 'local') {
await this.db.transaction().execute(async (tx) => {
await tx
.updateTable('mediaSource')
.set({
mediaType: updateReq.mediaType,
name: tag<MediaSourceName>(updateReq.name),
})
.where('mediaSource.uuid', '=', id)
.executeTakeFirstOrThrow();
await this.db
.updateTable('mediaSource')
.set({
name: tag<MediaSourceName>(server.name),
uri: trimEnd(server.uri, '/'),
accessToken: server.accessToken,
sendGuideUpdates: booleanToNumber(sendGuideUpdates),
sendChannelUpdates: booleanToNumber(sendChannelUpdates),
updatedAt: +dayjs(),
// This allows clearing the values
userId: server.userId,
username: server.username,
} satisfies MediaSourceUpdate)
.where('uuid', '=', tag<MediaSourceId>(server.id))
// TODO: Blocked on https://github.com/oven-sh/bun/issues/16909
// .limit(1)
.executeTakeFirst();
const newPaths = differenceWith(
updateReq.paths,
mediaSource.libraries,
(incomingPath, { externalKey }) => incomingPath === externalKey,
);
const deletePaths = differenceWith(
mediaSource.libraries,
updateReq.paths,
({ externalKey }, incomingPath) => externalKey === incomingPath,
).map(({ externalKey }) => externalKey);
this.mediaSourceApiFactory().deleteCachedClient(mediaSource);
if (deletePaths.length > 0) {
await tx
.deleteFrom('mediaSourceLibrary')
.where(
'mediaSourceLibrary.mediaSourceId',
'=',
tag<MediaSourceId>(updateReq.id),
)
.where('mediaSourceLibrary.externalKey', 'in', deletePaths)
.executeTakeFirstOrThrow();
}
if (newPaths.length > 0) {
await tx
.insertInto('mediaSourceLibrary')
.values(
newPaths.map((path) => ({
externalKey: path,
mediaSourceId: mediaSource.uuid,
mediaType: updateReq.mediaType,
name: path,
uuid: v4(),
enabled: booleanToNumber(true),
lastScannedAt: null,
})),
)
.executeTakeFirstOrThrow();
}
});
} else {
const sendGuideUpdates =
updateReq.type === 'plex'
? (updateReq.sendGuideUpdates ?? false)
: false;
const sendChannelUpdates =
updateReq.type === 'plex'
? (updateReq.sendChannelUpdates ?? false)
: false;
await this.db
.updateTable('mediaSource')
.set({
name: tag<MediaSourceName>(updateReq.name),
uri: trimEnd(updateReq.uri, '/'),
accessToken: updateReq.accessToken,
sendGuideUpdates: booleanToNumber(sendGuideUpdates),
sendChannelUpdates: booleanToNumber(sendChannelUpdates),
updatedAt: +dayjs(),
// This allows clearing the values
userId: updateReq.userId,
username: updateReq.username,
} satisfies MediaSourceUpdate)
.where('uuid', '=', tag<MediaSourceId>(updateReq.id))
// TODO: Blocked on https://github.com/oven-sh/bun/issues/16909
// .limit(1)
.executeTakeFirstOrThrow();
this.mediaSourceApiFactory().deleteCachedClient(mediaSource);
}
const report = await this.fixupProgramReferences(
tag(id),
@@ -251,13 +305,18 @@ export class MediaSourceDB {
}
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
const name = tag<MediaSourceName>(
isUndefined(server.name) ? 'plex' : server.name,
);
const name = tag<MediaSourceName>(server.name);
const sendGuideUpdates =
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
const sendChannelUpdates =
server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
if (server.type === 'local' && isEmpty(server.paths)) {
throw new Error(
'Must have at least one path specified for a local media source',
);
}
const index = await this.db
.selectFrom('mediaSource')
.select((eb) => eb.fn.count<number>('uuid').as('count'))
@@ -265,24 +324,60 @@ export class MediaSourceDB {
.then((_) => _?.count ?? 0);
const now = +dayjs();
const newServer = await this.db
.insertInto('mediaSource')
.values({
...server,
uuid: tag<MediaSourceId>(v4()),
name,
uri: trimEnd(server.uri, '/'),
sendChannelUpdates: sendChannelUpdates ? 1 : 0,
sendGuideUpdates: sendGuideUpdates ? 1 : 0,
createdAt: now,
updatedAt: now,
index,
type: server.type,
userId: isNonEmptyString(server.userId) ? server.userId : null,
username: isNonEmptyString(server.username) ? server.username : null,
})
.returning('uuid')
.executeTakeFirstOrThrow();
const newServer = await this.db.transaction().execute(async (tx) => {
const newServer = await tx
.insertInto('mediaSource')
.values({
// ...server,
uuid: tag<MediaSourceId>(v4()),
name,
uri: server.type === 'local' ? '' : trimEnd(server.uri, '/'),
sendChannelUpdates: sendChannelUpdates ? 1 : 0,
sendGuideUpdates: sendGuideUpdates ? 1 : 0,
createdAt: now,
updatedAt: now,
index,
type: server.type,
userId:
server.type === 'local'
? null
: isNonEmptyString(server.userId)
? server.userId
: null,
username:
server.type === 'local'
? null
: isNonEmptyString(server.username)
? server.username
: null,
accessToken: server.type === 'local' ? '' : server.accessToken,
mediaType: server.type === 'local' ? server.mediaType : null,
})
.returning('uuid')
.executeTakeFirstOrThrow();
if (server.type === 'local') {
await tx
.insertInto('mediaSourceLibrary')
.values(
server.paths.map(
(path) =>
({
externalKey: path,
mediaSourceId: newServer.uuid,
mediaType: server.mediaType,
name: path,
uuid: v4(),
enabled: booleanToNumber(true),
lastScannedAt: null,
}) satisfies NewMediaSourceLibrary,
),
)
.executeTakeFirstOrThrow();
}
return newServer;
});
await this.mediaSourceLibraryRefresher().refreshMediaSource(newServer.uuid);
@@ -299,13 +394,6 @@ export class MediaSourceDB {
}
if (updates.updatedLibraries.length > 0) {
// await tx.updateTable('mediaSourceLibrary').set(({eb}) => {
// return reduce(updates.updatedLibraries, (builder, lib) => {
// builder.when('mediaSourceLibrary.uuid', '=', lib.uuid).then({
// })
// }, eb.case() as unknown as CaseWhenBuilder<DB, 'mediaSourceLibrary', unknown, number>).end()
// }).execute()
for (const update of updates.updatedLibraries) {
await tx
.updateTable('mediaSourceLibrary')
@@ -318,11 +406,7 @@ export class MediaSourceDB {
if (updates.deletedLibraries.length > 0) {
await tx
.deleteFrom('mediaSourceLibrary')
.where(
'uuid',
'in',
updates.deletedLibraries.map((lib) => lib.uuid),
)
.where('uuid', 'in', updates.deletedLibraries)
.execute();
}
});
@@ -357,7 +441,7 @@ export class MediaSourceDB {
private async fixupProgramReferences(
serverId: MediaSourceId,
serverType: MediaSourceType,
newServer?: MediaSource,
newServer?: MediaSourceOrm,
) {
// TODO: We need to update this to:
// 1. handle different source types
@@ -479,5 +563,5 @@ export class MediaSourceDB {
export type MediaSourceLibrariesUpdate = {
addedLibraries: NewMediaSourceLibrary[];
updatedLibraries: MarkRequired<MediaSourceLibraryUpdate, 'uuid'>[];
deletedLibraries: MediaSourceLibrary[];
deletedLibraries: string[];
};

View File

@@ -11,3 +11,17 @@ export function withLibraries(eb: ExpressionBuilder<DB, 'mediaSource'>) {
.select(MediaSourceLibraryColumns),
).as('libraries');
}
export function withPaths(eb: ExpressionBuilder<DB, 'mediaSource'>) {
return jsonArrayFrom(
eb
.selectFrom('localMediaSourcePath')
.whereRef('localMediaSourcePath.mediaSourceId', '=', 'mediaSource.uuid')
.select([
'localMediaSourcePath.uuid',
'localMediaSourcePath.path',
'localMediaSourcePath.lastScannedAt',
'localMediaSourcePath.mediaSourceId',
]),
).as('paths');
}

View File

@@ -23,7 +23,8 @@ export function createPendingProgramIndexMap(
isContentProgram(p) &&
isNonEmptyString(p.externalSourceId) &&
isNonEmptyString(p.externalSourceType) &&
isNonEmptyString(p.externalKey)
isNonEmptyString(p.externalKey) &&
p.externalSourceType !== 'local'
) {
acc[
createExternalId(

View File

@@ -0,0 +1,19 @@
import { v4 } from 'uuid';
import { getProgramGroupingUpsertFields } from './programQueryHelpers.ts';
import { ProgramGroupingUpdate } from './schema/ProgramGrouping.ts';
describe('getProgramGroupingUpsertFields', () => {
test('should not override undefined fields on partial updates', () => {
const update: ProgramGroupingUpdate = {
uuid: v4(),
summary: 'new summary',
artistUuid: null,
year: undefined, // Explicit so it's clear what we're testing
};
expect(getProgramGroupingUpsertFields(update)).toEqual([
'excluded.summary',
'excluded.artistUUid',
]);
});
});

View File

@@ -1,3 +1,4 @@
import { seq } from '@tunarr/shared/util';
import { type TupleToUnion } from '@tunarr/types';
import type {
CaseWhenBuilder,
@@ -9,7 +10,15 @@ import type {
UpdateResult,
} from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite';
import { identity, isBoolean, isEmpty, keys, merge, reduce } from 'lodash-es';
import {
identity,
isBoolean,
isEmpty,
isUndefined,
keys,
merge,
reduce,
} from 'lodash-es';
import type { DeepPartial, DeepRequired, StrictExclude } from 'ts-essentials';
import type { Replace } from '../types/util.ts';
import type { FillerShowTable as RawFillerShow } from './schema/FillerShow.js';
@@ -20,9 +29,12 @@ import type {
import { ProgramType } from './schema/Program.ts';
import type { ProgramExternalId } from './schema/ProgramExternalId.ts';
import { ProgramExternalIdFieldsWithAlias } from './schema/ProgramExternalId.ts';
import type { ProgramGroupingFields } from './schema/ProgramGrouping.ts';
import type {
ProgramGrouping,
ProgramGroupingFields,
ProgramGroupingUpdate,
} from './schema/ProgramGrouping.ts';
import {
AllProgramGroupingFields,
AllProgramGroupingFieldsAliased,
ProgramGroupingType,
} from './schema/ProgramGrouping.ts';
@@ -308,6 +320,8 @@ export const AllProgramFields = [
'program.sourceType',
'program.tvShowUuid',
'program.mediaSourceId',
'program.localMediaFolderId',
'program.localMediaSourcePathId',
] as const;
type ProgramUpsertFields = StrictExclude<
@@ -318,11 +332,10 @@ type ProgramUpsertFields = StrictExclude<
const ProgramUpsertIgnoreFields = [
'program.uuid',
'program.createdAt',
'program.tvShowUuid',
'program.albumUuid',
'program.artistUuid',
'program.seasonUuid',
// 'program.libraryId',
// 'program.tvShowUuid',
// 'program.albumUuid',
// 'program.artistUuid',
// 'program.seasonUuid',
] as const;
type KnownProgramUpsertFields = StrictExclude<
@@ -343,6 +356,69 @@ export const ProgramUpsertFields: ProgramUpsertFields[] =
>,
);
type ProgramGroupingField = `programGrouping.${keyof ProgramGrouping}`;
type ProgramGroupingUpsertFields = StrictExclude<
Replace<ProgramGroupingField, 'programGrouping', 'excluded'>,
'excluded.uuid' | 'excluded.createdAt'
>;
export const AllProgramGroupingFields = [
'programGrouping.uuid',
'programGrouping.canonicalId',
'programGrouping.createdAt',
'programGrouping.updatedAt',
'programGrouping.icon',
'programGrouping.index',
'programGrouping.summary',
'programGrouping.title',
'programGrouping.type',
'programGrouping.year',
'programGrouping.artistUuid',
'programGrouping.showUuid',
'programGrouping.libraryId',
'programGrouping.sourceType',
'programGrouping.mediaSourceId',
'programGrouping.externalKey',
] as const;
const ProgramGroupingUpsertIgnoreFields = [
'programGrouping.uuid',
'programGrouping.createdAt',
] as const;
type KnownProgramGroupingUpsertFields = StrictExclude<
TupleToUnion<typeof AllProgramGroupingFields>,
TupleToUnion<typeof ProgramGroupingUpsertIgnoreFields>
>;
export function getProgramGroupingUpsertFields(
update: ProgramGroupingUpdate,
): ProgramGroupingUpsertFields[] {
const withoutExcluded = AllProgramGroupingFields.filter(
(f): f is KnownProgramGroupingUpsertFields => {
return !(
ProgramGroupingUpsertIgnoreFields as ReadonlyArray<ProgramGroupingField>
).includes(f);
},
);
return seq.collect(withoutExcluded, (field) => {
const name = field.replace('programGrouping.', '') as Replace<
KnownProgramGroupingUpsertFields,
'programGrouping.',
''
>;
if (isUndefined(update[name])) {
return;
}
return `excluded.${name}` as Replace<
typeof field,
'programGrouping',
'excluded'
>;
});
}
export type WithProgramsOptions = {
joins?: Partial<ProgramJoins>;
fields?: ProgramFields;

View File

@@ -0,0 +1,68 @@
import type { TupleToUnion } from '@tunarr/types';
import {
relations,
type InferInsertModel,
type InferSelectModel,
} from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { Program } from './Program.ts';
import { ProgramGrouping } from './ProgramGrouping.ts';
export const ArtworkTypes = [
'poster',
'thumbnail',
'logo',
'fanart',
'watermark',
'banner',
'landscape',
] as const;
export type ArtworkType = TupleToUnion<typeof ArtworkTypes>;
type ArtworkTypeMap = {
[K in Capitalize<ArtworkType>]: Lowercase<K>;
};
export const ArtworkType: ArtworkTypeMap = {
Banner: 'banner',
Fanart: 'fanart',
Poster: 'poster',
Thumbnail: 'thumbnail',
Logo: 'logo',
Watermark: 'watermark',
Landscape: 'landscape',
};
export const Artwork = sqliteTable(
'artwork',
{
uuid: text().primaryKey(),
cachePath: text().notNull(),
sourcePath: text().notNull(),
artworkType: text({ enum: ArtworkTypes }).notNull(),
blurHash43: text(),
blurHash64: text(),
programId: text().references(() => Program.uuid, { onDelete: 'cascade' }),
groupingId: text().references(() => ProgramGrouping.uuid, {
onDelete: 'cascade',
}),
createdAt: integer({ mode: 'timestamp_ms' }),
updatedAt: integer({ mode: 'timestamp_ms' }),
},
(table) => [index('artwork_program_idx').on(table.programId)],
);
export const ArtworkRelations = relations(Artwork, ({ one }) => ({
program: one(Program, {
fields: [Artwork.programId],
references: [Program.uuid],
}),
programGrouping: one(ProgramGrouping, {
fields: [Artwork.groupingId],
references: [ProgramGrouping.uuid],
}),
}));
export type Artwork = InferSelectModel<typeof Artwork>;
export type NewArtwork = InferInsertModel<typeof Artwork>;

View File

@@ -1,7 +1,8 @@
import type { InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import {
getTableConfig,
integer,
primaryKey,
sqliteTable,
text,
} from 'drizzle-orm/sqlite-core';
@@ -13,10 +14,10 @@ import {
type ChannelTranscodingSettings,
type ChannelWatermark,
} from './base.ts';
import { CustomShow } from './CustomShow.ts';
import { FillerShow } from './FillerShow.ts';
import { ChannelCustomShow } from './ChannelCustomShow.ts';
import { ChannelFillerShow } from './ChannelFillerShow.ts';
import { ChannelPrograms } from './ChannelPrograms.ts';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { Program } from './Program.ts';
export const Channel = sqliteTable('channel', {
uuid: text().primaryKey(),
@@ -57,75 +58,10 @@ export const AllChannelTableKeys: ChannelFields = ChannelTableKeys.map(
export type Channel = Selectable<ChannelTable>;
export type NewChannel = Insertable<ChannelTable>;
export type ChannelUpdate = Updateable<ChannelTable>;
export type ChannelOrm = InferSelectModel<typeof Channel>;
export const ChannelFillerShow = sqliteTable(
'channel_filler_show',
{
channelUuid: text()
.notNull()
.references(() => Channel.uuid, { onDelete: 'cascade' }),
fillerShowUuid: text()
.notNull()
.references(() => FillerShow.uuid, { onDelete: 'cascade' }),
cooldown: integer().notNull(),
weight: integer().notNull(),
},
(table) => [
primaryKey({ columns: [table.channelUuid, table.fillerShowUuid] }),
],
);
export type ChannelFillerShowTable = KyselifyBetter<typeof ChannelFillerShow>;
export type ChannelFillerShow = Selectable<ChannelFillerShowTable>;
export type NewChannelFillerShow = Insertable<ChannelFillerShowTable>;
export const ChannelFallback = sqliteTable(
'channel_custom_show',
{
channelUuid: text()
.notNull()
.references(() => Channel.uuid, { onDelete: 'cascade' }),
programUuid: text()
.notNull()
.references(() => Program.uuid, { onDelete: 'cascade' }),
},
(table) => [primaryKey({ columns: [table.channelUuid, table.programUuid] })],
);
export type ChannelFallbackTable = KyselifyBetter<typeof ChannelFallback>;
export type ChannelFallback = Selectable<ChannelFallbackTable>;
export const ChannelCustomShow = sqliteTable(
'channel_custom_show',
{
channelUuid: text()
.notNull()
.references(() => Channel.uuid, { onDelete: 'cascade' }),
customShowUuid: text()
.notNull()
.references(() => CustomShow.uuid, { onDelete: 'cascade' }),
},
(table) => [
primaryKey({ columns: [table.channelUuid, table.customShowUuid] }),
],
);
export type ChannelCustomShowsTable = KyselifyBetter<typeof ChannelCustomShow>;
export type ChannelCustomShows = Selectable<ChannelCustomShowsTable>;
export const ChannelPrograms = sqliteTable(
'channel_programs',
{
channelUuid: text()
.notNull()
.references(() => Channel.uuid, { onDelete: 'cascade' }),
programUuid: text()
.notNull()
.references(() => Program.uuid, { onDelete: 'cascade' }),
},
(table) => [primaryKey({ columns: [table.channelUuid, table.programUuid] })],
);
export type ChannelProgramsTable = KyselifyBetter<typeof ChannelPrograms>;
export type ChannelPrograms = Selectable<ChannelProgramsTable>;
export type NewChannelProgram = Insertable<ChannelProgramsTable>;
export const ChannelRelations = relations(Channel, ({ many }) => ({
channelPrograms: many(ChannelPrograms),
channelCustomShows: many(ChannelCustomShow),
channelFillerShow: many(ChannelFillerShow),
}));

View File

@@ -0,0 +1,38 @@
import { relations } from 'drizzle-orm';
import { primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Selectable } from 'kysely';
import { Channel } from './Channel.ts';
import { CustomShow } from './CustomShow.ts';
import type { KyselifyBetter } from './KyselifyBetter.ts';
export const ChannelCustomShow = sqliteTable(
'channel_custom_show',
{
channelUuid: text()
.notNull()
.references(() => Channel.uuid, { onDelete: 'cascade' }),
customShowUuid: text()
.notNull()
.references(() => CustomShow.uuid, { onDelete: 'cascade' }),
},
(table) => [
primaryKey({ columns: [table.channelUuid, table.customShowUuid] }),
],
);
export type ChannelCustomShowsTable = KyselifyBetter<typeof ChannelCustomShow>;
export type ChannelCustomShows = Selectable<ChannelCustomShowsTable>;
export const ChannelCustomShowRelations = relations(
ChannelCustomShow,
({ one }) => ({
channel: one(Channel, {
fields: [ChannelCustomShow.channelUuid],
references: [Channel.uuid],
}),
customShow: one(CustomShow, {
fields: [ChannelCustomShow.customShowUuid],
references: [CustomShow.uuid],
}),
}),
);

View File

@@ -0,0 +1,21 @@
import { primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Selectable } from 'kysely';
import { Channel } from './Channel.ts';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { Program } from './Program.ts';
export const ChannelFallback = sqliteTable(
'channel_custom_show',
{
channelUuid: text()
.notNull()
.references(() => Channel.uuid, { onDelete: 'cascade' }),
programUuid: text()
.notNull()
.references(() => Program.uuid, { onDelete: 'cascade' }),
},
(table) => [primaryKey({ columns: [table.channelUuid, table.programUuid] })],
);
export type ChannelFallbackTable = KyselifyBetter<typeof ChannelFallback>;
export type ChannelFallback = Selectable<ChannelFallbackTable>;

View File

@@ -0,0 +1,46 @@
import { relations } from 'drizzle-orm';
import {
integer,
primaryKey,
sqliteTable,
text,
} from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
import { Channel } from './Channel.ts';
import { FillerShow } from './FillerShow.ts';
import type { KyselifyBetter } from './KyselifyBetter.ts';
export const ChannelFillerShow = sqliteTable(
'channel_filler_show',
{
channelUuid: text()
.notNull()
.references(() => Channel.uuid, { onDelete: 'cascade' }),
fillerShowUuid: text()
.notNull()
.references(() => FillerShow.uuid, { onDelete: 'cascade' }),
cooldown: integer().notNull(),
weight: integer().notNull(),
},
(table) => [
primaryKey({ columns: [table.channelUuid, table.fillerShowUuid] }),
],
);
export type ChannelFillerShowTable = KyselifyBetter<typeof ChannelFillerShow>;
export type ChannelFillerShow = Selectable<ChannelFillerShowTable>;
export type NewChannelFillerShow = Insertable<ChannelFillerShowTable>;
export const ChannelFillerShowRelations = relations(
ChannelFillerShow,
({ one }) => ({
channel: one(Channel, {
fields: [ChannelFillerShow.channelUuid],
references: [Channel.uuid],
}),
filler: one(FillerShow, {
fields: [ChannelFillerShow.fillerShowUuid],
references: [FillerShow.uuid],
}),
}),
);

View File

@@ -0,0 +1,37 @@
import { relations } from 'drizzle-orm';
import { primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
import { Channel } from './Channel.ts';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { Program } from './Program.ts';
export const ChannelPrograms = sqliteTable(
'channel_programs',
{
channelUuid: text()
.notNull()
.references(() => Channel.uuid, { onDelete: 'cascade' }),
programUuid: text()
.notNull()
.references(() => Program.uuid, { onDelete: 'cascade' }),
},
(table) => [primaryKey({ columns: [table.channelUuid, table.programUuid] })],
);
export type ChannelProgramsTable = KyselifyBetter<typeof ChannelPrograms>;
export type ChannelPrograms = Selectable<ChannelProgramsTable>;
export type NewChannelProgram = Insertable<ChannelProgramsTable>;
export const ChannelProgramsRelations = relations(
ChannelPrograms,
({ one }) => ({
channel: one(Channel, {
fields: [ChannelPrograms.channelUuid],
references: [Channel.uuid],
}),
program: one(Program, {
fields: [ChannelPrograms.programUuid],
references: [Program.uuid],
}),
}),
);

View File

@@ -1,10 +1,8 @@
import {
integer,
primaryKey,
sqliteTable,
text,
} from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
import { ChannelCustomShow } from './ChannelCustomShow.ts';
import { CustomShowContent } from './CustomShowContent.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
export const CustomShow = sqliteTable('custom_show', {
@@ -15,24 +13,10 @@ export const CustomShow = sqliteTable('custom_show', {
});
export type CustomShowTable = KyselifyBetter<typeof CustomShow>;
export type CustomShow = Selectable<CustomShowTable>;
export type NewCustomShow = Insertable<CustomShowTable>;
export const CustomShowContent = sqliteTable(
'custom_show_content',
{
contentUuid: text().notNull(),
customShowUuid: text()
.notNull()
.references(() => CustomShow.uuid),
index: integer().notNull(),
},
(table) => [
primaryKey({ columns: [table.contentUuid, table.customShowUuid] }),
],
);
export type CustomShowContentTable = KyselifyBetter<typeof CustomShowContent>;
export type CustomShowContent = Selectable<CustomShowContentTable>;
export type NewCustomShowContent = Insertable<CustomShowContentTable>;
export const CustomShowRelations = relations(CustomShow, ({ many }) => ({
channelCustomShows: many(ChannelCustomShow),
content: many(CustomShowContent),
}));

View File

@@ -0,0 +1,43 @@
import { relations } from 'drizzle-orm';
import {
integer,
primaryKey,
sqliteTable,
text,
} from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
import { CustomShow } from './CustomShow.ts';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { Program } from './Program.ts';
export const CustomShowContent = sqliteTable(
'custom_show_content',
{
contentUuid: text().notNull(),
customShowUuid: text()
.notNull()
.references(() => CustomShow.uuid),
index: integer().notNull(),
},
(table) => [
primaryKey({ columns: [table.contentUuid, table.customShowUuid] }),
],
);
export type CustomShowContentTable = KyselifyBetter<typeof CustomShowContent>;
export type CustomShowContent = Selectable<CustomShowContentTable>;
export type NewCustomShowContent = Insertable<CustomShowContentTable>;
export const CustomShowContentRelations = relations(
CustomShowContent,
({ one }) => ({
program: one(Program, {
fields: [CustomShowContent.contentUuid],
references: [Program.uuid],
}),
customShow: one(CustomShow, {
fields: [CustomShowContent.customShowUuid],
references: [CustomShow.uuid],
}),
}),
);

View File

@@ -1,12 +1,8 @@
import {
integer,
primaryKey,
sqliteTable,
text,
} from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
import { FillerShowContent } from './FillerShowContent.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import { Program } from './Program.ts';
export const FillerShow = sqliteTable('filler_show', {
uuid: text().primaryKey(),
@@ -16,26 +12,9 @@ export const FillerShow = sqliteTable('filler_show', {
});
export type FillerShowTable = KyselifyBetter<typeof FillerShow>;
export type FillerShow = Selectable<FillerShowTable>;
export type NewFillerShow = Insertable<FillerShowTable>;
export const FillerShowContent = sqliteTable(
'filler_show_content',
{
fillerShowUuid: text()
.notNull()
.references(() => FillerShow.uuid),
index: integer().notNull(),
programUuid: text()
.notNull()
.references(() => Program.uuid),
},
(table) => [
primaryKey({ columns: [table.fillerShowUuid, table.programUuid] }),
],
);
export type FillerShowContentTable = KyselifyBetter<typeof FillerShowContent>;
export type FillerShowContent = Selectable<FillerShowContentTable>;
export type NewFillerShowContent = Insertable<FillerShowContentTable>;
export const FillerShowRelations = relations(FillerShow, ({ many }) => ({
fillerShowContent: many(FillerShowContent),
}));

View File

@@ -0,0 +1,45 @@
import { relations } from 'drizzle-orm';
import {
integer,
primaryKey,
sqliteTable,
text,
} from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
import { FillerShow } from './FillerShow.ts';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { Program } from './Program.ts';
export const FillerShowContent = sqliteTable(
'filler_show_content',
{
fillerShowUuid: text()
.notNull()
.references(() => FillerShow.uuid),
index: integer().notNull(),
programUuid: text()
.notNull()
.references(() => Program.uuid),
},
(table) => [
primaryKey({ columns: [table.fillerShowUuid, table.programUuid] }),
],
);
export type FillerShowContentTable = KyselifyBetter<typeof FillerShowContent>;
export type FillerShowContent = Selectable<FillerShowContentTable>;
export type NewFillerShowContent = Insertable<FillerShowContentTable>;
export const FillerShowContentRelations = relations(
FillerShowContent,
({ one }) => ({
fillerShow: one(FillerShow, {
fields: [FillerShowContent.fillerShowUuid],
references: [FillerShow.uuid],
}),
program: one(Program, {
fields: [FillerShowContent.programUuid],
references: [Program.uuid],
}),
}),
);

View File

@@ -0,0 +1,46 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSourceLibrary } from './MediaSource.ts';
export const LocalMediaFolder = sqliteTable(
'local_media_folder',
{
uuid: text().primaryKey(),
path: text().notNull(),
libraryId: text()
.notNull()
.references(() => MediaSourceLibrary.uuid, { onDelete: 'cascade' }),
canonicalId: text().notNull(),
parentId: text(),
},
(table) => [
index('local_media_folder_library_id_path_idx').on(
table.libraryId,
table.path,
),
index('local_media_folder_path_idx').on(table.path),
index('local_media_folder_canonical_id_id').on(table.canonicalId),
],
);
export const LocalMediaFolderRelations = relations(
LocalMediaFolder,
({ one, many }) => ({
parent: one(LocalMediaFolder, {
fields: [LocalMediaFolder.parentId],
references: [LocalMediaFolder.uuid],
relationName: 'hierarchy',
}),
children: many(LocalMediaFolder, { relationName: 'hierarchy' }),
library: one(MediaSourceLibrary, {
fields: [LocalMediaFolder.libraryId],
references: [MediaSourceLibrary.uuid],
}),
}),
);
export type LocalMediaFolderTable = KyselifyBetter<typeof LocalMediaFolder>;
export type LocalMediaFolderOrm = InferSelectModel<typeof LocalMediaFolder>;
export type NewLocalMediaFolderOrm = InferInsertModel<typeof LocalMediaFolder>;

View File

@@ -0,0 +1,49 @@
import type { InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
import type { MediaSourceId } from './base.ts';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { LocalMediaFolder } from './LocalMediaFolder.ts';
import { MediaSource } from './MediaSource.ts';
import { Program } from './Program.ts';
import { ProgramGrouping } from './ProgramGrouping.ts';
export const LocalMediaSourcePath = sqliteTable('local_media_source_path', {
uuid: text().primaryKey(),
mediaSourceId: text()
.references(() => MediaSource.uuid, { onDelete: 'cascade' })
.notNull()
.$type<MediaSourceId>(),
path: text().notNull(),
lastScannedAt: integer({ mode: 'timestamp_ms' }),
// libraryId: text()
// .notNull()
// .references(() => MediaSourceLibrary.uuid, { onDelete: 'cascade' }),
});
export const LocalMediaSourcePathRelations = relations(
LocalMediaSourcePath,
({ many, one }) => ({
folders: many(LocalMediaFolder),
mediaSource: one(MediaSource, {
fields: [LocalMediaSourcePath.mediaSourceId],
references: [MediaSource.uuid],
}),
// mediaSourceLibrary: one(MediaSourceLibrary, {
// fields: [LocalMediaSourcePath.libraryId],
// references: [MediaSourceLibrary.uuid],
// }),
programs: many(Program),
programGroupings: many(ProgramGrouping),
}),
);
export type LocalMediaSourcePathTable = KyselifyBetter<
typeof LocalMediaSourcePath
>;
export type LocalMediaSourcePathOrm = InferSelectModel<
typeof LocalMediaSourcePath
>;
export type LocalMediaSourcePath = Selectable<LocalMediaSourcePathTable>;
export type NewLocalMediaSourcePath = Insertable<LocalMediaSourcePathTable>;

View File

@@ -4,24 +4,18 @@ import { inArray, relations } from 'drizzle-orm';
import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Updateable } from 'kysely';
import { type Insertable, type Selectable } from 'kysely';
import type { MediaSourceName } from './base.ts';
import { type MediaSourceId } from './base.ts';
import type { StrictExclude } from 'ts-essentials';
import type { MediaSourceName, MediaSourceType } from './base.ts';
import {
MediaLibraryTypes,
MediaSourceTypes,
type MediaSourceId,
} from './base.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import { LocalMediaSourcePath } from './LocalMediaSourcePath.ts';
import { Program } from './Program.ts';
export const MediaSourceTypes = ['plex', 'jellyfin', 'emby'] as const;
export type MediaSourceType = TupleToUnion<typeof MediaSourceTypes>;
type MediaSourceMap = {
[k in Capitalize<(typeof MediaSourceTypes)[number]>]: Uncapitalize<k>;
};
export const MediaSourceType: MediaSourceMap = {
Plex: 'plex',
Jellyfin: 'jellyfin',
Emby: 'emby',
} as const;
export type RemoteMediaSourceType = StrictExclude<MediaSourceType, 'local'>;
export const MediaSource = sqliteTable(
'media_source',
@@ -39,6 +33,7 @@ export const MediaSource = sqliteTable(
uri: text().notNull(),
username: text(),
userId: text(),
mediaType: text({ enum: MediaLibraryTypes }), // Only present for local media sources
},
(table) => [
check(
@@ -51,6 +46,7 @@ export const MediaSource = sqliteTable(
export const MediaSourceRelations = relations(MediaSource, ({ many }) => ({
libraries: many(MediaSourceLibrary),
programs: many(Program),
paths: many(LocalMediaSourcePath),
}));
export const MediaSourceFields: (keyof MediaSourceTable)[] = [
@@ -71,17 +67,10 @@ export const MediaSourceFields: (keyof MediaSourceTable)[] = [
export type MediaSourceTable = KyselifyBetter<typeof MediaSource>;
export type MediaSource = Selectable<MediaSourceTable>;
export type MediaSourceOrm = InferSelectModel<typeof MediaSource>;
export type NewMediaSource = Insertable<MediaSourceTable>;
export type MediaSourceUpdate = Updateable<MediaSourceTable>;
export const MediaLibraryTypes = [
'movies',
'shows',
'music_videos',
'other_videos',
'tracks',
] as const;
export type MediaLibraryType = TupleToUnion<typeof MediaLibraryTypes>;
export const MediaSourceLibrary = sqliteTable(
@@ -110,7 +99,7 @@ export const MediaSourceLibraryRelations = relations(
MediaSourceLibrary,
({ one, many }) => ({
programs: many(Program),
one: one(MediaSource, {
mediaSource: one(MediaSource, {
fields: [MediaSourceLibrary.mediaSourceId],
references: [MediaSource.uuid],
}),

View File

@@ -11,16 +11,16 @@ import {
} from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable, Updateable } from 'kysely';
import type { MarkNotNilable } from '../../types/util.ts';
import { Artwork } from './Artwork.ts';
import type { MediaSourceName } from './base.ts';
import { type MediaSourceId } from './base.ts';
import { MediaSourceTypes, type MediaSourceId } from './base.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import {
MediaSource,
MediaSourceLibrary,
MediaSourceTypes,
} from './MediaSource.ts';
import { LocalMediaFolder } from './LocalMediaFolder.ts';
import { LocalMediaSourcePath } from './LocalMediaSourcePath.ts';
import { MediaSource, MediaSourceLibrary } from './MediaSource.ts';
import { ProgramExternalId } from './ProgramExternalId.ts';
import { ProgramGrouping } from './ProgramGrouping.ts';
import { ProgramSubtitles } from './ProgramSubtitles.ts';
import { ProgramVersion } from './ProgramVersion.ts';
export const ProgramTypes = [
@@ -61,6 +61,8 @@ export const Program = sqliteTable(
})
.$type<MediaSourceId>(),
libraryId: text().references(() => MediaSourceLibrary.uuid),
localMediaFolderId: text().references(() => LocalMediaFolder.uuid),
localMediaSourcePathId: text().references(() => LocalMediaSourcePath.uuid),
filePath: text(),
grandparentExternalKey: text(),
icon: text(),
@@ -137,6 +139,16 @@ export const ProgramRelations = relations(Program, ({ many, one }) => ({
references: [MediaSourceLibrary.uuid],
}),
externalIds: many(ProgramExternalId),
localMediaFolder: one(LocalMediaFolder, {
fields: [Program.localMediaFolderId],
references: [LocalMediaFolder.uuid],
}),
localMediaSourcePath: one(LocalMediaSourcePath, {
fields: [Program.localMediaSourcePathId],
references: [LocalMediaSourcePath.uuid],
}),
artwork: many(Artwork),
subtitles: many(ProgramSubtitles),
}));
export type ProgramTable = KyselifyBetter<typeof Program>;

View File

@@ -1,5 +1,5 @@
import type { TupleToUnion } from '@tunarr/types';
import type { InferSelectModel } from 'drizzle-orm';
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
@@ -33,4 +33,5 @@ export const ProgramChapterRelations = relations(ProgramChapter, ({ one }) => ({
export type ProgramChapterTable = KyselifyBetter<typeof ProgramChapter>;
export type ProgramChapter = Selectable<ProgramChapterTable>;
export type ProgramChapterOrm = InferSelectModel<typeof ProgramChapter>;
export type NewProgramChapterOrm = InferInsertModel<typeof ProgramChapter>;
export type NewProgramChapter = Insertable<ProgramChapterTable>;

View File

@@ -1,4 +1,5 @@
import { type TupleToUnion } from '@tunarr/types';
import type { InferSelectModel } from 'drizzle-orm';
import { inArray, relations } from 'drizzle-orm';
import {
type AnySQLiteColumn,
@@ -10,10 +11,14 @@ import {
} from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable, Updateable } from 'kysely';
import type { MarkRequiredNotNull } from '../../types/util.ts';
import { Artwork } from './Artwork.ts';
import type { MediaSourceId } from './base.ts';
import { MediaSourceTypes } from './base.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSourceLibrary } from './MediaSource.ts';
import { MediaSource, MediaSourceLibrary } from './MediaSource.ts';
import { Program } from './Program.ts';
import type { ProgramGroupingTable as RawProgramGrouping } from './ProgramGrouping.ts';
import { ProgramGroupingExternalId } from './ProgramGroupingExternalId.ts';
export const ProgramGroupingType = {
Show: 'show',
@@ -47,6 +52,13 @@ export const ProgramGrouping = sqliteTable(
artistUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
showUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
libraryId: text().references(() => MediaSourceLibrary.uuid),
sourceType: text({ enum: MediaSourceTypes }),
externalKey: text(),
mediaSourceId: text()
.references(() => MediaSource.uuid, {
onDelete: 'cascade',
})
.$type<MediaSourceId>(),
},
(table) => [
index('program_grouping_show_uuid_index').on(table.showUuid),
@@ -72,6 +84,20 @@ export const ProgramGroupingRelations = relations(
relationName: 'show',
}),
children: many(Program),
externalIds: many(ProgramGroupingExternalId),
artwork: many(Artwork),
library: one(MediaSourceLibrary, {
fields: [ProgramGrouping.libraryId],
references: [MediaSourceLibrary.uuid],
}),
mediaSource: one(MediaSource, {
fields: [ProgramGrouping.mediaSourceId],
references: [MediaSource.uuid],
}),
// localMediaSourcePath: one(LocalMediaSourcePath, {
// fields: [ProgramGrouping.mediaSourcePathId],
// references: [LocalMediaSourcePath.uuid],
// }),
}),
);
@@ -79,9 +105,10 @@ export type ProgramGroupingTable = KyselifyBetter<typeof ProgramGrouping>;
export type ProgramGrouping = Selectable<ProgramGroupingTable>;
export type NewProgramGrouping = MarkRequiredNotNull<
Insertable<ProgramGroupingTable>,
'canonicalId' | 'libraryId'
'canonicalId' | 'libraryId' | 'mediaSourceId' | 'sourceType' | 'externalKey'
>;
export type ProgramGroupingUpdate = Updateable<ProgramGroupingTable>;
export type ProgramGroupingOrm = InferSelectModel<typeof ProgramGrouping>;
const ProgramGroupingKeys: (keyof RawProgramGrouping)[] = [
'artistUuid',
@@ -96,10 +123,8 @@ const ProgramGroupingKeys: (keyof RawProgramGrouping)[] = [
'uuid',
'year',
];
// TODO move this definition to the ProgramGrouping DAO file
export const AllProgramGroupingFields: ProgramGroupingFields =
ProgramGroupingKeys.map((key) => `programGrouping.${key}` as const);
// TODO move this definition to the ProgramGrouping DAO file
export const AllProgramGroupingFieldsAliased = <Alias extends string>(
alias: Alias,

View File

@@ -1,10 +1,12 @@
import { inArray } from 'drizzle-orm';
import type { InferSelectModel } from 'drizzle-orm';
import { inArray, relations, sql } from 'drizzle-orm';
import {
check,
index,
integer,
sqliteTable,
text,
uniqueIndex,
} from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
import { omit } from 'lodash-es';
@@ -47,9 +49,29 @@ export const ProgramGroupingExternalId = sqliteTable(
'source_type_check',
inArray(table.sourceType, table.sourceType.enumValues).inlineParams(),
),
uniqueIndex('unique_program_grouping_multiple_external_id_media_source')
.on(table.groupUuid, table.sourceType, table.mediaSourceId)
.where(sql`\`media_source_id is not null\``),
uniqueIndex('unique_program_grouping_single_external_id_media_source')
.on(table.groupUuid, table.sourceType, table.mediaSourceId)
.where(sql`\`media_source_id is null\``),
],
);
export const ProgramGroupingExternalIdRelations = relations(
ProgramGroupingExternalId,
({ one }) => ({
grouping: one(ProgramGrouping, {
fields: [ProgramGroupingExternalId.groupUuid],
references: [ProgramGrouping.uuid],
}),
library: one(MediaSourceLibrary, {
fields: [ProgramGroupingExternalId.libraryId],
references: [MediaSourceLibrary.uuid],
}),
}),
);
export type ProgramGroupingExternalIdTable = KyselifyBetter<
typeof ProgramGroupingExternalId
>;
@@ -67,6 +89,9 @@ export type NewSingleOrMultiProgramGroupingExternalId =
Insertable<ProgramGroupingExternalIdTable>,
'externalSourceId' | 'mediaSourceId'
> & { type: 'multi' });
export type ProgramGroupingExternalIdOrm = InferSelectModel<
typeof ProgramGroupingExternalId
>;
export function toInsertableProgramGroupingExternalId(
eid: NewProgramGroupingExternalId | NewSingleOrMultiProgramGroupingExternalId,

View File

@@ -1,5 +1,43 @@
// import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable } from 'kysely';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { LocalMediaFolder } from './LocalMediaFolder.ts';
import { ProgramVersion } from './ProgramVersion.ts';
// export const ProgramMediaFile = sqliteTable('program_media_file', {
// uuid: text().primaryKey(),
// });
export const ProgramMediaFile = sqliteTable(
'program_media_file',
{
uuid: text().primaryKey(),
path: text().notNull(),
programVersionId: text()
.notNull()
.references(() => ProgramVersion.uuid, { onDelete: 'cascade' }),
localMediaFolderId: text().references(() => LocalMediaFolder.uuid, {
onDelete: 'cascade',
}),
},
(table) => [
index('program_media_file_program_version_idx').on(table.programVersionId),
index('program_media_file_folder_idx').on(table.localMediaFolderId),
],
);
export const ProgramMediaFileRelations = relations(
ProgramMediaFile,
({ one }) => ({
version: one(ProgramVersion, {
fields: [ProgramMediaFile.programVersionId],
references: [ProgramVersion.uuid],
}),
localMediaFolder: one(LocalMediaFolder, {
fields: [ProgramMediaFile.localMediaFolderId],
references: [LocalMediaFolder.uuid],
}),
}),
);
export type ProgramMediaFileTable = KyselifyBetter<typeof ProgramMediaFile>;
export type ProgramMediaFile = InferSelectModel<typeof ProgramMediaFile>;
export type NewProgramMediaFile = Insertable<ProgramMediaFileTable>;

View File

@@ -1,4 +1,4 @@
import type { InferSelectModel } from 'drizzle-orm';
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
@@ -19,7 +19,7 @@ export const ProgramMediaStream = sqliteTable(
uuid: text().primaryKey(),
index: integer().notNull(),
codec: text().notNull(),
profile: text().notNull(),
profile: text(), //.notNull(),
streamKind: text({ enum: MediaStreamKind }).notNull(),
title: text(),
@@ -59,3 +59,6 @@ export type ProgramMediaStreamTable = KyselifyBetter<typeof ProgramMediaStream>;
export type ProgramMediaStream = Selectable<ProgramMediaStreamTable>;
export type ProgramMediaStreamOrm = InferSelectModel<typeof ProgramMediaStream>;
export type NewProgramMediaStream = Insertable<ProgramMediaStreamTable>;
export type NewProgramMediaStreamOrm = InferInsertModel<
typeof ProgramMediaStream
>;

View File

@@ -0,0 +1,35 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { Program } from './Program.ts';
export const ProgramSubtitles = sqliteTable('program_subtitles', {
uuid: text().primaryKey(),
subtitleType: text({ enum: ['embedded', 'sidecar'] }).notNull(),
streamIndex: integer(),
codec: text().notNull(),
default: integer({ mode: 'boolean' }).notNull().default(false),
forced: integer({ mode: 'boolean' }).notNull().default(false),
sdh: integer({ mode: 'boolean' }).notNull().default(false),
language: text().notNull(),
path: text(),
programId: text()
.notNull()
.references(() => Program.uuid, { onDelete: 'cascade' }),
createdAt: integer({ mode: 'timestamp_ms' }).notNull(),
updatedAt: integer({ mode: 'timestamp_ms' }).notNull(),
isExtracted: integer({ mode: 'boolean' }).default(false),
});
export const ProgramSubtitlesRelations = relations(
ProgramSubtitles,
({ one }) => ({
program: one(Program, {
fields: [ProgramSubtitles.programId],
references: [Program.uuid],
}),
}),
);
export type ProgramSubtitles = InferSelectModel<typeof ProgramSubtitles>;
export type NewProgramSubtitles = InferInsertModel<typeof ProgramSubtitles>;

View File

@@ -1,10 +1,11 @@
import type { InferSelectModel } from 'drizzle-orm';
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable, Updateable } from 'kysely';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { Program } from './Program.ts';
import { ProgramChapter } from './ProgramChapter.ts';
import { ProgramMediaFile } from './ProgramMediaFile.ts';
import { ProgramMediaStream } from './ProgramMediaStream.ts';
export const VideoScanKind = ['unknown', 'progressive', 'interlaced'] as const;
@@ -13,15 +14,15 @@ export const ProgramVersion = sqliteTable(
'program_version',
{
uuid: text().primaryKey(),
createdAt: integer().notNull(),
updatedAt: integer().notNull(),
createdAt: integer({ mode: 'timestamp_ms' }).notNull(),
updatedAt: integer({ mode: 'timestamp_ms' }).notNull(),
duration: integer().notNull(),
sampleAspectRatio: text().notNull(),
displayAspectRatio: text().notNull(),
sampleAspectRatio: text(),
displayAspectRatio: text(),
frameRate: text(),
scanKind: text({ enum: VideoScanKind }),
width: integer(),
height: integer(),
scanKind: text({ enum: VideoScanKind }).notNull(),
width: integer().notNull(),
height: integer().notNull(),
// Join
programId: text()
@@ -41,6 +42,7 @@ export const ProgramVersionRelations = relations(
}),
mediaStreams: many(ProgramMediaStream),
chapters: many(ProgramChapter),
mediaFiles: many(ProgramMediaFile),
}),
);
@@ -48,4 +50,5 @@ export type ProgramVersionTable = KyselifyBetter<typeof ProgramVersion>;
export type ProgramVersion = Selectable<ProgramVersionTable>;
export type ProgramVersionOrm = InferSelectModel<typeof ProgramVersion>;
export type NewProgramVersionDao = Insertable<ProgramVersionTable>;
export type NewProgramVersionOrm = InferInsertModel<typeof ProgramVersion>;
export type ProgramVersionUpdate = Updateable<ProgramVersionTable>;

View File

@@ -23,7 +23,7 @@ export const ChannelSubtitlePreferences = sqliteTable(
...commonSubtitlePreferenceCols,
channelId: text()
.notNull()
.references(() => Channel.uuid),
.references(() => Channel.uuid, { onDelete: 'cascade' }),
},
(table) => [
index('channel_priority_index').on(table.channelId, table.priority),
@@ -44,7 +44,7 @@ export const CustomShowSubtitlePreferences = sqliteTable(
...commonSubtitlePreferenceCols,
customShowId: text()
.notNull()
.references(() => CustomShow.uuid),
.references(() => CustomShow.uuid, { onDelete: 'cascade' }),
},
(table) => [
index('custom_show_priority_index').on(table.customShowId, table.priority),

View File

@@ -123,3 +123,23 @@ export type ChannelOfflineSettings = z.infer<
export type MediaSourceId = Tag<string, 'mediaSourceId'>;
export type MediaSourceName = Tag<string, 'mediaSourceName'>;
export const MediaSourceTypes = ['plex', 'jellyfin', 'emby', 'local'] as const;
export const MediaLibraryTypes = [
'movies',
'shows',
'music_videos',
'other_videos',
'tracks',
] as const;
export type MediaSourceType = TupleToUnion<typeof MediaSourceTypes>;
type MediaSourceMap = {
[k in Capitalize<(typeof MediaSourceTypes)[number]>]: Uncapitalize<k>;
};
export const MediaSourceType: MediaSourceMap = {
Plex: 'plex',
Jellyfin: 'jellyfin',
Emby: 'emby',
Local: 'local',
} as const;

View File

@@ -1,13 +1,15 @@
import type { CachedImageTable } from './CachedImage.js';
import type {
ChannelCustomShowsTable,
ChannelFallbackTable,
ChannelFillerShowTable,
ChannelProgramsTable,
ChannelTable,
} from './Channel.ts';
import type { CustomShowContentTable, CustomShowTable } from './CustomShow.js';
import type { FillerShowContentTable, FillerShowTable } from './FillerShow.js';
import type { ChannelTable } from './Channel.ts';
import type { ChannelCustomShowsTable } from './ChannelCustomShow.ts';
import type { ChannelFallbackTable } from './ChannelFallback.ts';
import type { ChannelFillerShowTable } from './ChannelFillerShow.ts';
import type { ChannelProgramsTable } from './ChannelPrograms.ts';
import type { CustomShowTable } from './CustomShow.js';
import type { CustomShowContentTable } from './CustomShowContent.ts';
import type { FillerShowTable } from './FillerShow.js';
import type { FillerShowContentTable } from './FillerShowContent.ts';
import type { LocalMediaFolderTable } from './LocalMediaFolder.ts';
import type { LocalMediaSourcePathTable } from './LocalMediaSourcePath.ts';
import type {
MediaSourceLibraryTable,
MediaSourceTable,
@@ -18,6 +20,7 @@ import type { ProgramChapterTable } from './ProgramChapter.ts';
import type { ProgramExternalIdTable } from './ProgramExternalId.ts';
import type { ProgramGroupingTable } from './ProgramGrouping.ts';
import type { ProgramGroupingExternalIdTable } from './ProgramGroupingExternalId.ts';
import type { ProgramMediaFileTable } from './ProgramMediaFile.ts';
import type { ProgramMediaStreamTable } from './ProgramMediaStream.ts';
import type { ProgramVersionTable } from './ProgramVersion.ts';
import type {
@@ -39,12 +42,15 @@ export interface DB {
customShowSubtitlePreferences: CustomShowSubtitlePreferencesTable;
fillerShow: FillerShowTable;
fillerShowContent: FillerShowContentTable;
localMediaSourcePath: LocalMediaSourcePathTable;
localMediaFolder: LocalMediaFolderTable;
mediaSource: MediaSourceTable;
mediaSourceLibrary: MediaSourceLibraryTable;
program: ProgramTable;
programChapter: ProgramChapterTable;
programExternalId: ProgramExternalIdTable;
programMediaStream: ProgramMediaStreamTable;
programMediaFile: ProgramMediaFileTable;
programVersion: ProgramVersionTable;
programGrouping: ProgramGroupingTable;
programGroupingExternalId: ProgramGroupingExternalIdTable;

View File

@@ -2,13 +2,20 @@ import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import type { MarkNonNullable, Nullable } from '@/types/util.js';
import type { Insertable } from 'kysely';
import type { DeepNullable, MarkRequired, StrictOmit } from 'ts-essentials';
import type { Channel, ChannelFillerShow } from './Channel.ts';
import type { Artwork, NewArtwork } from './Artwork.ts';
import type { MediaSourceType } from './base.ts';
import type { Channel, ChannelOrm } from './Channel.ts';
import type { ChannelFillerShow } from './ChannelFillerShow.ts';
import type { FillerShow } from './FillerShow.ts';
import type {
LocalMediaSourcePath,
LocalMediaSourcePathOrm,
} from './LocalMediaSourcePath.ts';
import type {
MediaSource,
MediaSourceLibrary,
MediaSourceLibraryOrm,
MediaSourceType,
MediaSourceOrm,
} from './MediaSource.ts';
import type {
NewProgramDao,
@@ -17,6 +24,7 @@ import type {
ProgramType,
} from './Program.ts';
import type {
NewProgramChapterOrm,
ProgramChapter,
ProgramChapterOrm,
ProgramChapterTable,
@@ -28,19 +36,31 @@ import type {
import type {
NewProgramGrouping,
ProgramGrouping,
ProgramGroupingOrm,
ProgramGroupingType,
} from './ProgramGrouping.ts';
import type {
NewSingleOrMultiProgramGroupingExternalId,
ProgramGroupingExternalId,
ProgramGroupingExternalIdOrm,
} from './ProgramGroupingExternalId.ts';
import type {
NewProgramMediaFile,
ProgramMediaFile,
} from './ProgramMediaFile.ts';
import type {
NewProgramMediaStream,
NewProgramMediaStreamOrm,
ProgramMediaStream,
ProgramMediaStreamOrm,
} from './ProgramMediaStream.ts';
import type {
NewProgramSubtitles,
ProgramSubtitles,
} from './ProgramSubtitles.ts';
import type {
NewProgramVersionDao,
NewProgramVersionOrm,
ProgramVersion,
ProgramVersionOrm,
} from './ProgramVersion.ts';
@@ -53,9 +73,15 @@ export type ProgramVersionWithRelations = ProgramVersion & {
export type ProgramVersionOrmWithRelations = ProgramVersionOrm & {
mediaStreams?: ProgramMediaStreamOrm[];
mediaFiles?: ProgramMediaFile[];
chapters?: ProgramChapterOrm[];
};
export type NewProgramVersionOrmWithRelations = NewProgramVersionOrm & {
mediaStreams?: NewProgramMediaStreamOrm[];
chapters?: NewProgramChapterOrm[];
};
export type ProgramWithRelations = ProgramDao & {
tvShow?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
tvSeason?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
@@ -76,8 +102,19 @@ export type ProgramWithRelationsOrm = ProgramOrm & {
externalIds?: MinimalProgramExternalId[];
versions?: ProgramVersionOrmWithRelations[];
mediaLibrary?: Nullable<MediaSourceLibraryOrm>;
artwork?: Artwork[];
subtitles?: ProgramSubtitles[];
};
export type SpecificProgramOrmType<
Typ extends ProgramType,
ProgramT extends { type: ProgramType } = ProgramWithRelationsOrm,
> = StrictOmit<ProgramT, 'type'> & { type: Typ };
export type SpecificProgramSourceOrmType<
Typ extends MediaSourceType,
ProgramT extends { sourceType: MediaSourceType } = ProgramWithRelationsOrm,
> = StrictOmit<ProgramT, 'sourceType'> & { sourceType: Typ };
export type SpecificProgramGroupingType<
Typ extends ProgramGroupingType,
ProgramGroupingT extends { type: ProgramGroupingType } = ProgramGrouping,
@@ -119,6 +156,14 @@ export type ChannelWithRelations = Channel & {
subtitlePreferences?: ChannelSubtitlePreferences[];
};
export type ChannelOrmWithRelations = ChannelOrm & {
programs?: ProgramWithRelationsOrm[];
fillerContent?: ProgramWithRelationsOrm[];
fillerShows?: ChannelFillerShow[];
transcodeConfig?: TranscodeConfig;
subtitlePreferences?: ChannelSubtitlePreferences[];
};
export type ChannelWithTranscodeConfig = MarkRequired<
ChannelWithRelations,
'transcodeConfig'
@@ -132,6 +177,11 @@ export type ChannelWithPrograms = MarkRequired<
'programs'
>;
export type ChannelOrmWithPrograms = MarkRequired<
ChannelOrmWithRelations,
'programs'
>;
export type ChannelFillerShowWithRelations = ChannelFillerShow & {
fillerShow: MarkNonNullable<DeepNullable<FillerShow>, 'uuid'>;
fillerContent?: ProgramWithRelations[];
@@ -153,6 +203,7 @@ export type ProgramWithExternalIds = ProgramDao & {
export type NewProgramVersion = NewProgramVersionDao & {
mediaStreams: NewProgramMediaStream[];
mediaFiles: NewProgramMediaFile[];
chapters?: Insertable<ProgramChapterTable>[];
};
@@ -160,6 +211,8 @@ export type NewProgramWithRelations<Type extends ProgramType = ProgramType> = {
program: SpecificProgramType<Type, NewProgramDao>;
externalIds: NewSingleOrMultiExternalId[];
versions: NewProgramVersion[];
artwork?: NewArtwork[];
subtitles?: NewProgramSubtitles[];
};
export type NewProgramWithExternalIds = NewProgramDao & {
@@ -178,6 +231,11 @@ export type ProgramGroupingWithExternalIds = ProgramGrouping & {
externalIds: ProgramGroupingExternalId[];
};
export type ProgramGroupingOrmWithRelations = ProgramGroupingOrm & {
externalIds: ProgramGroupingExternalIdOrm[];
artwork?: Artwork[];
};
type SpecificSubtype<
BaseType extends { type: string },
Value extends BaseType['type'],
@@ -220,11 +278,16 @@ type WithNewGroupingExternalIds = {
export type NewProgramGroupingWithExternalIds = NewProgramGrouping &
WithNewGroupingExternalIds;
export type NewTvShow = SpecificProgramGroupingType<
'show',
NewProgramGrouping
> &
WithNewGroupingExternalIds;
export type NewProgramGroupingWithRelations<
Typ extends ProgramGroupingType = ProgramGroupingType,
> = {
programGrouping: SpecificProgramGroupingType<Typ, NewProgramGrouping>;
externalIds: NewSingleOrMultiProgramGroupingExternalId[];
artwork: NewArtwork[];
};
export type NewTvShow = SpecificProgramGroupingType<'show', NewProgramGrouping>;
export type NewTvSeason = SpecificProgramGroupingType<
'season',
NewProgramGrouping
@@ -244,9 +307,16 @@ export type NewMusicAlbum = SpecificProgramGroupingType<
export type NewMusicTrack = SpecificProgramType<'track', NewProgramDao>;
export type MediaSourceWithLibraries = MediaSource & {
export type MediaSourceWithLibrariesDirect = MediaSource & {
libraries: MediaSourceLibrary[];
paths: LocalMediaSourcePath[];
};
export type MediaSourceWithLibraries = MediaSourceOrm & {
libraries: MediaSourceLibraryOrm[];
paths: LocalMediaSourcePathOrm[];
};
export type SpecificMediaSourceType<Typ extends MediaSourceType> = StrictOmit<
MediaSourceWithLibraries,
'type'

View File

@@ -1,9 +1,41 @@
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { Channel } from './Channel.ts';
import { Artwork, ArtworkRelations } from './Artwork.ts';
import { Channel, ChannelRelations } from './Channel.ts';
import {
ChannelCustomShow,
ChannelCustomShowRelations,
} from './ChannelCustomShow.ts';
import {
ChannelFillerShow,
ChannelFillerShowRelations,
} from './ChannelFillerShow.ts';
import {
ChannelPrograms,
ChannelProgramsRelations,
} from './ChannelPrograms.ts';
import { CustomShow, CustomShowRelations } from './CustomShow.ts';
import {
CustomShowContent,
CustomShowContentRelations,
} from './CustomShowContent.ts';
import { FillerShow, FillerShowRelations } from './FillerShow.ts';
import {
FillerShowContent,
FillerShowContentRelations,
} from './FillerShowContent.ts';
import {
LocalMediaFolder,
LocalMediaFolderRelations,
} from './LocalMediaFolder.ts';
import {
LocalMediaSourcePath,
LocalMediaSourcePathRelations,
} from './LocalMediaSourcePath.ts';
import {
MediaSource,
MediaSourceLibrary,
MediaSourceLibraryRelations,
MediaSourceRelations,
} from './MediaSource.ts';
import { Program, ProgramRelations } from './Program.ts';
import { ProgramChapter, ProgramChapterRelations } from './ProgramChapter.ts';
@@ -15,16 +47,43 @@ import {
ProgramGrouping,
ProgramGroupingRelations,
} from './ProgramGrouping.ts';
import {
ProgramGroupingExternalId,
ProgramGroupingExternalIdRelations,
} from './ProgramGroupingExternalId.ts';
import {
ProgramMediaFile,
ProgramMediaFileRelations,
} from './ProgramMediaFile.ts';
import {
ProgramMediaStream,
ProgramMediaStreamRelations,
} from './ProgramMediaStream.ts';
import {
ProgramSubtitles,
ProgramSubtitlesRelations,
} from './ProgramSubtitles.ts';
import { ProgramVersion, ProgramVersionRelations } from './ProgramVersion.ts';
// export { Program } from './Program.ts';
export const schema = {
channels: Channel,
channelRelations: ChannelRelations,
channelPrograms: ChannelPrograms,
channelCustomShows: ChannelCustomShow,
channelCustomShowRelations: ChannelCustomShowRelations,
channelFillerShow: ChannelFillerShow,
channelFillerShowRelations: ChannelFillerShowRelations,
channelProgramRelations: ChannelProgramsRelations,
customShow: CustomShow,
customShowRelations: CustomShowRelations,
customShowContent: CustomShowContent,
customShowContentRelations: CustomShowContentRelations,
fillerShows: FillerShow,
fillerShowRelations: FillerShowRelations,
fillerShowContent: FillerShowContent,
fillerShowContentRelations: FillerShowContentRelations,
program: Program,
programVersion: ProgramVersion,
programRelations: ProgramRelations,
@@ -33,13 +92,26 @@ export const schema = {
programGroupingRelations: ProgramGroupingRelations,
programExternalId: ProgramExternalId,
programExternalIdRelations: ProgramExternalIdRelations,
programGroupingExternalId: ProgramGroupingExternalId,
programGroupingExternalIdRelations: ProgramGroupingExternalIdRelations,
programMediaStream: ProgramMediaStream,
programMediaStreamRelations: ProgramMediaStreamRelations,
programChapter: ProgramChapter,
programChapterRelations: ProgramChapterRelations,
mediaSource: MediaSource,
mediaSourceRelations: MediaSourceRelations,
mediaSourceLibrary: MediaSourceLibrary,
mediaSourceLibraryRelations: MediaSourceLibraryRelations,
localMediaSourcePath: LocalMediaSourcePath,
localMediaSourcePathRelations: LocalMediaSourcePathRelations,
localMediaFolder: LocalMediaFolder,
localMediaFolderRelations: LocalMediaFolderRelations,
programMediaFile: ProgramMediaFile,
programMediaFileRelations: ProgramMediaFileRelations,
artwork: Artwork,
artworkRelations: ArtworkRelations,
programSubtitles: ProgramSubtitles,
programSubtitlesRelations: ProgramSubtitlesRelations,
};
export type DrizzleDBAccess = BetterSQLite3Database<typeof schema>;

View File

@@ -1,6 +1,6 @@
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
import type { MediaSource } from '@/db/schema/MediaSource.js';
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import { MediaSourceType } from '@/db/schema/base.js';
import type { MediaSource, MediaSourceOrm } from '@/db/schema/MediaSource.js';
import type { Maybe } from '@/types/util.js';
import { isDefined, isNonEmptyString } from '@/util/index.js';
import type { FindChild } from '@tunarr/types';
@@ -9,7 +9,7 @@ import { inject, injectable, LazyServiceIdentifier } from 'inversify';
import { forEach, isBoolean, isEmpty, isNil } from 'lodash-es';
import NodeCache from 'node-cache';
import type { ISettingsDB } from '../db/interfaces/ISettingsDB.ts';
import { MediaSourceId } from '../db/schema/base.ts';
import { MediaSourceId } from '../db/schema/base.js';
import { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
import { KEYS } from '../types/inject.ts';
import { Result } from '../types/result.ts';
@@ -126,19 +126,10 @@ export class MediaSourceApiFactory {
getPlexApiClientForMediaSource(
mediaSource: MediaSourceWithLibraries,
): Promise<PlexApiClient> {
// const opts = mediaSourceToApiOptions(mediaSource);
return this.getPlexApiClient({ mediaSource });
}
getPlexApiClient(opts: ApiClientOptions): Promise<PlexApiClient> {
// const key = `${opts.url}|${opts.accessToken}`;
// const client = await cacheGetOrSet(MediaSourceApiFactory.cache, key, () => {
// return Promise.resolve(
// ,
// );
// });
// client.setApiClientOptions(opts);
// return client;
return Promise.resolve(
this.plexApiClientFactory({
...opts,
@@ -187,7 +178,7 @@ export class MediaSourceApiFactory {
);
}
deleteCachedClient(mediaSource: MediaSource) {
deleteCachedClient(mediaSource: MediaSourceOrm) {
const key = this.getCacheKeyForMediaSource(mediaSource);
return MediaSourceApiFactory.cache.del(key) === 1;
}
@@ -224,7 +215,9 @@ export class MediaSourceApiFactory {
return `${type}|${uri}|${accessToken}`;
}
private getCacheKeyForMediaSource(mediaSource: MediaSource): string {
private getCacheKeyForMediaSource(
mediaSource: MediaSource | MediaSourceOrm,
): string {
return this.getCacheKey(
mediaSource.type,
mediaSource.uri,

View File

@@ -80,6 +80,7 @@ import type {
NamedEntity,
} from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import { titleToSortTitle } from '../../util/programs.ts';
import {
QueryError,
type ApiClientOptions,
@@ -898,6 +899,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
uuid: v4(),
canonicalId: this.canonicalizer.getCanonicalId(movie),
title: movie.Name!,
sortTitle: titleToSortTitle(movie.Name ?? ''),
originalTitle: movie.OriginalTitle ?? null,
year: movie.ProductionYear ?? null,
releaseDate: isError(parsedReleaseDate)
@@ -924,7 +926,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
tags: movie.Tags?.filter(isNonEmptyString) ?? [],
summary: null,
type: 'movie',
externalKey: movie.Id,
mediaItem,
identifiers: collectEmbyItemIdentifiers(
movie,
@@ -1064,6 +1065,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
externalId: series.Id,
canonicalId: this.canonicalizer.getCanonicalId(series),
title: series.Name!,
sortTitle: titleToSortTitle(series.Name ?? ''),
// originalTitle: series.OriginalTitle ?? null,
year: series.ProductionYear ?? null,
releaseDate: isError(parsedReleaseDate)
@@ -1092,7 +1094,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
tags: series.Tags?.filter(isNonEmptyString) ?? [],
summary: null,
type: 'show',
externalKey: series.Id,
// mediaItem,
identifiers: collectEmbyItemIdentifiers(
series,
@@ -1112,6 +1113,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
externalId: season.Id,
canonicalId: this.canonicalizer.getCanonicalId(season),
title: season.Name!,
sortTitle: titleToSortTitle(season.Name ?? ''),
// originalTitle: season.OriginalTitle ?? null,
year: season.ProductionYear ?? null,
mediaSourceId: this.options.mediaSource.uuid,
@@ -1140,7 +1142,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
tags: season.Tags?.filter(isNonEmptyString) ?? [],
summary: null,
type: 'season',
externalKey: season.Id,
// mediaItem,
identifiers: collectEmbyItemIdentifiers(
season,
@@ -1186,6 +1187,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
externalId: episode.Id,
canonicalId: this.canonicalizer.getCanonicalId(episode),
title: episode.Name!,
sortTitle: titleToSortTitle(episode.Name ?? ''),
originalTitle: episode.OriginalTitle ?? null,
year: episode.ProductionYear ?? null,
releaseDate: isError(parsedReleaseDate)
@@ -1215,7 +1217,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
tags: episode.Tags?.filter(isNonEmptyString) ?? [],
summary: null,
type: 'episode',
externalKey: episode.Id,
mediaItem,
identifiers: collectEmbyItemIdentifiers(
episode,
@@ -1229,8 +1230,8 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
private embyApiMusicArtistInjection(artist: ApiEmbyMusicArtist) {
return {
title: artist.Name ?? '',
sortTitle: titleToSortTitle(artist.Name ?? ''),
canonicalId: this.canonicalizer.getCanonicalId(artist),
externalKey: artist.Id,
genres:
artist.Genres?.map((genre) => ({
name: genre,
@@ -1259,8 +1260,8 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
type: 'album',
externalId: album.Id,
title: album.Name ?? '',
sortTitle: titleToSortTitle(album.Name ?? ''),
canonicalId: this.canonicalizer.getCanonicalId(album),
externalKey: album.Id,
genres:
album.Genres?.map((genre) => ({
name: genre,
@@ -1312,9 +1313,9 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
uuid: v4(),
canonicalId: this.canonicalizer.getCanonicalId(track),
title: track.Name ?? '',
sortTitle: titleToSortTitle(track.Name ?? ''),
actors: [],
directors: [],
externalKey: track.Id,
genres: [],
tags: track.Tags?.filter(isNonEmptyString) ?? [],
year: track.ProductionYear ?? null,
@@ -1378,6 +1379,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
uuid: v4(),
canonicalId: this.canonicalizer.getCanonicalId(video),
title: video.Name!,
sortTitle: titleToSortTitle(video.Name ?? ''),
originalTitle: video.OriginalTitle ?? null,
year: video.ProductionYear ?? null,
releaseDate: isError(parsedReleaseDate)
@@ -1404,7 +1406,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
tags: video.Tags?.filter(isNonEmptyString) ?? [],
// summary: null,
type: 'music_video',
externalKey: video.Id,
mediaItem,
identifiers: collectEmbyItemIdentifiers(
video,
@@ -1447,6 +1448,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
uuid: v4(),
canonicalId: this.canonicalizer.getCanonicalId(video),
title: video.Name!,
sortTitle: titleToSortTitle(video.Name ?? ''),
originalTitle: video.OriginalTitle ?? null,
year: video.ProductionYear ?? null,
releaseDate: isError(parsedReleaseDate)
@@ -1473,7 +1475,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
tags: video.Tags?.filter(isNonEmptyString) ?? [],
// summary: null,
type: 'other_video',
externalKey: video.Id,
mediaItem,
identifiers: collectEmbyItemIdentifiers(
video,

View File

@@ -84,6 +84,7 @@ import type {
NamedEntity,
} from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import { titleToSortTitle } from '../../util/programs.ts';
import {
QueryError,
type ApiClientOptions,
@@ -965,6 +966,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
uuid: v4(),
canonicalId: this.canonicalizer.getCanonicalId(movie),
title: movie.Name!,
sortTitle: titleToSortTitle(movie.Name!),
originalTitle: movie.OriginalTitle ?? null,
year: movie.ProductionYear ?? null,
releaseDate: isError(parsedReleaseDate)
@@ -991,7 +993,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
tags: movie.Tags?.filter(isNonEmptyString) ?? [],
summary: null,
type: 'movie',
externalKey: movie.Id,
mediaItem,
identifiers: collectJellyfinItemIdentifiers(
movie,
@@ -1162,6 +1163,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
externalId: series.Id,
canonicalId: this.canonicalizer.getCanonicalId(series),
title: series.Name!,
sortTitle: titleToSortTitle(series.Name!),
// originalTitle: series.OriginalTitle ?? null,
year: series.ProductionYear ?? null,
releaseDate: isError(parsedReleaseDate)
@@ -1190,7 +1192,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
tags: series.Tags?.filter(isNonEmptyString) ?? [],
summary: null,
type: 'show',
externalKey: series.Id,
// mediaItem,
identifiers: collectJellyfinItemIdentifiers(
series,
@@ -1212,6 +1213,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
externalId: season.Id,
canonicalId: this.canonicalizer.getCanonicalId(season),
title: season.Name!,
sortTitle: titleToSortTitle(season.Name!),
// originalTitle: season.OriginalTitle ?? null,
year: season.ProductionYear ?? null,
mediaSourceId: this.options.mediaSource.uuid,
@@ -1240,7 +1242,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
tags: season.Tags?.filter(isNonEmptyString) ?? [],
summary: null,
type: 'season',
externalKey: season.Id,
// mediaItem,
identifiers: collectJellyfinItemIdentifiers(
season,
@@ -1286,6 +1287,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
externalId: episode.Id,
canonicalId: this.canonicalizer.getCanonicalId(episode),
title: episode.Name!,
sortTitle: titleToSortTitle(episode.Name!),
originalTitle: episode.OriginalTitle ?? null,
year: episode.ProductionYear ?? null,
releaseDate: isError(parsedReleaseDate)
@@ -1315,7 +1317,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
tags: episode.Tags?.filter(isNonEmptyString) ?? [],
summary: null,
type: 'episode',
externalKey: episode.Id,
mediaItem,
identifiers: collectJellyfinItemIdentifiers(
episode,
@@ -1330,7 +1331,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
return {
title: artist.Name ?? '',
canonicalId: this.canonicalizer.getCanonicalId(artist),
externalKey: artist.Id,
genres:
artist.Genres?.map((genre) => ({
name: genre,
@@ -1351,6 +1351,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
mediaSourceId: this.options.mediaSource.uuid,
childCount: artist.ChildCount ?? undefined,
externalId: artist.Id,
sortTitle: titleToSortTitle(artist.Name ?? ''),
} satisfies JellyfinMusicArtist;
}
@@ -1361,8 +1362,8 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
type: 'album',
externalId: album.Id,
title: album.Name ?? '',
sortTitle: titleToSortTitle(album.Name!),
canonicalId: this.canonicalizer.getCanonicalId(album),
externalKey: album.Id,
genres:
album.Genres?.map((genre) => ({
name: genre,
@@ -1414,9 +1415,9 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
uuid: v4(),
canonicalId: this.canonicalizer.getCanonicalId(track),
title: track.Name ?? '',
sortTitle: titleToSortTitle(track.Name!),
actors: [],
directors: [],
externalKey: track.Id,
genres: [],
tags: track.Tags?.filter(isNonEmptyString) ?? [],
year: track.ProductionYear ?? null,
@@ -1483,6 +1484,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
uuid: v4(),
canonicalId: this.canonicalizer.getCanonicalId(video),
title: video.Name!,
sortTitle: titleToSortTitle(video.Name!),
originalTitle: video.OriginalTitle ?? null,
year: video.ProductionYear ?? null,
releaseDate: isError(parsedReleaseDate)
@@ -1509,7 +1511,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
tags: video.Tags?.filter(isNonEmptyString) ?? [],
// summary: null,
type: 'music_video',
externalKey: video.Id,
mediaItem,
identifiers: collectJellyfinItemIdentifiers(
video,
@@ -1555,6 +1556,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
uuid: v4(),
canonicalId: this.canonicalizer.getCanonicalId(video),
title: video.Name!,
sortTitle: titleToSortTitle(video.Name!),
originalTitle: video.OriginalTitle ?? null,
year: video.ProductionYear ?? null,
releaseDate: isError(parsedReleaseDate)
@@ -1581,7 +1583,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
tags: video.Tags?.filter(isNonEmptyString) ?? [],
// summary: null,
type: 'other_video',
externalKey: video.Id,
mediaItem,
identifiers: collectJellyfinItemIdentifiers(
video,

View File

@@ -1,6 +1,7 @@
import { ProgramDaoMinter } from '@/db/converters/ProgramMinter.js';
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
import { ProgramType } from '@/db/schema/Program.js';
import { MediaSourceType } from '@/db/schema/base.js';
import type { ProgramWithExternalIds } from '@/db/schema/derivedTypes.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { GlobalScheduler } from '@/services/Scheduler.js';
@@ -24,8 +25,7 @@ import {
programExternalIdTypeFromJellyfinProvider,
} from '../../db/custom_types/ProgramExternalIdType.ts';
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
import { MediaSourceId } from '../../db/schema/base.ts';
import { MediaSourceId } from '../../db/schema/base.js';
import { ReconcileProgramDurationsTaskFactory } from '../../tasks/TasksModule.ts';
import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts';

View File

@@ -1,3 +1,4 @@
import { MediaSourceType } from '@/db/schema/base.js';
import type { Nilable, Nullable } from '@/types/util.js';
import { type Maybe } from '@/types/util.js';
import { getChannelId } from '@/util/channels.js';
@@ -89,8 +90,7 @@ import { match, P } from 'ts-pattern';
import { v4 } from 'uuid';
import type { z } from 'zod/v4';
import type { PageParams } from '../../db/interfaces/IChannelDB.ts';
import type { MediaSourceLibrary } from '../../db/schema/MediaSource.ts';
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
import type { MediaSourceLibraryOrm } from '../../db/schema/MediaSource.ts';
import { ProgramType, ProgramTypes } from '../../db/schema/Program.js';
import { ProgramGroupingType } from '../../db/schema/ProgramGrouping.js';
import type { Canonicalizer } from '../../services/Canonicalizer.ts';
@@ -112,6 +112,7 @@ import type {
import { Result } from '../../types/result.ts';
import { parsePlexGuid } from '../../util/externalIds.ts';
import iterators from '../../util/iterator.ts';
import { titleToSortTitle } from '../../util/programs.ts';
import type { ApiClientOptions } from '../BaseApiClient.js';
import { QueryError, type QueryResult } from '../BaseApiClient.js';
import { MediaSourceApiClient } from '../MediaSourceApiClient.ts';
@@ -366,7 +367,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
schema: z.ZodType<PlexMetadataResponse<ItemType>>,
converter: (
item: ItemType,
libraryId: MediaSourceLibrary,
libraryId: MediaSourceLibraryOrm,
) => Result<OutType>,
pageSize: number = 50,
key: string = `/library/sections/${libraryId}/all`,
@@ -659,7 +660,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
schema: z.ZodType<PlexMetadataResponse<ItemType>>,
converter: (
plexItem: ItemType,
library: MediaSourceLibrary,
library: MediaSourceLibraryOrm,
) => Result<OutType>,
): Promise<QueryResult<OutType>> {
const queryResult = await this.getItemMetadataInternal(externalKey, schema);
@@ -678,7 +679,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
private findLibraryFromPlexMedia(
media: PlexMediaNoCollectionOrPlaylist,
libraryId?: string,
): QueryResult<MediaSourceLibrary> {
): QueryResult<MediaSourceLibraryOrm> {
libraryId ??= media.librarySectionID?.toString();
if (!isNonEmptyString(libraryId)) {
return this.makeErrorResult(
@@ -1094,7 +1095,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
private plexShowInjection(
plexShow: ApiPlexTvShow,
mediaLibrary: MediaSourceLibrary,
mediaLibrary: MediaSourceLibraryOrm,
): Result<PlexShow> {
return Result.success({
uuid: v4(),
@@ -1103,7 +1104,6 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
libraryId: mediaLibrary.uuid,
externalLibraryId: mediaLibrary.externalKey,
sourceType: MediaSourceType.Plex,
externalKey: plexShow.ratingKey,
title: plexShow.title,
type: ProgramGroupingType.Show,
year: plexShow.year ?? null,
@@ -1145,12 +1145,13 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
externalId: plexShow.ratingKey,
childCount: plexShow.childCount,
grandchildCount: plexShow.leafCount,
sortTitle: titleToSortTitle(plexShow.title),
} satisfies PlexShow);
}
private plexSeasonInjection(
plexSeason: ApiPlexTvSeason,
mediaLibrary: MediaSourceLibrary,
mediaLibrary: MediaSourceLibraryOrm,
): Result<PlexSeason> {
return Result.success({
uuid: v4(),
@@ -1159,8 +1160,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
libraryId: mediaLibrary.uuid,
externalLibraryId: mediaLibrary.externalKey,
sourceType: MediaSourceType.Plex,
externalKey: plexSeason.ratingKey,
title: plexSeason.title,
sortTitle: titleToSortTitle(plexSeason.title),
type: ProgramGroupingType.Season,
index: plexSeason.index,
releaseDate: null,
@@ -1196,8 +1197,10 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
childCount: plexSeason.leafCount,
show: plexSeason.parentRatingKey
? ({
sortTitle: plexSeason.parentTitle
? titleToSortTitle(plexSeason.parentTitle)
: '',
externalId: plexSeason.parentRatingKey,
externalKey: plexSeason.parentRatingKey,
externalLibraryId: mediaLibrary.externalKey,
identifiers: compact([
plexSeason.parentRatingKey
@@ -1239,7 +1242,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
private plexEpisodeInjection(
plexEpisode: ApiPlexEpisode,
mediaLibrary: MediaSourceLibrary,
mediaLibrary: MediaSourceLibraryOrm,
): Result<PlexEpisode> {
if (isNil(plexEpisode.duration) || plexEpisode.duration <= 0) {
return Result.forError(
@@ -1271,8 +1274,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
externalLibraryId: mediaLibrary.externalKey,
type: ProgramType.Episode,
sourceType: MediaSourceType.Plex,
externalKey: plexEpisode.ratingKey,
title: plexEpisode.title,
sortTitle: titleToSortTitle(plexEpisode.title),
originalTitle: null,
year: null,
summary: plexEpisode.summary ?? null,
@@ -1315,7 +1318,6 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
season: plexEpisode.parentRatingKey
? {
externalId: plexEpisode.parentRatingKey,
externalKey: plexEpisode.parentRatingKey,
externalLibraryId: mediaLibrary.externalKey,
identifiers: compact([
plexEpisode.parentRatingKey
@@ -1341,6 +1343,9 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
studios: [],
sourceType: 'plex',
title: plexEpisode.parentTitle ?? '',
sortTitle: plexEpisode.parentTitle
? titleToSortTitle(plexEpisode.parentTitle)
: '',
summary: null,
tagline: null,
tags: [],
@@ -1351,7 +1356,6 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
show: plexEpisode.grandparentRatingKey
? ({
externalId: plexEpisode.grandparentRatingKey,
externalKey: plexEpisode.grandparentRatingKey,
externalLibraryId: mediaLibrary.externalKey,
identifiers: compact([
plexEpisode.grandparentRatingKey
@@ -1376,6 +1380,9 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
studios: [],
sourceType: 'plex',
title: plexEpisode.grandparentTitle ?? '',
sortTitle: plexEpisode.grandparentTitle
? titleToSortTitle(plexEpisode.grandparentTitle)
: '',
summary: null,
tagline: null,
tags: [],
@@ -1397,7 +1404,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
private plexMovieInjection(
plexMovie: ApiPlexMovie,
mediaLibrary: MediaSourceLibrary,
mediaLibrary: MediaSourceLibraryOrm,
): Result<PlexMovie> {
if (isNil(plexMovie.duration) || plexMovie.duration <= 0) {
return Result.forError(
@@ -1432,8 +1439,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
libraryId: mediaLibrary.uuid,
externalLibraryId: mediaLibrary.externalKey,
sourceType: MediaSourceType.Plex,
externalKey: plexMovie.ratingKey,
title: plexMovie.title,
sortTitle: titleToSortTitle(plexMovie.title),
originalTitle: null,
year: plexMovie.year ?? null,
releaseDate: plexMovie.originallyAvailableAt
@@ -1480,7 +1487,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
private plexOtherVideoInjection(
plexClip: ApiPlexMovie,
mediaLibrary: MediaSourceLibrary,
mediaLibrary: MediaSourceLibraryOrm,
): Result<PlexOtherVideo> {
if (isNil(plexClip.duration) || plexClip.duration <= 0) {
return Result.forError(
@@ -1515,6 +1522,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
sourceType: MediaSourceType.Plex,
externalKey: plexClip.ratingKey,
title: plexClip.title,
sortTitle: titleToSortTitle(plexClip.title),
originalTitle: null,
year: plexClip.year ?? null,
releaseDate: plexClip.originallyAvailableAt
@@ -1560,7 +1568,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
private plexMusicArtistInjection(
plexArtist: ApiPlexMusicArtist,
mediaLibrary: MediaSourceLibrary,
mediaLibrary: MediaSourceLibraryOrm,
): Result<PlexArtist> {
return Result.success({
uuid: v4(),
@@ -1569,8 +1577,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
libraryId: mediaLibrary.uuid,
externalLibraryId: mediaLibrary.externalKey,
sourceType: MediaSourceType.Plex,
externalKey: plexArtist.ratingKey,
title: plexArtist.title,
sortTitle: titleToSortTitle(plexArtist.title),
type: ProgramGroupingType.Artist,
tagline: null,
genres: plexJoinItemInject(plexArtist.Genre),
@@ -1602,7 +1610,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
private plexAlbumInjection(
plexAlbum: ApiPlexMusicAlbum,
mediaLibrary: MediaSourceLibrary,
mediaLibrary: MediaSourceLibraryOrm,
): Result<PlexAlbum> {
return Result.success({
uuid: v4(),
@@ -1611,8 +1619,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
libraryId: mediaLibrary.uuid,
externalLibraryId: mediaLibrary.externalKey,
sourceType: MediaSourceType.Plex,
externalKey: plexAlbum.ratingKey,
title: plexAlbum.title,
sortTitle: titleToSortTitle(plexAlbum.title),
type: ProgramGroupingType.Album,
index: plexAlbum.index,
genres: plexJoinItemInject(plexAlbum.Genre),
@@ -1653,7 +1661,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
private plexTrackInjection(
plexTrack: ApiPlexMusicTrack,
mediaLibrary: MediaSourceLibrary,
mediaLibrary: MediaSourceLibraryOrm,
): Result<PlexTrack, WrappedError> {
if (isNil(plexTrack.duration) || plexTrack.duration <= 0) {
return Result.forError(
@@ -1679,8 +1687,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
externalLibraryId: mediaLibrary.externalKey,
type: ProgramType.Track,
sourceType: MediaSourceType.Plex,
externalKey: plexTrack.ratingKey,
title: plexTrack.title,
sortTitle: titleToSortTitle(plexTrack.title),
originalTitle: null,
year: plexTrack.parentYear ?? null,
duration: plexTrack.duration ?? 0,
@@ -1722,7 +1730,6 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
album: plexTrack.parentRatingKey
? {
externalId: plexTrack.parentRatingKey,
externalKey: plexTrack.parentRatingKey,
externalLibraryId: mediaLibrary.externalKey,
identifiers: compact([
plexTrack.parentRatingKey
@@ -1748,6 +1755,9 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
studios: [],
sourceType: 'plex',
title: plexTrack.parentTitle ?? '',
sortTitle: plexTrack.parentTitle
? titleToSortTitle(plexTrack.parentTitle)
: '',
summary: null,
tagline: null,
tags: [],
@@ -1758,7 +1768,6 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
artist: plexTrack.grandparentRatingKey
? ({
externalId: plexTrack.grandparentRatingKey,
externalKey: plexTrack.grandparentRatingKey,
externalLibraryId: mediaLibrary.externalKey,
identifiers: compact([
plexTrack.grandparentRatingKey
@@ -1780,6 +1789,9 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
plot: null,
sourceType: 'plex',
title: plexTrack.grandparentTitle ?? '',
sortTitle: plexTrack.grandparentTitle
? titleToSortTitle(plexTrack.grandparentTitle)
: '',
summary: null,
tagline: null,
tags: [],

View File

@@ -214,7 +214,7 @@ function calculateScaledSize(
videoStream: VideoStreamDetails,
) {
const { widthPx: targetW, heightPx: targetH } = config.resolution;
const [width, height] = videoStream.sampleAspectRatio
const [width, height] = (videoStream.sampleAspectRatio ?? '1:1')
.split(':')
.map((i) => parseInt(i));
const sarSize: Resolution = { widthPx: width, heightPx: height };

View File

@@ -314,9 +314,9 @@ export class FfmpegStreamFactory extends IFFMPEG {
if (streamDetails.videoDetails) {
const [videoStreamDetails] = streamDetails.videoDetails;
const streamIndex = isNonEmptyString(videoStreamDetails.streamIndex)
? parseInt(videoStreamDetails.streamIndex)
: 0;
const streamIndex = isUndefined(videoStreamDetails.streamIndex)
? 0
: videoStreamDetails.streamIndex;
let pixelFormat: Maybe<PixelFormat>;
if (videoStreamDetails.pixelFormat) {

View File

@@ -140,10 +140,10 @@ export class SubtitleStreamPicker {
const cacheFolder = this.getCacheFolder();
const filePath = getSubtitleCacheFilePath(
{
id: lineupItem.programId,
externalKey: lineupItem.externalKey,
externalSourceId: lineupItem.externalSourceId,
externalSourceType: lineupItem.externalSource,
id: lineupItem.program.uuid,
externalKey: lineupItem.program.externalKey,
externalSourceId: lineupItem.program.mediaSourceId,
externalSourceType: lineupItem.program.sourceType,
},
stream,
);

View File

@@ -115,9 +115,10 @@ export class NvidiaGpuDetectionHelper {
'null',
'-',
],
true,
{},
true,
{
swallowError: true,
isPath: true,
},
);
const lines = reject(

View File

@@ -51,8 +51,6 @@ export class QsvHardwareCapabilitiesFactory
const output = await new ChildProcessHelper().getStdout(
this.ffmpegSettings.ffmpegExecutablePath,
['-hide_banner', '-help', 'decoder=h264_qsv'],
false,
{},
);
const nonEmptyLines = reject(map(drop(split(output, '\n'), 1), trim), (s) =>

View File

@@ -13,11 +13,13 @@ export class VainfoProcessHelper {
new ChildProcessHelper().getStdout(
'vainfo',
['--display', display, '--device', vaapiDevice, '-a'],
swallowError,
isNonEmptyString(vaapiDriver)
? { LIBVA_DRIVER_NAME: vaapiDriver }
: undefined,
swallowError,
{
swallowError,
env: isNonEmptyString(vaapiDriver)
? { LIBVA_DRIVER_NAME: vaapiDriver }
: undefined,
isPath: false,
},
),
);
}

View File

@@ -2,7 +2,10 @@ import { FfprobeMediaInfoSchema } from '@/types/ffmpeg.js';
import { KEYS } from '@/types/inject.js';
import { Result } from '@/types/result.js';
import { Nullable } from '@/types/util.js';
import { ChildProcessHelper } from '@/util/ChildProcessHelper.js';
import {
ChildProcessHelper,
GetStdoutOptions,
} from '@/util/ChildProcessHelper.js';
import { cacheGetOrSet } from '@/util/cache.js';
import dayjs from '@/util/dayjs.js';
import { Logger } from '@/util/logging/LoggerFactory.js';
@@ -303,18 +306,21 @@ export class FfmpegInfo {
);
}
async probeFile(path: string) {
const output = await this.getFfprobeStdout([
'-hide_banner',
'-print_format',
'json',
'-show_format',
'-show_chapters',
'-show_streams',
'-analyzeduration',
'30',
`${path}`,
]);
async probeFile(path: string, timeout?: number) {
const output = await this.getFfprobeStdout(
[
'-hide_banner',
'-print_format',
'json',
'-show_format',
'-show_chapters',
'-show_streams',
'-analyzeduration',
'30',
`${path}`,
],
{ timeout, swallowError: false },
);
const result = await FfprobeMediaInfoSchema.safeParseAsync(
JSON.parse(output),
@@ -335,32 +341,24 @@ export class FfmpegInfo {
private getFfmpegStdout(
args: string[],
swallowError: boolean = false,
opts: GetStdoutOptions = { swallowError: false },
): Promise<string> {
return this.getStdout(this.ffmpegPath, args, swallowError);
return this.getStdout(this.ffmpegPath, args, opts);
}
private getFfprobeStdout(
args: string[],
swallowError: boolean = false,
opts: GetStdoutOptions = { swallowError: false },
): Promise<string> {
return this.getStdout(this.ffprobePath, args, swallowError);
return this.getStdout(this.ffprobePath, args, opts);
}
private getStdout(
executable: string,
args: string[],
swallowError: boolean = false,
env?: NodeJS.ProcessEnv,
isPath: boolean = true,
opts?: GetStdoutOptions,
): Promise<string> {
return new ChildProcessHelper().getStdout(
executable,
args,
swallowError,
env,
isPath,
);
return new ChildProcessHelper().getStdout(executable, args, opts);
}
private cacheKey(key: keyof typeof CacheKeys): string {

View File

@@ -1,11 +1,6 @@
import type { StreamLineupItem } from '../db/derived_types/StreamLineup.ts';
export interface IStreamLineupCache {
getCurrentLineupItem(
channelId: string,
timeNow: number,
): StreamLineupItem | undefined;
getProgramLastPlayTime(channelId: string, programId: string): number;
getFillerLastPlayTime(channelId: string, fillerId: string): number;
@@ -17,6 +12,4 @@ export interface IStreamLineupCache {
): Promise<void>;
clear(): void;
clearPlayback(channelId: string): Promise<void>;
}

View File

@@ -36,6 +36,14 @@ import Migration1756312561_InitialAdvancedTranscodeConfig from './db/Migration17
import Migration1756381281_AddLibraries from './db/Migration1756381281_AddLibraries.ts';
import Migration1757704591_AddProgramMediaSourceIndex from './db/Migration1757704591_AddProgramMediaSourceIndex.ts';
import Migration1758203109_AddProgramMedia from './db/Migration1758203109_AddProgramMedia.ts';
import Migration1758570688_AddLocalLibraries from './db/Migration1758570688_AddLocalLibraries.ts';
import Migration1758732083_FixLocalLibraryPath from './db/Migration1758732083_FixLocalLibraryPath.ts';
import Migration1758903045_FixLocalLibraryPathAgain from './db/Migration1758903045_FixLocalLibraryPathAgain.ts';
import Migration1759170884_AddArtworkAndMore from './db/Migration1759170884_AddArtworkAndMore.ts';
import Migration1759518565_AddProgramSubtitles from './db/Migration1759518565_AddProgramSubtitles.ts';
import Migration1760129429_AddProgramGroupingSourceType from './db/Migration1760129429_AddProgramGroupingSourceType.ts';
import Migration1760213210_AddMoreProgramGroupingFields from './db/Migration1760213210_AddMoreProgramGroupingFields.ts';
import Migration1760455673_UpdateForeignKeyCasacades from './db/Migration1760455673_UpdateForeignKeyCasacades.ts';
export const LegacyMigrationNameToNewMigrationName = [
['Migration20240124115044', '_Legacy_Migration00'],
@@ -119,6 +127,21 @@ export class DirectMigrationProvider implements MigrationProvider {
migration1756381281: Migration1756381281_AddLibraries,
migration1757704591: Migration1757704591_AddProgramMediaSourceIndex,
migration1758203109: Migration1758203109_AddProgramMedia,
migration1758570688: Migration1758570688_AddLocalLibraries,
migration1758732083_FixLocalLibraryPath:
Migration1758732083_FixLocalLibraryPath,
migration1758903045_FixLocalLibraryPathAgain:
Migration1758903045_FixLocalLibraryPathAgain,
migration1759170884_AddArtworkAndMore:
Migration1759170884_AddArtworkAndMore,
migration1759518565_AddProgramSubtitles:
Migration1759518565_AddProgramSubtitles,
migration1760129429_AddProgramGroupingSourceType:
Migration1760129429_AddProgramGroupingSourceType,
migration1760213210_AddMoreProgramGroupingFields:
Migration1760213210_AddMoreProgramGroupingFields,
migration1760455673_UpdateForeignKeyCasacades:
Migration1760455673_UpdateForeignKeyCasacades,
},
wrapWithTransaction,
),

View File

@@ -0,0 +1,35 @@
import type { interfaces } from 'inversify';
import { sortBy } from 'lodash-es';
import assert from 'node:assert';
import { container } from '../container.ts';
import type { Json } from '../types/schemas.ts';
export interface MigrationStep {
from: number;
to: number;
migrate(input: Json): Promise<void>;
}
export abstract class JsonFileMigrator<StepClass extends MigrationStep> {
protected pipeline: StepClass[];
constructor(migrationStepKeys: interfaces.ServiceIdentifier<StepClass>[]) {
const allSteps = sortBy(
migrationStepKeys.map((step) => container.get(step)),
({ from }) => from,
);
for (let i = 0; i < allSteps.length; i++) {
if (i === 0) {
continue;
} else {
const prevStep = allSteps[i - 1];
const thisStep = allSteps[i];
assert(prevStep.to === thisStep.from);
}
}
this.pipeline = allSteps;
}
abstract run(): Promise<void>;
}

View File

@@ -5,7 +5,7 @@ import type {
WithCreatedAt,
WithUpdatedAt,
WithUuid,
} from '../../db/schema/base.ts';
} from '../../db/schema/base.js';
interface CurrentProgramExternalIdTable
extends WithUuid,

View File

@@ -5,7 +5,7 @@ import type {
WithCreatedAt,
WithUpdatedAt,
WithUuid,
} from '../../db/schema/base.ts';
} from '../../db/schema/base.js';
interface CurrentProgramExternalIdTable
extends WithUuid,

View File

@@ -1,4 +1,4 @@
import type { MediaSourceType } from '@/db/schema/MediaSource.js';
import type { MediaSourceType } from '@/db/schema/base.js';
import type { ProgramGroupingType } from '@/db/schema/ProgramGrouping.js';
import type { Kysely } from 'kysely';
import { CompiledQuery, sql } from 'kysely';
@@ -7,7 +7,7 @@ import type {
WithCreatedAt,
WithUpdatedAt,
WithUuid,
} from '../../db/schema/base.ts';
} from '../../db/schema/base.js';
interface ProgramGroupingInMigration
extends WithUuid,

View File

@@ -1,6 +1,6 @@
import type { ChannelFillerShowTable } from '@/db/schema/Channel.js';
import type { Kysely, Migration } from 'kysely';
import { CompiledQuery } from 'kysely';
import type { ChannelFillerShowTable } from '../../db/schema/ChannelFillerShow.ts';
type DBTemp = {
channelFillerShowTmp: ChannelFillerShowTable;

View File

@@ -1,6 +1,6 @@
import type { CustomShowContent } from '@/db/schema/CustomShow.js';
import type { Kysely, Migration } from 'kysely';
import { CompiledQuery } from 'kysely';
import type { CustomShowContent } from '../../db/schema/CustomShowContent.ts';
type DBTemp = {
customShowContentTmp: CustomShowContent;

View File

@@ -0,0 +1,6 @@
import { makeKyselyMigrationFromSqlFile } from './util.ts';
export default makeKyselyMigrationFromSqlFile(
'./sql/0013_silent_the_anarchist.sql',
true,
);

View File

@@ -0,0 +1,3 @@
import { makeKyselyMigrationFromSqlFile } from './util.ts';
export default makeKyselyMigrationFromSqlFile('./sql/0014_gray_mongu.sql');

View File

@@ -0,0 +1,3 @@
import { makeKyselyMigrationFromSqlFile } from './util.ts';
export default makeKyselyMigrationFromSqlFile('./sql/0015_cuddly_midnight.sql');

View File

@@ -0,0 +1,6 @@
import { makeKyselyMigrationFromSqlFile } from './util.ts';
export default makeKyselyMigrationFromSqlFile(
'./sql/0016_wealthy_dragon_lord.sql',
true,
);

View File

@@ -0,0 +1,5 @@
import { makeKyselyMigrationFromSqlFile } from './util.ts';
export default makeKyselyMigrationFromSqlFile(
'./sql/0017_glossy_lorna_dane.sql',
);

View File

@@ -0,0 +1,5 @@
import { makeKyselyMigrationFromSqlFile } from './util.ts';
export default makeKyselyMigrationFromSqlFile(
'./sql/0018_lumpy_rick_jones.sql',
);

View File

@@ -0,0 +1,3 @@
import { makeKyselyMigrationFromSqlFile } from './util.ts';
export default makeKyselyMigrationFromSqlFile('./sql/0019_purple_thanos.sql');

View File

@@ -0,0 +1,6 @@
import { makeKyselyMigrationFromSqlFile } from './util.ts';
export default makeKyselyMigrationFromSqlFile(
'./sql/0020_whole_the_hand.sql',
true,
);

View File

@@ -0,0 +1,97 @@
CREATE TABLE `local_media_folder` (
`uuid` text PRIMARY KEY NOT NULL,
`path` text NOT NULL,
`local_media_source_path_id` text NOT NULL,
FOREIGN KEY (`local_media_source_path_id`) REFERENCES `local_media_source_path`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `local_media_source_path` (
`uuid` text PRIMARY KEY NOT NULL,
`media_source_id` text NOT NULL,
`path` text NOT NULL,
`last_scanned_at` integer,
`canonical_id` text NOT NULL,
FOREIGN KEY (`media_source_id`) REFERENCES `media_source`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_media_source` (
`uuid` text PRIMARY KEY NOT NULL,
`created_at` integer,
`updated_at` integer,
`access_token` text NOT NULL,
`client_identifier` text,
`index` integer NOT NULL,
`name` text NOT NULL,
`send_channel_updates` integer DEFAULT false,
`send_guide_updates` integer DEFAULT false,
`type` text NOT NULL,
`uri` text NOT NULL,
`username` text,
`user_id` text,
CONSTRAINT "media_source_type_check" CHECK("__new_media_source"."type" in ('plex', 'jellyfin', 'emby', 'local'))
);
--> statement-breakpoint
INSERT INTO `__new_media_source`("uuid", "created_at", "updated_at", "access_token", "client_identifier", "index", "name", "send_channel_updates", "send_guide_updates", "type", "uri", "username", "user_id") SELECT "uuid", "created_at", "updated_at", "access_token", "client_identifier", "index", "name", "send_channel_updates", "send_guide_updates", "type", "uri", "username", "user_id" FROM `media_source`;--> statement-breakpoint
DROP TABLE `media_source`;--> statement-breakpoint
ALTER TABLE `__new_media_source` RENAME TO `media_source`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE TABLE `__new_program` (
`uuid` text PRIMARY KEY NOT NULL,
`created_at` integer,
`updated_at` integer,
`album_name` text,
`album_uuid` text,
`artist_name` text,
`artist_uuid` text,
`canonical_id` text,
`duration` integer NOT NULL,
`episode` integer,
`episode_icon` text,
`external_key` text NOT NULL,
`external_source_id` text NOT NULL,
`media_source_id` text,
`library_id` text,
`local_media_folder_id` text,
`local_media_source_path_id` text,
`file_path` text,
`grandparent_external_key` text,
`icon` text,
`original_air_date` text,
`parent_external_key` text,
`plex_file_path` text,
`plex_rating_key` text,
`rating` text,
`season_icon` text,
`season_number` integer,
`season_uuid` text,
`show_icon` text,
`show_title` text,
`source_type` text NOT NULL,
`summary` text,
`title` text NOT NULL,
`tv_show_uuid` text,
`type` text NOT NULL,
`year` integer,
FOREIGN KEY (`album_uuid`) REFERENCES `program_grouping`(`uuid`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`artist_uuid`) REFERENCES `program_grouping`(`uuid`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`media_source_id`) REFERENCES `media_source`(`uuid`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`library_id`) REFERENCES `media_source_library`(`uuid`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`local_media_folder_id`) REFERENCES `local_media_folder`(`uuid`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`local_media_source_path_id`) REFERENCES `local_media_source_path`(`uuid`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`season_uuid`) REFERENCES `program_grouping`(`uuid`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`tv_show_uuid`) REFERENCES `program_grouping`(`uuid`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "program_type_check" CHECK("__new_program"."type" in ('movie', 'episode', 'track', 'music_video', 'other_video')),
CONSTRAINT "program_source_type_check" CHECK("__new_program"."source_type" in ('plex', 'jellyfin', 'emby', 'local'))
);
--> statement-breakpoint
INSERT INTO `__new_program`("uuid", "created_at", "updated_at", "album_name", "album_uuid", "artist_name", "artist_uuid", "canonical_id", "duration", "episode", "episode_icon", "external_key", "external_source_id", "media_source_id", "library_id", "file_path", "grandparent_external_key", "icon", "original_air_date", "parent_external_key", "plex_file_path", "plex_rating_key", "rating", "season_icon", "season_number", "season_uuid", "show_icon", "show_title", "source_type", "summary", "title", "tv_show_uuid", "type", "year") SELECT "uuid", "created_at", "updated_at", "album_name", "album_uuid", "artist_name", "artist_uuid", "canonical_id", "duration", "episode", "episode_icon", "external_key", "external_source_id", "media_source_id", "library_id", "file_path", "grandparent_external_key", "icon", "original_air_date", "parent_external_key", "plex_file_path", "plex_rating_key", "rating", "season_icon", "season_number", "season_uuid", "show_icon", "show_title", "source_type", "summary", "title", "tv_show_uuid", "type", "year" FROM `program`;--> statement-breakpoint
DROP TABLE `program`;--> statement-breakpoint
ALTER TABLE `__new_program` RENAME TO `program`;--> statement-breakpoint
CREATE INDEX `program_season_uuid_index` ON `program` (`season_uuid`);--> statement-breakpoint
CREATE INDEX `program_tv_show_uuid_index` ON `program` (`tv_show_uuid`);--> statement-breakpoint
CREATE INDEX `program_album_uuid_index` ON `program` (`album_uuid`);--> statement-breakpoint
CREATE INDEX `program_artist_uuid_index` ON `program` (`artist_uuid`);--> statement-breakpoint
CREATE UNIQUE INDEX `program_source_type_external_source_id_external_key_unique` ON `program` (`source_type`,`external_source_id`,`external_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `program_source_type_media_source_external_key_unique` ON `program` (`source_type`,`media_source_id`,`external_key`);--> statement-breakpoint
CREATE INDEX `program_canonical_id_index` ON `program` (`canonical_id`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `local_media_folder` ADD `canonical_id` text NOT NULL;--> statement-breakpoint
ALTER TABLE `media_source` ADD `media_type` text;--> statement-breakpoint
ALTER TABLE `local_media_source_path` DROP COLUMN `canonical_id`;

View File

@@ -0,0 +1,13 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_local_media_folder` (
`uuid` text PRIMARY KEY NOT NULL,
`path` text NOT NULL,
`library_id` text NOT NULL,
`canonical_id` text NOT NULL,
`parent_id` text,
FOREIGN KEY (`library_id`) REFERENCES `media_source_library`(`uuid`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
DROP TABLE `local_media_folder`;--> statement-breakpoint
ALTER TABLE `__new_local_media_folder` RENAME TO `local_media_folder`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,76 @@
CREATE TABLE `artwork` (
`uuid` text PRIMARY KEY NOT NULL,
`cache_path` text NOT NULL,
`source_path` text NOT NULL,
`artwork_type` text NOT NULL,
`blur_hash43` text,
`blur_hash64` text,
`program_id` text,
`grouping_id` text,
`created_at` integer,
`updated_at` integer,
FOREIGN KEY (`program_id`) REFERENCES `program`(`uuid`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`grouping_id`) REFERENCES `program_grouping`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `artwork_program_idx` ON `artwork` (`program_id`);--> statement-breakpoint
CREATE TABLE `program_media_file` (
`uuid` text PRIMARY KEY NOT NULL,
`path` text NOT NULL,
`program_version_id` text NOT NULL,
`local_media_folder_id` text,
FOREIGN KEY (`program_version_id`) REFERENCES `program_version`(`uuid`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`local_media_folder_id`) REFERENCES `local_media_folder`(`uuid`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `program_media_file_program_version_idx` ON `program_media_file` (`program_version_id`);--> statement-breakpoint
CREATE INDEX `program_media_file_folder_idx` ON `program_media_file` (`local_media_folder_id`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_program_media_stream` (
`uuid` text PRIMARY KEY NOT NULL,
`index` integer NOT NULL,
`codec` text NOT NULL,
`profile` text,
`stream_kind` text NOT NULL,
`title` text,
`language` text,
`channels` integer,
`default` integer DEFAULT false NOT NULL,
`forced` integer DEFAULT false NOT NULL,
`pixel_format` text,
`color_range` text,
`color_space` text,
`color_transfer` text,
`color_primaries` text,
`bits_per_sample` integer,
`program_version_id` text NOT NULL,
FOREIGN KEY (`program_version_id`) REFERENCES `program_version`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_program_media_stream`("uuid", "index", "codec", "profile", "stream_kind", "title", "language", "channels", "default", "forced", "pixel_format", "color_range", "color_space", "color_transfer", "color_primaries", "bits_per_sample", "program_version_id") SELECT "uuid", "index", "codec", "profile", "stream_kind", "title", "language", "channels", "default", "forced", "pixel_format", "color_range", "color_space", "color_transfer", "color_primaries", "bits_per_sample", "program_version_id" FROM `program_media_stream`;--> statement-breakpoint
DROP TABLE `program_media_stream`;--> statement-breakpoint
ALTER TABLE `__new_program_media_stream` RENAME TO `program_media_stream`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `index_program_version_id` ON `program_media_stream` (`program_version_id`);--> statement-breakpoint
CREATE TABLE `__new_program_version` (
`uuid` text PRIMARY KEY NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`duration` integer,
`sample_aspect_ratio` text,
`display_aspect_ratio` text,
`frame_rate` text,
`scan_kind` text,
`width` integer,
`height` integer,
`program_id` text NOT NULL,
FOREIGN KEY (`program_id`) REFERENCES `program`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_program_version`("uuid", "created_at", "updated_at", "duration", "sample_aspect_ratio", "display_aspect_ratio", "frame_rate", "scan_kind", "width", "height", "program_id") SELECT "uuid", "created_at", "updated_at", "duration", "sample_aspect_ratio", "display_aspect_ratio", "frame_rate", "scan_kind", "width", "height", "program_id" FROM `program_version`;--> statement-breakpoint
DROP TABLE `program_version`;--> statement-breakpoint
ALTER TABLE `__new_program_version` RENAME TO `program_version`;--> statement-breakpoint
CREATE INDEX `index_program_version_program_id` ON `program_version` (`program_id`);--> statement-breakpoint
CREATE INDEX `local_media_folder_library_id_path_idx` ON `local_media_folder` (`library_id`,`path`);--> statement-breakpoint
CREATE INDEX `local_media_folder_path_idx` ON `local_media_folder` (`path`);--> statement-breakpoint
CREATE INDEX `local_media_folder_canonical_id_id` ON `local_media_folder` (`canonical_id`);

View File

@@ -0,0 +1,16 @@
CREATE TABLE `program_subtitles` (
`uuid` text PRIMARY KEY NOT NULL,
`subtitle_type` text NOT NULL,
`stream_index` integer,
`codec` text NOT NULL,
`default` integer DEFAULT false NOT NULL,
`forced` integer DEFAULT false NOT NULL,
`sdh` integer DEFAULT false NOT NULL,
`language` text NOT NULL,
`path` text,
`program_id` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`is_extracted` integer DEFAULT false,
FOREIGN KEY (`program_id`) REFERENCES `program`(`uuid`) ON UPDATE no action ON DELETE cascade
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `program_grouping` ADD `source_type` text;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS `unique_program_grouping_multiple_external_id_media_source` ON `program_grouping_external_id` (`group_uuid`,`source_type`,`media_source_id`) WHERE `media_source_id is not null`;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS `unique_program_grouping_single_external_id_media_source` ON `program_grouping_external_id` (`group_uuid`,`source_type`,`media_source_id`) WHERE `media_source_id is null`;

View File

@@ -0,0 +1,2 @@
ALTER TABLE `program_grouping` ADD `external_key` text;--> statement-breakpoint
ALTER TABLE `program_grouping` ADD `media_source_id` text REFERENCES media_source(uuid);

View File

@@ -0,0 +1,80 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_local_media_folder` (
`uuid` text PRIMARY KEY NOT NULL,
`path` text NOT NULL,
`library_id` text NOT NULL,
`canonical_id` text NOT NULL,
`parent_id` text,
FOREIGN KEY (`library_id`) REFERENCES `media_source_library`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_local_media_folder`("uuid", "path", "library_id", "canonical_id", "parent_id") SELECT "uuid", "path", "library_id", "canonical_id", "parent_id" FROM `local_media_folder`;--> statement-breakpoint
DROP TABLE `local_media_folder`;--> statement-breakpoint
ALTER TABLE `__new_local_media_folder` RENAME TO `local_media_folder`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `local_media_folder_library_id_path_idx` ON `local_media_folder` (`library_id`,`path`);--> statement-breakpoint
CREATE INDEX `local_media_folder_path_idx` ON `local_media_folder` (`path`);--> statement-breakpoint
CREATE INDEX `local_media_folder_canonical_id_id` ON `local_media_folder` (`canonical_id`);--> statement-breakpoint
CREATE TABLE `__new_program_media_file` (
`uuid` text PRIMARY KEY NOT NULL,
`path` text NOT NULL,
`program_version_id` text NOT NULL,
`local_media_folder_id` text,
FOREIGN KEY (`program_version_id`) REFERENCES `program_version`(`uuid`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`local_media_folder_id`) REFERENCES `local_media_folder`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_program_media_file`("uuid", "path", "program_version_id", "local_media_folder_id") SELECT "uuid", "path", "program_version_id", "local_media_folder_id" FROM `program_media_file`;--> statement-breakpoint
DROP TABLE `program_media_file`;--> statement-breakpoint
ALTER TABLE `__new_program_media_file` RENAME TO `program_media_file`;--> statement-breakpoint
CREATE INDEX `program_media_file_program_version_idx` ON `program_media_file` (`program_version_id`);--> statement-breakpoint
CREATE INDEX `program_media_file_folder_idx` ON `program_media_file` (`local_media_folder_id`);--> statement-breakpoint
CREATE TABLE `__new_channel_subtitle_preferences` (
`uuid` text PRIMARY KEY NOT NULL,
`language_code` text NOT NULL,
`priority` integer NOT NULL,
`allow_image_based` integer DEFAULT true NOT NULL,
`allow_external` integer DEFAULT true NOT NULL,
`filter_type` text DEFAULT 'any' NOT NULL,
`channel_id` text NOT NULL,
FOREIGN KEY (`channel_id`) REFERENCES `channel`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_channel_subtitle_preferences`("uuid", "language_code", "priority", "allow_image_based", "allow_external", "filter_type", "channel_id") SELECT "uuid", "language_code", "priority", "allow_image_based", "allow_external", "filter_type", "channel_id" FROM `channel_subtitle_preferences`;--> statement-breakpoint
DROP TABLE `channel_subtitle_preferences`;--> statement-breakpoint
ALTER TABLE `__new_channel_subtitle_preferences` RENAME TO `channel_subtitle_preferences`;--> statement-breakpoint
CREATE INDEX `channel_priority_index` ON `channel_subtitle_preferences` (`channel_id`,`priority`);--> statement-breakpoint
CREATE TABLE `__new_custom_show_subtitle_preferences` (
`uuid` text PRIMARY KEY NOT NULL,
`language_code` text NOT NULL,
`priority` integer NOT NULL,
`allow_image_based` integer DEFAULT true NOT NULL,
`allow_external` integer DEFAULT true NOT NULL,
`filter_type` text DEFAULT 'any' NOT NULL,
`custom_show_id` text NOT NULL,
FOREIGN KEY (`custom_show_id`) REFERENCES `custom_show`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_custom_show_subtitle_preferences`("uuid", "language_code", "priority", "allow_image_based", "allow_external", "filter_type", "custom_show_id") SELECT "uuid", "language_code", "priority", "allow_image_based", "allow_external", "filter_type", "custom_show_id" FROM `custom_show_subtitle_preferences`;--> statement-breakpoint
DROP TABLE `custom_show_subtitle_preferences`;--> statement-breakpoint
ALTER TABLE `__new_custom_show_subtitle_preferences` RENAME TO `custom_show_subtitle_preferences`;--> statement-breakpoint
CREATE INDEX `custom_show_priority_index` ON `custom_show_subtitle_preferences` (`custom_show_id`,`priority`);--> statement-breakpoint
CREATE TABLE `__new_program_version` (
`uuid` text PRIMARY KEY NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`duration` integer NOT NULL,
`sample_aspect_ratio` text,
`display_aspect_ratio` text,
`frame_rate` text,
`scan_kind` text NOT NULL,
`width` integer NOT NULL,
`height` integer NOT NULL,
`program_id` text NOT NULL,
FOREIGN KEY (`program_id`) REFERENCES `program`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_program_version`("uuid", "created_at", "updated_at", "duration", "sample_aspect_ratio", "display_aspect_ratio", "frame_rate", "scan_kind", "width", "height", "program_id") SELECT "uuid", "created_at", "updated_at", "duration", "sample_aspect_ratio", "display_aspect_ratio", "frame_rate", "scan_kind", "width", "height", "program_id" FROM `program_version`;--> statement-breakpoint
DROP TABLE `program_version`;--> statement-breakpoint
ALTER TABLE `__new_program_version` RENAME TO `program_version`;--> statement-breakpoint
CREATE INDEX `index_program_version_program_id` ON `program_version` (`program_id`);

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