mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
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:
committed by
GitHub
parent
9a791467df
commit
a748408fcc
1
docs/generated/tunarr-v0.23.0-alpha.8-openapi.json
Normal file
1
docs/generated/tunarr-v0.23.0-alpha.8-openapi.json
Normal file
File diff suppressed because one or more lines are too long
333
pnpm-lock.yaml
generated
333
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(' '),
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] }> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
50
server/src/db/DrizzleSqlCaseWhen.ts
Normal file
50
server/src/db/DrizzleSqlCaseWhen.ts
Normal 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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
162
server/src/db/LocalMediaDB.ts
Normal file
162
server/src/db/LocalMediaDB.ts
Normal 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;
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
19
server/src/db/programQueryHelpers.test.ts
Normal file
19
server/src/db/programQueryHelpers.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
68
server/src/db/schema/Artwork.ts
Normal file
68
server/src/db/schema/Artwork.ts
Normal 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>;
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
38
server/src/db/schema/ChannelCustomShow.ts
Normal file
38
server/src/db/schema/ChannelCustomShow.ts
Normal 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],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
21
server/src/db/schema/ChannelFallback.ts
Normal file
21
server/src/db/schema/ChannelFallback.ts
Normal 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>;
|
||||
46
server/src/db/schema/ChannelFillerShow.ts
Normal file
46
server/src/db/schema/ChannelFillerShow.ts
Normal 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],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
37
server/src/db/schema/ChannelPrograms.ts
Normal file
37
server/src/db/schema/ChannelPrograms.ts
Normal 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],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
43
server/src/db/schema/CustomShowContent.ts
Normal file
43
server/src/db/schema/CustomShowContent.ts
Normal 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],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
45
server/src/db/schema/FillerShowContent.ts
Normal file
45
server/src/db/schema/FillerShowContent.ts
Normal 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],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
46
server/src/db/schema/LocalMediaFolder.ts
Normal file
46
server/src/db/schema/LocalMediaFolder.ts
Normal 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>;
|
||||
49
server/src/db/schema/LocalMediaSourcePath.ts
Normal file
49
server/src/db/schema/LocalMediaSourcePath.ts
Normal 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>;
|
||||
@@ -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],
|
||||
}),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
35
server/src/db/schema/ProgramSubtitles.ts
Normal file
35
server/src/db/schema/ProgramSubtitles.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>;
|
||||
|
||||
21
server/src/external/MediaSourceApiFactory.ts
vendored
21
server/src/external/MediaSourceApiFactory.ts
vendored
@@ -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,
|
||||
|
||||
19
server/src/external/emby/EmbyApiClient.ts
vendored
19
server/src/external/emby/EmbyApiClient.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
62
server/src/external/plex/PlexApiClient.ts
vendored
62
server/src/external/plex/PlexApiClient.ts
vendored
@@ -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: [],
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -115,9 +115,10 @@ export class NvidiaGpuDetectionHelper {
|
||||
'null',
|
||||
'-',
|
||||
],
|
||||
true,
|
||||
{},
|
||||
true,
|
||||
{
|
||||
swallowError: true,
|
||||
isPath: true,
|
||||
},
|
||||
);
|
||||
|
||||
const lines = reject(
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
35
server/src/migration/JsonFileMigrator.ts
Normal file
35
server/src/migration/JsonFileMigrator.ts
Normal 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>;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
WithCreatedAt,
|
||||
WithUpdatedAt,
|
||||
WithUuid,
|
||||
} from '../../db/schema/base.ts';
|
||||
} from '../../db/schema/base.js';
|
||||
|
||||
interface CurrentProgramExternalIdTable
|
||||
extends WithUuid,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
WithCreatedAt,
|
||||
WithUpdatedAt,
|
||||
WithUuid,
|
||||
} from '../../db/schema/base.ts';
|
||||
} from '../../db/schema/base.js';
|
||||
|
||||
interface CurrentProgramExternalIdTable
|
||||
extends WithUuid,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { makeKyselyMigrationFromSqlFile } from './util.ts';
|
||||
|
||||
export default makeKyselyMigrationFromSqlFile(
|
||||
'./sql/0013_silent_the_anarchist.sql',
|
||||
true,
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { makeKyselyMigrationFromSqlFile } from './util.ts';
|
||||
|
||||
export default makeKyselyMigrationFromSqlFile('./sql/0014_gray_mongu.sql');
|
||||
@@ -0,0 +1,3 @@
|
||||
import { makeKyselyMigrationFromSqlFile } from './util.ts';
|
||||
|
||||
export default makeKyselyMigrationFromSqlFile('./sql/0015_cuddly_midnight.sql');
|
||||
@@ -0,0 +1,6 @@
|
||||
import { makeKyselyMigrationFromSqlFile } from './util.ts';
|
||||
|
||||
export default makeKyselyMigrationFromSqlFile(
|
||||
'./sql/0016_wealthy_dragon_lord.sql',
|
||||
true,
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { makeKyselyMigrationFromSqlFile } from './util.ts';
|
||||
|
||||
export default makeKyselyMigrationFromSqlFile(
|
||||
'./sql/0017_glossy_lorna_dane.sql',
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { makeKyselyMigrationFromSqlFile } from './util.ts';
|
||||
|
||||
export default makeKyselyMigrationFromSqlFile(
|
||||
'./sql/0018_lumpy_rick_jones.sql',
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { makeKyselyMigrationFromSqlFile } from './util.ts';
|
||||
|
||||
export default makeKyselyMigrationFromSqlFile('./sql/0019_purple_thanos.sql');
|
||||
@@ -0,0 +1,6 @@
|
||||
import { makeKyselyMigrationFromSqlFile } from './util.ts';
|
||||
|
||||
export default makeKyselyMigrationFromSqlFile(
|
||||
'./sql/0020_whole_the_hand.sql',
|
||||
true,
|
||||
);
|
||||
97
server/src/migration/db/sql/0013_silent_the_anarchist.sql
Normal file
97
server/src/migration/db/sql/0013_silent_the_anarchist.sql
Normal 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`);
|
||||
3
server/src/migration/db/sql/0014_gray_mongu.sql
Normal file
3
server/src/migration/db/sql/0014_gray_mongu.sql
Normal 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`;
|
||||
13
server/src/migration/db/sql/0015_cuddly_midnight.sql
Normal file
13
server/src/migration/db/sql/0015_cuddly_midnight.sql
Normal 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;
|
||||
76
server/src/migration/db/sql/0016_wealthy_dragon_lord.sql
Normal file
76
server/src/migration/db/sql/0016_wealthy_dragon_lord.sql
Normal 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`);
|
||||
16
server/src/migration/db/sql/0017_glossy_lorna_dane.sql
Normal file
16
server/src/migration/db/sql/0017_glossy_lorna_dane.sql
Normal 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
|
||||
);
|
||||
3
server/src/migration/db/sql/0018_lumpy_rick_jones.sql
Normal file
3
server/src/migration/db/sql/0018_lumpy_rick_jones.sql
Normal 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`;
|
||||
2
server/src/migration/db/sql/0019_purple_thanos.sql
Normal file
2
server/src/migration/db/sql/0019_purple_thanos.sql
Normal 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);
|
||||
80
server/src/migration/db/sql/0020_whole_the_hand.sql
Normal file
80
server/src/migration/db/sql/0020_whole_the_hand.sql
Normal 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
Reference in New Issue
Block a user