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:
|
server:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@cospired/i18n-iso-languages':
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0
|
||||||
'@dotenvx/dotenvx':
|
'@dotenvx/dotenvx':
|
||||||
specifier: ^1.49.0
|
specifier: ^1.49.0
|
||||||
version: 1.49.0
|
version: 1.49.0
|
||||||
@@ -171,6 +174,9 @@ importers:
|
|||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
specifier: 11.8.1
|
specifier: 11.8.1
|
||||||
version: 11.8.1
|
version: 11.8.1
|
||||||
|
blurhash:
|
||||||
|
specifier: ^2.0.5
|
||||||
|
version: 2.0.5
|
||||||
chalk:
|
chalk:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.6.0
|
version: 5.6.0
|
||||||
@@ -261,6 +267,9 @@ importers:
|
|||||||
retry:
|
retry:
|
||||||
specifier: ^0.13.1
|
specifier: ^0.13.1
|
||||||
version: 0.13.1
|
version: 0.13.1
|
||||||
|
sharp:
|
||||||
|
specifier: ^0.34.4
|
||||||
|
version: 0.34.4
|
||||||
split2:
|
split2:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 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)
|
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':
|
'@hookform/error-message':
|
||||||
specifier: ^2.0.1
|
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':
|
'@mui/icons-material':
|
||||||
specifier: ^7.0.2
|
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)
|
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
|
specifier: ^6.0.0
|
||||||
version: 6.0.0(react@18.2.0)
|
version: 6.0.0(react@18.2.0)
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.48.2
|
specifier: ^7.63.0
|
||||||
version: 7.48.2(react@18.2.0)
|
version: 7.63.0(react@18.2.0)
|
||||||
react-markdown:
|
react-markdown:
|
||||||
specifier: ^9.0.3
|
specifier: ^9.0.3
|
||||||
version: 9.0.3(@types/react@18.2.15)(react@18.2.0)
|
version: 9.0.3(@types/react@18.2.15)(react@18.2.0)
|
||||||
@@ -1106,6 +1115,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@noble/ciphers': ^1.0.0
|
'@noble/ciphers': ^1.0.0
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.5.0':
|
||||||
|
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
|
||||||
|
|
||||||
'@emotion/babel-plugin@11.13.5':
|
'@emotion/babel-plugin@11.13.5':
|
||||||
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
|
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
|
||||||
|
|
||||||
@@ -1879,6 +1891,132 @@ packages:
|
|||||||
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
|
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
|
||||||
engines: {node: '>=18.18'}
|
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':
|
'@inversifyjs/common@1.4.0':
|
||||||
resolution: {integrity: sha512-qfRJ/3iOlCL/VfJq8+4o5X4oA14cZSBbpAmHsYj8EsIit1xDndoOl0xKOyglKtQD4u4gdNVxMHx4RWARk/I4QA==}
|
resolution: {integrity: sha512-qfRJ/3iOlCL/VfJq8+4o5X4oA14cZSBbpAmHsYj8EsIit1xDndoOl0xKOyglKtQD4u4gdNVxMHx4RWARk/I4QA==}
|
||||||
|
|
||||||
@@ -3728,6 +3866,9 @@ packages:
|
|||||||
bluebird@3.7.2:
|
bluebird@3.7.2:
|
||||||
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||||
|
|
||||||
|
blurhash@2.0.5:
|
||||||
|
resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==}
|
||||||
|
|
||||||
bottleneck@2.19.5:
|
bottleneck@2.19.5:
|
||||||
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
|
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
|
||||||
|
|
||||||
@@ -4341,6 +4482,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
detect-libc@2.1.1:
|
||||||
|
resolution: {integrity: sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
devlop@1.1.0:
|
devlop@1.1.0:
|
||||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||||
|
|
||||||
@@ -7145,11 +7290,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.13.1'
|
react: '>=16.13.1'
|
||||||
|
|
||||||
react-hook-form@7.48.2:
|
react-hook-form@7.63.0:
|
||||||
resolution: {integrity: sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==}
|
resolution: {integrity: sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==}
|
||||||
engines: {node: '>=12.22.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
@@ -7478,6 +7623,10 @@ packages:
|
|||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
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:
|
shebang-command@1.2.0:
|
||||||
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
|
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -8934,7 +9083,7 @@ snapshots:
|
|||||||
outdent: 0.5.0
|
outdent: 0.5.0
|
||||||
prettier: 2.8.8
|
prettier: 2.8.8
|
||||||
resolve-from: 5.0.0
|
resolve-from: 5.0.0
|
||||||
semver: 7.5.4
|
semver: 7.7.2
|
||||||
|
|
||||||
'@changesets/assemble-release-plan@6.0.3':
|
'@changesets/assemble-release-plan@6.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8944,7 +9093,7 @@ snapshots:
|
|||||||
'@changesets/should-skip-package': 0.1.0
|
'@changesets/should-skip-package': 0.1.0
|
||||||
'@changesets/types': 6.0.0
|
'@changesets/types': 6.0.0
|
||||||
'@manypkg/get-packages': 1.1.3
|
'@manypkg/get-packages': 1.1.3
|
||||||
semver: 7.5.4
|
semver: 7.7.2
|
||||||
|
|
||||||
'@changesets/changelog-git@0.2.0':
|
'@changesets/changelog-git@0.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9005,7 +9154,7 @@ snapshots:
|
|||||||
'@manypkg/get-packages': 1.1.3
|
'@manypkg/get-packages': 1.1.3
|
||||||
chalk: 2.4.2
|
chalk: 2.4.2
|
||||||
fs-extra: 7.0.1
|
fs-extra: 7.0.1
|
||||||
semver: 7.5.4
|
semver: 7.7.2
|
||||||
|
|
||||||
'@changesets/get-release-plan@4.0.3':
|
'@changesets/get-release-plan@4.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9243,6 +9392,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@noble/ciphers': 1.3.0
|
'@noble/ciphers': 1.3.0
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.5.0':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@emotion/babel-plugin@11.13.5':
|
'@emotion/babel-plugin@11.13.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-module-imports': 7.24.7
|
'@babel/helper-module-imports': 7.24.7
|
||||||
@@ -9669,7 +9823,7 @@ snapshots:
|
|||||||
debug: 4.4.1
|
debug: 4.4.1
|
||||||
espree: 10.3.0
|
espree: 10.3.0
|
||||||
globals: 14.0.0
|
globals: 14.0.0
|
||||||
ignore: 5.3.1
|
ignore: 5.3.2
|
||||||
import-fresh: 3.3.0
|
import-fresh: 3.3.0
|
||||||
js-yaml: 4.1.0
|
js-yaml: 4.1.0
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.2
|
||||||
@@ -9784,11 +9938,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@hey-api/openapi-ts': 0.80.16(magicast@0.3.5)(typescript@5.7.3)
|
'@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:
|
dependencies:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
react-dom: 18.2.0(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': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
@@ -9803,6 +9957,94 @@ snapshots:
|
|||||||
|
|
||||||
'@humanwhocodes/retry@0.4.1': {}
|
'@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/common@1.4.0': {}
|
||||||
|
|
||||||
'@inversifyjs/core@1.3.5(reflect-metadata@0.2.2)':
|
'@inversifyjs/core@1.3.5(reflect-metadata@0.2.2)':
|
||||||
@@ -10492,7 +10734,7 @@ snapshots:
|
|||||||
read-pkg: 9.0.1
|
read-pkg: 9.0.1
|
||||||
registry-auth-token: 5.1.0
|
registry-auth-token: 5.1.0
|
||||||
semantic-release: 24.2.7(typescript@5.7.3)
|
semantic-release: 24.2.7(typescript@5.7.3)
|
||||||
semver: 7.6.3
|
semver: 7.7.2
|
||||||
tempy: 3.1.0
|
tempy: 3.1.0
|
||||||
|
|
||||||
'@semantic-release/release-notes-generator@14.0.3(semantic-release@24.2.7(typescript@5.7.3))':
|
'@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)
|
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.7.3)
|
||||||
eslint: 9.17.0(jiti@2.4.1)
|
eslint: 9.17.0(jiti@2.4.1)
|
||||||
eslint-scope: 5.1.1
|
eslint-scope: 5.1.1
|
||||||
semver: 7.5.4
|
semver: 7.7.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- typescript
|
||||||
@@ -11541,9 +11783,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.11.3
|
acorn: 8.11.3
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.14.1):
|
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.14.1
|
acorn: 8.15.0
|
||||||
|
|
||||||
acorn-walk@8.3.4:
|
acorn-walk@8.3.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -11850,6 +12092,8 @@ snapshots:
|
|||||||
|
|
||||||
bluebird@3.7.2: {}
|
bluebird@3.7.2: {}
|
||||||
|
|
||||||
|
blurhash@2.0.5: {}
|
||||||
|
|
||||||
bottleneck@2.19.5: {}
|
bottleneck@2.19.5: {}
|
||||||
|
|
||||||
bowser@2.11.0: {}
|
bowser@2.11.0: {}
|
||||||
@@ -12222,7 +12466,7 @@ snapshots:
|
|||||||
conventional-commits-filter: 5.0.0
|
conventional-commits-filter: 5.0.0
|
||||||
handlebars: 4.7.8
|
handlebars: 4.7.8
|
||||||
meow: 13.2.0
|
meow: 13.2.0
|
||||||
semver: 7.6.3
|
semver: 7.7.2
|
||||||
|
|
||||||
conventional-commits-filter@5.0.0: {}
|
conventional-commits-filter@5.0.0: {}
|
||||||
|
|
||||||
@@ -12460,6 +12704,8 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.0.4: {}
|
detect-libc@2.0.4: {}
|
||||||
|
|
||||||
|
detect-libc@2.1.1: {}
|
||||||
|
|
||||||
devlop@1.1.0:
|
devlop@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
@@ -13010,7 +13256,7 @@ snapshots:
|
|||||||
|
|
||||||
eslint@9.17.0(jiti@2.4.1):
|
eslint@9.17.0(jiti@2.4.1):
|
||||||
dependencies:
|
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-community/regexpp': 4.12.1
|
||||||
'@eslint/config-array': 0.19.1
|
'@eslint/config-array': 0.19.1
|
||||||
'@eslint/core': 0.9.1
|
'@eslint/core': 0.9.1
|
||||||
@@ -13020,7 +13266,7 @@ snapshots:
|
|||||||
'@humanfs/node': 0.16.6
|
'@humanfs/node': 0.16.6
|
||||||
'@humanwhocodes/module-importer': 1.0.1
|
'@humanwhocodes/module-importer': 1.0.1
|
||||||
'@humanwhocodes/retry': 0.4.1
|
'@humanwhocodes/retry': 0.4.1
|
||||||
'@types/estree': 1.0.6
|
'@types/estree': 1.0.8
|
||||||
'@types/json-schema': 7.0.15
|
'@types/json-schema': 7.0.15
|
||||||
ajv: 6.12.6
|
ajv: 6.12.6
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
@@ -13028,7 +13274,7 @@ snapshots:
|
|||||||
debug: 4.4.1
|
debug: 4.4.1
|
||||||
escape-string-regexp: 4.0.0
|
escape-string-regexp: 4.0.0
|
||||||
eslint-scope: 8.2.0
|
eslint-scope: 8.2.0
|
||||||
eslint-visitor-keys: 4.2.0
|
eslint-visitor-keys: 4.2.1
|
||||||
espree: 10.3.0
|
espree: 10.3.0
|
||||||
esquery: 1.5.0
|
esquery: 1.5.0
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
@@ -13036,7 +13282,7 @@ snapshots:
|
|||||||
file-entry-cache: 8.0.0
|
file-entry-cache: 8.0.0
|
||||||
find-up: 5.0.0
|
find-up: 5.0.0
|
||||||
glob-parent: 6.0.2
|
glob-parent: 6.0.2
|
||||||
ignore: 5.3.1
|
ignore: 5.3.2
|
||||||
imurmurhash: 0.1.4
|
imurmurhash: 0.1.4
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
json-stable-stringify-without-jsonify: 1.0.1
|
json-stable-stringify-without-jsonify: 1.0.1
|
||||||
@@ -13057,9 +13303,9 @@ snapshots:
|
|||||||
|
|
||||||
espree@10.3.0:
|
espree@10.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.14.1
|
acorn: 8.15.0
|
||||||
acorn-jsx: 5.3.2(acorn@8.14.1)
|
acorn-jsx: 5.3.2(acorn@8.15.0)
|
||||||
eslint-visitor-keys: 4.2.0
|
eslint-visitor-keys: 4.2.1
|
||||||
|
|
||||||
esprima@4.0.1: {}
|
esprima@4.0.1: {}
|
||||||
|
|
||||||
@@ -14978,7 +15224,7 @@ snapshots:
|
|||||||
normalize-package-data@6.0.2:
|
normalize-package-data@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
hosted-git-info: 7.0.2
|
hosted-git-info: 7.0.2
|
||||||
semver: 7.6.3
|
semver: 7.7.2
|
||||||
validate-npm-package-license: 3.0.4
|
validate-npm-package-license: 3.0.4
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
@@ -15547,7 +15793,7 @@ snapshots:
|
|||||||
'@babel/runtime': 7.27.1
|
'@babel/runtime': 7.27.1
|
||||||
react: 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):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
|
|
||||||
@@ -15963,7 +16209,7 @@ snapshots:
|
|||||||
|
|
||||||
semver-diff@4.0.0:
|
semver-diff@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.6.3
|
semver: 7.7.2
|
||||||
|
|
||||||
semver-regex@4.0.5: {}
|
semver-regex@4.0.5: {}
|
||||||
|
|
||||||
@@ -16005,6 +16251,35 @@ snapshots:
|
|||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
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:
|
shebang-command@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 1.0.0
|
shebang-regex: 1.0.0
|
||||||
@@ -16076,7 +16351,7 @@ snapshots:
|
|||||||
|
|
||||||
simple-update-notifier@2.0.0:
|
simple-update-notifier@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.5.4
|
semver: 7.7.2
|
||||||
|
|
||||||
skin-tone@2.0.0:
|
skin-tone@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -22,12 +22,13 @@
|
|||||||
"kysely": "dotenv -e .env.development -- kysely",
|
"kysely": "dotenv -e .env.development -- kysely",
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"run-fixer": "dotenv -e .env.development -- tsx src/index.ts fixer",
|
"run-fixer": "dotenv -e .env.development -- tsx src/index.ts fixer",
|
||||||
"test:watch": "vitest --watch",
|
"test:watch": "vitest --typecheck.tsconfig tsconfig.test.json --watch",
|
||||||
"test": "vitest --run",
|
"test": "vitest --typecheck.tsconfig tsconfig.test.json --run",
|
||||||
"tunarr": "dotenv -e .env.development -- tsx src/index.ts",
|
"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"
|
"typecheck": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.build.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cospired/i18n-iso-languages": "^4.2.0",
|
||||||
"@dotenvx/dotenvx": "^1.49.0",
|
"@dotenvx/dotenvx": "^1.49.0",
|
||||||
"@fastify/cors": "^10.1.0",
|
"@fastify/cors": "^10.1.0",
|
||||||
"@fastify/error": "^4.2.0",
|
"@fastify/error": "^4.2.0",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"base32": "^0.0.7",
|
"base32": "^0.0.7",
|
||||||
"better-sqlite3": "11.8.1",
|
"better-sqlite3": "11.8.1",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"chalk": "^5.6.0",
|
"chalk": "^5.6.0",
|
||||||
"cron-parser": "^4.9.0",
|
"cron-parser": "^4.9.0",
|
||||||
"dayjs": "^1.11.14",
|
"dayjs": "^1.11.14",
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
"random-js": "2.1.0",
|
"random-js": "2.1.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"retry": "^0.13.1",
|
"retry": "^0.13.1",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
"split2": "^4.2.0",
|
"split2": "^4.2.0",
|
||||||
"ts-pattern": "^5.8.0",
|
"ts-pattern": "^5.8.0",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import { TranscodeConfigDB } from './db/TranscodeConfigDB.ts';
|
|||||||
import { ProgramConverter } from './db/converters/ProgramConverter.ts';
|
import { ProgramConverter } from './db/converters/ProgramConverter.ts';
|
||||||
import { MediaSourceDB } from './db/mediaSourceDB.ts';
|
import { MediaSourceDB } from './db/mediaSourceDB.ts';
|
||||||
import { DB } from './db/schema/db.ts';
|
import { DB } from './db/schema/db.ts';
|
||||||
|
import { DrizzleDBAccess } from './db/schema/index.ts';
|
||||||
import { MediaSourceApiFactory } from './external/MediaSourceApiFactory.ts';
|
import { MediaSourceApiFactory } from './external/MediaSourceApiFactory.ts';
|
||||||
import { IWorkerPool } from './interfaces/IWorkerPool.ts';
|
import { IWorkerPool } from './interfaces/IWorkerPool.ts';
|
||||||
import { EventService } from './services/EventService.ts';
|
import { EventService } from './services/EventService.ts';
|
||||||
import { FileCacheService } from './services/FileCacheService.ts';
|
import { FileCacheService } from './services/FileCacheService.ts';
|
||||||
import { HdhrService } from './services/HDHRService.ts';
|
import { HdhrService } from './services/HDHRService.ts';
|
||||||
import { HealthCheckService } from './services/HealthCheckService.js';
|
import { HealthCheckService } from './services/HealthCheckService.js';
|
||||||
|
import { ImageCache } from './services/ImageCache.ts';
|
||||||
import { M3uService } from './services/M3UService.ts';
|
import { M3uService } from './services/M3UService.ts';
|
||||||
import { MediaSourceLibraryRefresher } from './services/MediaSourceLibraryRefresher.ts';
|
import { MediaSourceLibraryRefresher } from './services/MediaSourceLibraryRefresher.ts';
|
||||||
import { MeilisearchService } from './services/MeilisearchService.ts';
|
import { MeilisearchService } from './services/MeilisearchService.ts';
|
||||||
@@ -70,6 +72,9 @@ export class ServerContext {
|
|||||||
@inject(KEYS.DatabaseFactory)
|
@inject(KEYS.DatabaseFactory)
|
||||||
public readonly databaseFactory!: interfaces.AutoFactory<Kysely<DB>>;
|
public readonly databaseFactory!: interfaces.AutoFactory<Kysely<DB>>;
|
||||||
|
|
||||||
|
@inject(KEYS.DrizzleDatabaseFactory)
|
||||||
|
public readonly drizzleFactory!: interfaces.AutoFactory<DrizzleDBAccess>;
|
||||||
|
|
||||||
@inject(KEYS.WorkerPool)
|
@inject(KEYS.WorkerPool)
|
||||||
public readonly workerPool: IWorkerPool;
|
public readonly workerPool: IWorkerPool;
|
||||||
|
|
||||||
@@ -77,10 +82,13 @@ export class ServerContext {
|
|||||||
public readonly searchService!: MeilisearchService;
|
public readonly searchService!: MeilisearchService;
|
||||||
|
|
||||||
@inject(MediaSourceScanCoordinator)
|
@inject(MediaSourceScanCoordinator)
|
||||||
public readonly mediaSourceScanCoordinator: MediaSourceScanCoordinator;
|
public readonly mediaSourceScanCoordinator!: MediaSourceScanCoordinator;
|
||||||
|
|
||||||
@inject(MediaSourceLibraryRefresher)
|
@inject(MediaSourceLibraryRefresher)
|
||||||
public readonly mediaSourceLibraryRefresher: MediaSourceLibraryRefresher;
|
public readonly mediaSourceLibraryRefresher!: MediaSourceLibraryRefresher;
|
||||||
|
|
||||||
|
@inject(ImageCache)
|
||||||
|
public readonly imageCache!: ImageCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ServerRequestContext {
|
export class ServerRequestContext {
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
id: customShow.uuid,
|
id: customShow.uuid,
|
||||||
name: customShow.name,
|
name: customShow.name,
|
||||||
contentCount: customShow.customShowContent.length,
|
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,
|
id: customShow.uuid,
|
||||||
name: customShow.name,
|
name: customShow.name,
|
||||||
contentCount: customShow.customShowContent.length,
|
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 { FfprobeStreamDetails } from '@/stream/FfprobeStreamDetails.js';
|
||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||||
import { tag } from '@tunarr/types';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import { container } from '../../container.ts';
|
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 type { FfmpegEncoder } from '../../ffmpeg/ffmpegInfo.ts';
|
||||||
import { FfmpegInfo } 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 (
|
export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
|
||||||
fastify,
|
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 { container } from '@/container.js';
|
||||||
|
import { MediaSourceType } from '@/db/schema/base.js';
|
||||||
import type { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
import type { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
||||||
import { JellyfinItemFinder } from '@/external/jellyfin/JellyfinItemFinder.js';
|
import { JellyfinItemFinder } from '@/external/jellyfin/JellyfinItemFinder.js';
|
||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||||
@@ -7,7 +8,6 @@ import { tag } from '@tunarr/types';
|
|||||||
import { isNil } from 'lodash-es';
|
import { isNil } from 'lodash-es';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
|
|
||||||
import type { MediaSourceApiClientFactory } from '../../external/MediaSourceApiClient.ts';
|
import type { MediaSourceApiClientFactory } from '../../external/MediaSourceApiClient.ts';
|
||||||
import { KEYS } from '../../types/inject.ts';
|
import { KEYS } from '../../types/inject.ts';
|
||||||
|
|
||||||
@@ -40,6 +40,8 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
username: null,
|
username: null,
|
||||||
libraries: [],
|
libraries: [],
|
||||||
type: 'jellyfin',
|
type: 'jellyfin',
|
||||||
|
mediaType: null,
|
||||||
|
paths: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,6 +80,8 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
username: null,
|
username: null,
|
||||||
libraries: [],
|
libraries: [],
|
||||||
type: 'jellyfin',
|
type: 'jellyfin',
|
||||||
|
mediaType: null,
|
||||||
|
paths: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,25 +37,20 @@ export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
|
|
||||||
if (!program) {
|
if (!program) {
|
||||||
return res.status(400).send('No 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 =
|
const mediaSourceId = program.mediaSourceId;
|
||||||
req.serverCtx.programConverter.programDaoToContentProgram(program);
|
|
||||||
|
|
||||||
if (!contentProgram) {
|
|
||||||
return res.status(500).send();
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamDetails = await container.get(PlexStreamDetails).getStream({
|
const streamDetails = await container.get(PlexStreamDetails).getStream({
|
||||||
server: mediaSource,
|
server: mediaSource,
|
||||||
lineupItem: {
|
lineupItem: {
|
||||||
...contentProgram,
|
...program,
|
||||||
programId: contentProgram.id,
|
sourceType: 'plex',
|
||||||
externalKey: req.query.key,
|
mediaSourceId,
|
||||||
programType: contentProgram.subtype,
|
|
||||||
externalSource: 'plex',
|
|
||||||
duration: contentProgram.duration,
|
|
||||||
externalFilePath: contentProgram.serverFilePath,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { container } from '@/container.js';
|
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 { createOfflineStreamLineupItem } from '@/db/derived_types/StreamLineup.js';
|
||||||
import type { Channel } from '@/db/schema/Channel.js';
|
import type { Channel } from '@/db/schema/Channel.js';
|
||||||
import { AllChannelTableKeys } 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 { ProgramType } from '@/db/schema/Program.js';
|
||||||
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
|
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
|
||||||
import { AllTranscodeConfigColumns } 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 { jsonObjectFrom } from 'kysely/helpers/sqlite';
|
||||||
import { isNumber, isUndefined, nth, random } from 'lodash-es';
|
import { isNumber, isUndefined, nth, random } from 'lodash-es';
|
||||||
import { PassThrough } from 'node:stream';
|
import { PassThrough } from 'node:stream';
|
||||||
|
import type { MarkRequired } from 'ts-essentials';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
import type { ProgramWithRelationsOrm } from '../../db/schema/derivedTypes.ts';
|
||||||
import type { ProgramStreamFactory } from '../../stream/ProgramStreamFactory.ts';
|
import type { ProgramStreamFactory } from '../../stream/ProgramStreamFactory.ts';
|
||||||
import { isNonEmptyString } from '../../util/index.ts';
|
import { isNonEmptyString } from '../../util/index.ts';
|
||||||
|
|
||||||
@@ -142,13 +142,17 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
|
|
||||||
fastify.get('/streams/random', async (req, res) => {
|
fastify.get('/streams/random', async (req, res) => {
|
||||||
const program = await req.serverCtx
|
const program = await req.serverCtx
|
||||||
.databaseFactory()
|
.drizzleFactory()
|
||||||
.selectFrom('program')
|
.query.program.findFirst({
|
||||||
.orderBy((ob) => ob.fn('random'))
|
where: (fields, ops) => ops.eq(fields.type, ProgramType.Episode),
|
||||||
.where('type', '=', ProgramType.Episode)
|
orderBy: (_, { sql }) => sql`random()`,
|
||||||
.limit(1)
|
with: {
|
||||||
.selectAll()
|
externalIds: true,
|
||||||
.executeTakeFirstOrThrow();
|
},
|
||||||
|
});
|
||||||
|
if (!program) {
|
||||||
|
return res.status(404).send();
|
||||||
|
}
|
||||||
|
|
||||||
const channels = await req.serverCtx
|
const channels = await req.serverCtx
|
||||||
.databaseFactory()
|
.databaseFactory()
|
||||||
@@ -279,12 +283,23 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function initStream(
|
async function initStream(
|
||||||
program: ProgramDao,
|
program: MarkRequired<ProgramWithRelationsOrm, 'externalIds'>,
|
||||||
channel: Channel,
|
channel: Channel,
|
||||||
transcodeConfig: TranscodeConfig,
|
transcodeConfig: TranscodeConfig,
|
||||||
startTime: number = 0,
|
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;
|
lineupItem.startOffset = startTime;
|
||||||
const ctx = new PlayerContext(
|
const ctx = new PlayerContext(
|
||||||
lineupItem,
|
lineupItem,
|
||||||
@@ -308,23 +323,3 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
return out;
|
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 { DebugPlexApiRouter } from '@/api/debug/debugPlexApi.js';
|
||||||
import type { ArchiveDatabaseBackupFactory } from '@/db/backup/ArchiveDatabaseBackup.js';
|
import type { ArchiveDatabaseBackupFactory } from '@/db/backup/ArchiveDatabaseBackup.js';
|
||||||
import { ArchiveDatabaseBackupKey } 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 { LineupCreator } from '@/services/dynamic_channels/LineupCreator.js';
|
||||||
import { PlexTaskQueue } from '@/tasks/TaskQueue.js';
|
import { PlexTaskQueue } from '@/tasks/TaskQueue.js';
|
||||||
import { SavePlexProgramExternalIdsTask } from '@/tasks/plex/SavePlexProgramExternalIdsTask.js';
|
import { SavePlexProgramExternalIdsTask } from '@/tasks/plex/SavePlexProgramExternalIdsTask.js';
|
||||||
import { DateTimeRange } from '@/types/DateTimeRange.js';
|
import { DateTimeRange } from '@/types/DateTimeRange.js';
|
||||||
import { OpenDateTimeRange } from '@/types/OpenDateTimeRange.js';
|
import { OpenDateTimeRange } from '@/types/OpenDateTimeRange.js';
|
||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||||
import { enumValues } from '@/util/enumUtil.js';
|
|
||||||
import { ifDefined } from '@/util/index.js';
|
import { ifDefined } from '@/util/index.js';
|
||||||
import { tag } from '@tunarr/types';
|
import { tag } from '@tunarr/types';
|
||||||
import { ChannelLineupQuery } from '@tunarr/types/api';
|
import { ChannelLineupQuery } from '@tunarr/types/api';
|
||||||
import { ChannelLineupSchema } from '@tunarr/types/schemas';
|
import { ChannelLineupSchema } from '@tunarr/types/schemas';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
|
import { isUndefined } from 'lodash-es';
|
||||||
import { isUndefined, map, reject, some } from 'lodash-es';
|
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import z from 'zod/v4';
|
import z from 'zod/v4';
|
||||||
import { container } from '../container.ts';
|
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(
|
fastify.get(
|
||||||
'/debug/subprocess/status',
|
'/debug/subprocess/status',
|
||||||
{
|
{
|
||||||
@@ -416,4 +360,33 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
return res.send(response);
|
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 { EmbyApiClient } from '@/external/emby/EmbyApiClient.js';
|
||||||
import { TruthyQueryParam } from '@/types/schemas.js';
|
import { TruthyQueryParam } from '@/types/schemas.js';
|
||||||
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
|
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { FfmpegEncoder } from '@/ffmpeg/ffmpegInfo.js';
|
import type { FfmpegEncoder } from '@/ffmpeg/ffmpegInfo.js';
|
||||||
import { FfmpegInfo } 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 { GlobalScheduler } from '@/services/Scheduler.js';
|
||||||
import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js';
|
import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js';
|
||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||||
import { fileExists } from '@/util/fsUtil.js';
|
import { fileExists } from '@/util/fsUtil.js';
|
||||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||||
import { getTunarrVersion } from '@/util/version.js';
|
import { getTunarrVersion } from '@/util/version.js';
|
||||||
|
import fpStatic from '@fastify/static';
|
||||||
import { VersionApiResponseSchema } from '@tunarr/types/api';
|
import { VersionApiResponseSchema } from '@tunarr/types/api';
|
||||||
import { fileTypeFromStream } from 'file-type';
|
import { fileTypeFromStream } from 'file-type';
|
||||||
import { isEmpty } from 'lodash-es';
|
import { isEmpty } from 'lodash-es';
|
||||||
@@ -48,6 +49,11 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await fastify
|
await fastify
|
||||||
|
.register(fpStatic, {
|
||||||
|
root: globalOptions().databaseDirectory,
|
||||||
|
serve: false,
|
||||||
|
decorateReply: true,
|
||||||
|
})
|
||||||
.register(tasksApiRouter)
|
.register(tasksApiRouter)
|
||||||
.register(channelsApi)
|
.register(channelsApi)
|
||||||
.register(customShowsApiV2)
|
.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 { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
||||||
import { mediaSourceParamsSchema, TruthyQueryParam } from '@/types/schemas.js';
|
import { mediaSourceParamsSchema, TruthyQueryParam } from '@/types/schemas.js';
|
||||||
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
|
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { GlobalScheduler } from '@/services/Scheduler.js';
|
import { GlobalScheduler } from '@/services/Scheduler.js';
|
||||||
import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js';
|
import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js';
|
||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.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 { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||||
import { numberToBoolean } from '@/util/sqliteUtil.js';
|
import { numberToBoolean } from '@/util/sqliteUtil.js';
|
||||||
import { seq } from '@tunarr/shared/util';
|
import { seq } from '@tunarr/shared/util';
|
||||||
|
import type { LocalMediaSource } from '@tunarr/types';
|
||||||
import {
|
import {
|
||||||
tag,
|
tag,
|
||||||
type MediaSourceLibrary,
|
type MediaSourceLibrary,
|
||||||
@@ -25,12 +26,12 @@ import {
|
|||||||
} from '@tunarr/types/api';
|
} from '@tunarr/types/api';
|
||||||
import {
|
import {
|
||||||
ContentProgramSchema,
|
ContentProgramSchema,
|
||||||
ExternalSourceTypeSchema,
|
|
||||||
MediaSourceLibrarySchema,
|
MediaSourceLibrarySchema,
|
||||||
MediaSourceSettingsSchema,
|
MediaSourceSettingsSchema,
|
||||||
} from '@tunarr/types/schemas';
|
} from '@tunarr/types/schemas';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { isEmpty, isError, isNil, isNull } from 'lodash-es';
|
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 { match, P } from 'ts-pattern';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import z from 'zod/v4';
|
import z from 'zod/v4';
|
||||||
@@ -40,6 +41,7 @@ import { EntityMutex } from '../services/EntityMutex.ts';
|
|||||||
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
|
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
|
||||||
import { MediaSourceProgressService } from '../services/scanner/MediaSourceProgressService.ts';
|
import { MediaSourceProgressService } from '../services/scanner/MediaSourceProgressService.ts';
|
||||||
import { TruthyQueryParam } from '../types/schemas.ts';
|
import { TruthyQueryParam } from '../types/schemas.ts';
|
||||||
|
import { fileExists } from '../util/fsUtil.ts';
|
||||||
|
|
||||||
export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||||
fastify,
|
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(
|
fastify.get(
|
||||||
'/media-sources/:id/libraries',
|
'/media-sources/:id/libraries',
|
||||||
{
|
{
|
||||||
@@ -86,6 +123,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
params: BasicIdParamSchema,
|
params: BasicIdParamSchema,
|
||||||
response: {
|
response: {
|
||||||
200: z.array(MediaSourceLibrarySchema),
|
200: z.array(MediaSourceLibrarySchema),
|
||||||
|
400: z.void(),
|
||||||
404: z.void(),
|
404: z.void(),
|
||||||
500: z.string(),
|
500: z.string(),
|
||||||
},
|
},
|
||||||
@@ -100,6 +138,10 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
return res.status(404).send();
|
return res.status(404).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mediaSource.type === 'local') {
|
||||||
|
return res.status(400).send();
|
||||||
|
}
|
||||||
|
|
||||||
const entityLocker = container.get<EntityMutex>(EntityMutex);
|
const entityLocker = container.get<EntityMutex>(EntityMutex);
|
||||||
const apiMediaSource = convertToApiMediaSource(entityLocker, mediaSource);
|
const apiMediaSource = convertToApiMediaSource(entityLocker, mediaSource);
|
||||||
if (isNull(apiMediaSource)) {
|
if (isNull(apiMediaSource)) {
|
||||||
@@ -108,20 +150,28 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
.send('Invalid media source type: ' + mediaSource.type);
|
.send('Invalid media source type: ' + mediaSource.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.send(
|
const libraries = mediaSource.libraries.map(
|
||||||
mediaSource.libraries.map(
|
(library) =>
|
||||||
(library) =>
|
({
|
||||||
({
|
...library,
|
||||||
...library,
|
id: library.uuid,
|
||||||
id: library.uuid,
|
type: mediaSource.type,
|
||||||
type: mediaSource.type,
|
enabled: library.enabled,
|
||||||
enabled: numberToBoolean(library.enabled),
|
lastScannedAt: library.lastScannedAt
|
||||||
lastScannedAt: nullToUndefined(library.lastScannedAt),
|
? +dayjs(library.lastScannedAt)
|
||||||
isLocked: entityLocker.isLibraryLocked(library),
|
: undefined,
|
||||||
mediaSource: apiMediaSource,
|
isLocked:
|
||||||
}) satisfies MediaSourceLibrary,
|
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,
|
body: UpdateMediaSourceLibraryRequest,
|
||||||
response: {
|
response: {
|
||||||
200: MediaSourceLibrarySchema,
|
200: MediaSourceLibrarySchema,
|
||||||
404: z.void(),
|
400: z.string(),
|
||||||
|
404: z.string(),
|
||||||
500: z.string(),
|
500: z.string(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -147,7 +198,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!mediaSource) {
|
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);
|
const entityLocker = container.get(EntityMutex);
|
||||||
@@ -184,7 +243,9 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
type: mediaSource.type,
|
type: mediaSource.type,
|
||||||
enabled: numberToBoolean(updatedLibrary.enabled),
|
enabled: numberToBoolean(updatedLibrary.enabled),
|
||||||
lastScannedAt: nullToUndefined(updatedLibrary.lastScannedAt),
|
lastScannedAt: nullToUndefined(updatedLibrary.lastScannedAt),
|
||||||
isLocked: entityLocker.isLibraryLocked(updatedLibrary),
|
isLocked:
|
||||||
|
entityLocker.isLibraryLocked(updatedLibrary) ||
|
||||||
|
entityLocker.isMediaSourceLocked(mediaSource),
|
||||||
mediaSource: apiMediaSource,
|
mediaSource: apiMediaSource,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -218,19 +279,18 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
const entityLocker = container.get<EntityMutex>(EntityMutex);
|
const entityLocker = container.get<EntityMutex>(EntityMutex);
|
||||||
|
|
||||||
return res.send({
|
return res.send({
|
||||||
...library,
|
// ...library,
|
||||||
id: library.uuid,
|
id: library.uuid,
|
||||||
type: library.mediaSource.type,
|
type: library.mediaSource.type,
|
||||||
enabled: numberToBoolean(library.enabled),
|
enabled: library.enabled,
|
||||||
lastScannedAt: nullToUndefined(library.lastScannedAt),
|
lastScannedAt: library.lastScannedAt?.valueOf(),
|
||||||
isLocked: entityLocker.isLibraryLocked(library),
|
isLocked:
|
||||||
mediaSource: convertToApiMediaSource(
|
entityLocker.isLibraryLocked(library) ||
|
||||||
entityLocker,
|
entityLocker.isMediaSourceLocked(library.mediaSource),
|
||||||
library.mediaSource,
|
name: library.name,
|
||||||
)!,
|
mediaType: library.mediaType,
|
||||||
// TODO this is dumb
|
externalKey: library.externalKey,
|
||||||
} satisfies MediaSourceLibrary & {
|
mediaSource: convertToApiMediaSource(entityLocker, library.mediaSource),
|
||||||
mediaSource: MediaSourceSettings;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -275,14 +335,16 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/media-libraries/:libraryId/status',
|
'/media-sources/:mediaSourceId/:libraryId/status',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
|
mediaSourceId: z.string(),
|
||||||
libraryId: z.string(),
|
libraryId: z.string(),
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: ScanProgressSchema,
|
200: ScanProgressSchema,
|
||||||
|
404: z.void(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -290,8 +352,27 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
const progressService = container.get<MediaSourceProgressService>(
|
const progressService = container.get<MediaSourceProgressService>(
|
||||||
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)
|
const response = match(progress)
|
||||||
.returnType<ScanProgress>()
|
.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(
|
fastify.post(
|
||||||
'/media-sources/:id/libraries/:libraryId/scan',
|
'/media-sources/:id/libraries/:libraryId/scan',
|
||||||
{
|
{
|
||||||
@@ -377,17 +487,30 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
return res.status(501);
|
return res.status(501);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const library of libraries) {
|
if (mediaSource.type === 'local') {
|
||||||
const result = await req.serverCtx.mediaSourceScanCoordinator.add({
|
const result = await req.serverCtx.mediaSourceScanCoordinator.addLocal({
|
||||||
libraryId: library.uuid,
|
|
||||||
forceScan: !!req.query.forceScan,
|
forceScan: !!req.query.forceScan,
|
||||||
|
mediaSourceId: mediaSource.uuid,
|
||||||
});
|
});
|
||||||
if (!result) {
|
if (!result) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Unable to schedule library ID %s for scanning',
|
'Unable to schedule local media source ID %s for scanning',
|
||||||
library.uuid,
|
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();
|
return res.status(202).send();
|
||||||
@@ -420,6 +543,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const healthyPromise = match(server)
|
const healthyPromise = match(server)
|
||||||
|
.returnType<Promise<MediaSourceStatus>>()
|
||||||
.with({ type: 'plex' }, async (server) => {
|
.with({ type: 'plex' }, async (server) => {
|
||||||
return (
|
return (
|
||||||
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
|
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
|
||||||
@@ -441,6 +565,21 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
)
|
)
|
||||||
).ping();
|
).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();
|
.exhaustive();
|
||||||
|
|
||||||
const status = await Promise.race([
|
const status = await Promise.race([
|
||||||
@@ -465,13 +604,20 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['Media Source'],
|
tags: ['Media Source'],
|
||||||
body: z.object({
|
body: z
|
||||||
name: z.string().optional(),
|
.object({
|
||||||
accessToken: z.string(),
|
name: z.string().optional(),
|
||||||
uri: z.string(),
|
accessToken: z.string(),
|
||||||
type: ExternalSourceTypeSchema,
|
uri: z.string(),
|
||||||
username: z.string().optional(),
|
type: z.enum(['plex', 'jellyfin', 'emby']),
|
||||||
}),
|
username: z.string().optional(),
|
||||||
|
})
|
||||||
|
.or(
|
||||||
|
z.object({
|
||||||
|
type: z.literal('local'),
|
||||||
|
paths: z.string().array().nonempty(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
response: {
|
response: {
|
||||||
200: MediaSourceStatusSchema,
|
200: MediaSourceStatusSchema,
|
||||||
404: z.void(),
|
404: z.void(),
|
||||||
@@ -493,6 +639,8 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
name: tag(req.body.name ?? 'unknown'),
|
name: tag(req.body.name ?? 'unknown'),
|
||||||
uuid: tag(v4()),
|
uuid: tag(v4()),
|
||||||
libraries: [],
|
libraries: [],
|
||||||
|
paths: [],
|
||||||
|
mediaType: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -510,6 +658,8 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
name: tag(req.body.name ?? 'unknown'),
|
name: tag(req.body.name ?? 'unknown'),
|
||||||
uuid: tag(v4()),
|
uuid: tag(v4()),
|
||||||
libraries: [],
|
libraries: [],
|
||||||
|
paths: [],
|
||||||
|
mediaType: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -527,12 +677,32 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
name: tag(req.body.name ?? 'unknown'),
|
name: tag(req.body.name ?? 'unknown'),
|
||||||
uuid: tag(v4()),
|
uuid: tag(v4()),
|
||||||
libraries: [],
|
libraries: [],
|
||||||
|
paths: [],
|
||||||
|
mediaType: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
healthyPromise = emby.ping();
|
healthyPromise = emby.ping();
|
||||||
break;
|
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([
|
const status = await Promise.race([
|
||||||
@@ -717,10 +887,10 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
// TODO put this in its own class.
|
// TODO put this in its own class.
|
||||||
function convertToApiMediaSource(
|
function convertToApiMediaSource(
|
||||||
entityLocker: EntityMutex,
|
entityLocker: EntityMutex,
|
||||||
source: MarkOptional<MediaSourceWithLibraries, 'libraries'>,
|
source: MarkOptional<MediaSourceWithLibraries, 'libraries' | 'paths'>,
|
||||||
): MediaSourceSettings | null {
|
): MediaSourceSettings {
|
||||||
return match(source)
|
return match(source)
|
||||||
.returnType<MediaSourceSettings | null>()
|
.returnType<MediaSourceSettings>()
|
||||||
.with(
|
.with(
|
||||||
{ type: P.union('plex', 'jellyfin', 'emby') },
|
{ type: P.union('plex', 'jellyfin', 'emby') },
|
||||||
(source) =>
|
(source) =>
|
||||||
@@ -732,20 +902,53 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
name: source.name,
|
name: source.name,
|
||||||
accessToken: source.accessToken,
|
accessToken: source.accessToken,
|
||||||
clientIdentifier: nullToUndefined(source.clientIdentifier),
|
clientIdentifier: nullToUndefined(source.clientIdentifier),
|
||||||
sendChannelUpdates: numberToBoolean(source.sendChannelUpdates),
|
sendChannelUpdates: source.sendChannelUpdates ?? false,
|
||||||
sendGuideUpdates: numberToBoolean(source.sendGuideUpdates),
|
sendGuideUpdates: source.sendGuideUpdates ?? false,
|
||||||
libraries: (source.libraries ?? []).map((library) => ({
|
libraries: (source.libraries ?? []).map((library) => ({
|
||||||
...library,
|
|
||||||
id: library.uuid,
|
id: library.uuid,
|
||||||
type: source.type,
|
type: source.type,
|
||||||
enabled: numberToBoolean(library.enabled),
|
enabled: library.enabled,
|
||||||
lastScannedAt: nullToUndefined(library.lastScannedAt),
|
lastScannedAt: nullToUndefined(library.lastScannedAt)?.valueOf(),
|
||||||
isLocked: entityLocker.isLibraryLocked(library),
|
isLocked:
|
||||||
|
entityLocker.isLibraryLocked(library) ||
|
||||||
|
entityLocker.isMediaSourceLocked(source),
|
||||||
|
name: library.name,
|
||||||
|
externalKey: library.externalKey,
|
||||||
|
mediaType: library.mediaType,
|
||||||
})),
|
})),
|
||||||
userId: source.userId,
|
userId: source.userId,
|
||||||
username: source.username,
|
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,
|
ProgramSourceType,
|
||||||
programSourceTypeFromString,
|
programSourceTypeFromString,
|
||||||
} from '../db/custom_types/ProgramSourceType.ts';
|
} 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';
|
import { getServerContext } from '../ServerContext.ts';
|
||||||
|
|
||||||
const externalIdSchema = z
|
const externalIdSchema = z
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MediaSourceType } from '@/db/schema/base.js';
|
||||||
import { tag, type Library } from '@tunarr/types';
|
import { tag, type Library } from '@tunarr/types';
|
||||||
import { PagedResult } from '@tunarr/types/api';
|
import { PagedResult } from '@tunarr/types/api';
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +17,6 @@ import { isNil } from 'lodash-es';
|
|||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import type { PageParams } from '../db/interfaces/IChannelDB.ts';
|
import type { PageParams } from '../db/interfaces/IChannelDB.ts';
|
||||||
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||||
import { MediaSourceType } from '../db/schema/MediaSource.ts';
|
|
||||||
import { ServerRequestContext } from '../ServerContext.ts';
|
import { ServerRequestContext } from '../ServerContext.ts';
|
||||||
import { mediaSourceParamsSchema } from '../types/schemas.ts';
|
import { mediaSourceParamsSchema } from '../types/schemas.ts';
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||||
import type {
|
import type {
|
||||||
MediaSource,
|
MediaSourceLibraryOrm,
|
||||||
MediaSourceLibrary,
|
MediaSourceOrm,
|
||||||
} from '@/db/schema/MediaSource.js';
|
} from '@/db/schema/MediaSource.js';
|
||||||
import { ProgramType } from '@/db/schema/Program.js';
|
import { ProgramType } from '@/db/schema/Program.js';
|
||||||
import type { ProgramGrouping as ProgramGroupingDao } from '@/db/schema/ProgramGrouping.js';
|
import type { ProgramGrouping as ProgramGroupingDao } from '@/db/schema/ProgramGrouping.js';
|
||||||
import {
|
import { ProgramGroupingType } from '@/db/schema/ProgramGrouping.js';
|
||||||
AllProgramGroupingFields,
|
|
||||||
ProgramGroupingType,
|
|
||||||
} from '@/db/schema/ProgramGrouping.js';
|
|
||||||
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
||||||
import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
|
import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
|
||||||
import { PagingParams, TruthyQueryParam } from '@/types/schemas.js';
|
import { PagingParams, TruthyQueryParam } from '@/types/schemas.js';
|
||||||
@@ -42,6 +39,7 @@ import {
|
|||||||
} from '@tunarr/types/api';
|
} from '@tunarr/types/api';
|
||||||
import {
|
import {
|
||||||
ContentProgramSchema,
|
ContentProgramSchema,
|
||||||
|
ProgramGroupingSchema,
|
||||||
TerminalProgramSchema,
|
TerminalProgramSchema,
|
||||||
} from '@tunarr/types/schemas';
|
} from '@tunarr/types/schemas';
|
||||||
import axios, { AxiosHeaders, isAxiosError } from 'axios';
|
import axios, { AxiosHeaders, isAxiosError } from 'axios';
|
||||||
@@ -58,6 +56,7 @@ import {
|
|||||||
isUndefined,
|
isUndefined,
|
||||||
map,
|
map,
|
||||||
omitBy,
|
omitBy,
|
||||||
|
trimStart,
|
||||||
values,
|
values,
|
||||||
} from 'lodash-es';
|
} from 'lodash-es';
|
||||||
import type stream from 'node:stream';
|
import type stream from 'node:stream';
|
||||||
@@ -69,13 +68,19 @@ import {
|
|||||||
programSourceTypeFromString,
|
programSourceTypeFromString,
|
||||||
} from '../db/custom_types/ProgramSourceType.ts';
|
} from '../db/custom_types/ProgramSourceType.ts';
|
||||||
import type { ProgramGroupingChildCounts } from '../db/interfaces/IProgramDB.ts';
|
import type { ProgramGroupingChildCounts } from '../db/interfaces/IProgramDB.ts';
|
||||||
import { AllProgramFields } from '../db/programQueryHelpers.ts';
|
import {
|
||||||
import type { MediaSourceId } from '../db/schema/base.ts';
|
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 {
|
import type {
|
||||||
MediaSourceWithLibraries,
|
MediaSourceWithLibraries,
|
||||||
ProgramWithRelations,
|
ProgramWithRelationsOrm,
|
||||||
} from '../db/schema/derivedTypes.js';
|
} from '../db/schema/derivedTypes.js';
|
||||||
import type { DrizzleDBAccess } from '../db/schema/index.ts';
|
import type { DrizzleDBAccess } from '../db/schema/index.ts';
|
||||||
|
import { globalOptions } from '../globals.ts';
|
||||||
import type {
|
import type {
|
||||||
ProgramGroupingSearchDocument,
|
ProgramGroupingSearchDocument,
|
||||||
ProgramSearchDocument,
|
ProgramSearchDocument,
|
||||||
@@ -127,19 +132,19 @@ function isProgramGroupingDocument(
|
|||||||
|
|
||||||
function convertProgramSearchResult(
|
function convertProgramSearchResult(
|
||||||
doc: TerminalProgramSearchDocument,
|
doc: TerminalProgramSearchDocument,
|
||||||
program: ProgramWithRelations,
|
program: ProgramWithRelationsOrm,
|
||||||
mediaSource: MediaSourceWithLibraries,
|
mediaSource: MediaSourceWithLibraries,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
): TerminalProgram {
|
): TerminalProgram {
|
||||||
if (!program.canonicalId) {
|
if (!program.canonicalId) {
|
||||||
throw new Error('');
|
throw new Error('Program did not have a canonical ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
const externalId = doc.externalIds.find(
|
const externalId = doc.externalIds.find(
|
||||||
(eid) => eid.source === mediaSource.type,
|
(eid) => eid.source === mediaSource.type,
|
||||||
)?.id;
|
)?.id;
|
||||||
if (!externalId) {
|
if (!externalId && program.sourceType !== 'local') {
|
||||||
throw new Error('');
|
throw new Error('No external Id found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
@@ -150,8 +155,9 @@ function convertProgramSearchResult(
|
|||||||
releaseDateString: doc.originalReleaseDate
|
releaseDateString: doc.originalReleaseDate
|
||||||
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
|
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
|
||||||
: null,
|
: null,
|
||||||
externalId,
|
externalId: externalId ?? program.externalKey,
|
||||||
sourceType: mediaSource.type,
|
sourceType: mediaSource.type,
|
||||||
|
sortTitle: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const identifiers = doc.externalIds.map((eid) => ({
|
const identifiers = doc.externalIds.map((eid) => ({
|
||||||
@@ -188,6 +194,7 @@ function convertProgramSearchResult(
|
|||||||
identifiers,
|
identifiers,
|
||||||
episodeNumber: ep.index ?? 0,
|
episodeNumber: ep.index ?? 0,
|
||||||
canonicalId: program.canonicalId!,
|
canonicalId: program.canonicalId!,
|
||||||
|
sortTitle: '',
|
||||||
// mediaItem: {
|
// mediaItem: {
|
||||||
// displayAspectRatio: '',
|
// displayAspectRatio: '',
|
||||||
// duration: doc.duration,
|
// duration: doc.duration,
|
||||||
@@ -248,7 +255,10 @@ function convertProgramSearchResult(
|
|||||||
.otherwise(() => null);
|
.otherwise(() => null);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error('');
|
throw new Error(
|
||||||
|
'Could not convert program result for incoming document: ' +
|
||||||
|
JSON.stringify(doc),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -259,10 +269,10 @@ function convertProgramGroupingSearchResult(
|
|||||||
grouping: ProgramGroupingDao,
|
grouping: ProgramGroupingDao,
|
||||||
childCounts: Maybe<ProgramGroupingChildCounts>,
|
childCounts: Maybe<ProgramGroupingChildCounts>,
|
||||||
mediaSource: MediaSourceWithLibraries,
|
mediaSource: MediaSourceWithLibraries,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
) {
|
) {
|
||||||
if (!grouping.canonicalId) {
|
if (!grouping.canonicalId) {
|
||||||
throw new Error('');
|
throw new Error(`No canonical id for grouping ${grouping.uuid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const childCount = childCounts?.childCount;
|
const childCount = childCounts?.childCount;
|
||||||
@@ -282,11 +292,13 @@ function convertProgramGroupingSearchResult(
|
|||||||
const externalId = doc.externalIds.find(
|
const externalId = doc.externalIds.find(
|
||||||
(eid) => eid.source === mediaSource.type,
|
(eid) => eid.source === mediaSource.type,
|
||||||
)?.id;
|
)?.id;
|
||||||
if (!externalId) {
|
|
||||||
|
if (!externalId && mediaSource.type !== 'local') {
|
||||||
throw new Error('');
|
throw new Error('');
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
|
sortTitle: '',
|
||||||
mediaSourceId: mediaSource.uuid,
|
mediaSourceId: mediaSource.uuid,
|
||||||
libraryId: mediaLibrary.uuid,
|
libraryId: mediaLibrary.uuid,
|
||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
@@ -294,7 +306,7 @@ function convertProgramGroupingSearchResult(
|
|||||||
releaseDateString: doc.originalReleaseDate
|
releaseDateString: doc.originalReleaseDate
|
||||||
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
|
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
|
||||||
: null,
|
: null,
|
||||||
externalId,
|
externalId: externalId ?? grouping.externalKey ?? '',
|
||||||
sourceType: mediaSource.type,
|
sourceType: mediaSource.type,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -390,6 +402,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
offset: req.body.page ?? 1,
|
offset: req.body.page ?? 1,
|
||||||
limit: req.body.limit ?? 20,
|
limit: req.body.limit ?? 20,
|
||||||
},
|
},
|
||||||
|
mediaSourceId: req.body.mediaSourceId,
|
||||||
libraryId: req.body.libraryId,
|
libraryId: req.body.libraryId,
|
||||||
// TODO not a great cast...
|
// TODO not a great cast...
|
||||||
restrictSearchTo: req.body.query
|
restrictSearchTo: req.body.query
|
||||||
@@ -427,6 +440,8 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
req.serverCtx.programDB.getProgramGroupingChildCounts(groupingIds),
|
req.serverCtx.programDB.getProgramGroupingChildCounts(groupingIds),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
console.log(groupings, groupingIds);
|
||||||
|
|
||||||
const results = seq.collect(result.hits, (program) => {
|
const results = seq.collect(result.hits, (program) => {
|
||||||
const mediaSourceId = decodeCaseSensitiveId(program.mediaSourceId);
|
const mediaSourceId = decodeCaseSensitiveId(program.mediaSourceId);
|
||||||
const mediaSource = allMediaSourcesById[mediaSourceId];
|
const mediaSource = allMediaSourcesById[mediaSourceId];
|
||||||
@@ -495,7 +510,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
if (program) {
|
if (program) {
|
||||||
return res.send(
|
return res.send(
|
||||||
compact([
|
compact([
|
||||||
req.serverCtx.programConverter.convertProgramWithExternalIds(
|
req.serverCtx.programConverter.programOrmToContentProgram(
|
||||||
program,
|
program,
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
@@ -512,7 +527,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const apiPrograms = seq.collect(programs, (program) =>
|
const apiPrograms = seq.collect(programs, (program) =>
|
||||||
req.serverCtx.programConverter.convertProgramWithExternalIds(program),
|
req.serverCtx.programConverter.programOrmToContentProgram(program),
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.send(apiPrograms);
|
return res.send(apiPrograms);
|
||||||
@@ -528,7 +543,8 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
}),
|
}),
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
facetQuery: z.string().optional(),
|
facetQuery: z.string().optional(),
|
||||||
libraryId: z.string().uuid().optional(),
|
mediaSourceId: z.uuid().optional(),
|
||||||
|
libraryId: z.uuid().optional(),
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
@@ -543,6 +559,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
{
|
{
|
||||||
facetQuery: req.query.facetQuery,
|
facetQuery: req.query.facetQuery,
|
||||||
facetName: req.params.facetName,
|
facetName: req.params.facetName,
|
||||||
|
mediaSourceId: req.query.mediaSourceId,
|
||||||
libraryId: req.query.libraryId,
|
libraryId: req.query.libraryId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -627,6 +644,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
with: {
|
with: {
|
||||||
mediaStreams: true,
|
mediaStreams: true,
|
||||||
chapters: 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(
|
fastify.get(
|
||||||
'/programs/:id/stream_details',
|
'/programs/:id/stream_details',
|
||||||
{
|
{
|
||||||
@@ -669,6 +807,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
.status(404)
|
.status(404)
|
||||||
.send('Program has no associated media source ID');
|
.send('Program has no associated media source ID');
|
||||||
}
|
}
|
||||||
|
const mediaSourceId = program.mediaSourceId;
|
||||||
|
|
||||||
const server = await req.serverCtx.mediaSourceDB.findByType(
|
const server = await req.serverCtx.mediaSourceDB.findByType(
|
||||||
program.sourceType,
|
program.sourceType,
|
||||||
@@ -690,15 +829,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
ExternalStreamDetailsFetcherFactory,
|
ExternalStreamDetailsFetcherFactory,
|
||||||
)
|
)
|
||||||
.getStream({
|
.getStream({
|
||||||
lineupItem: {
|
lineupItem: { ...program, mediaSourceId },
|
||||||
externalKey: program.externalKey,
|
|
||||||
externalSource: program.sourceType,
|
|
||||||
externalSourceId: program.mediaSourceId ?? program.externalSourceId,
|
|
||||||
duration: program.duration,
|
|
||||||
externalFilePath: program.plexFilePath ?? undefined,
|
|
||||||
programId: program.uuid,
|
|
||||||
programType: program.type,
|
|
||||||
},
|
|
||||||
server,
|
server,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -844,7 +975,10 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
return res.status(404).send('ID not found');
|
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') {
|
if (req.query.method === 'proxy') {
|
||||||
try {
|
try {
|
||||||
logger.debug('Proxying response to %s', result);
|
logger.debug('Proxying response to %s', result);
|
||||||
@@ -1144,7 +1278,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const converted =
|
const converted =
|
||||||
req.serverCtx.programConverter.programDaoToContentProgram(program);
|
req.serverCtx.programConverter.programOrmToContentProgram(program);
|
||||||
|
|
||||||
if (!converted) {
|
if (!converted) {
|
||||||
return res
|
return res
|
||||||
@@ -1185,7 +1319,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
return res.send(
|
return res.send(
|
||||||
groupByUniq(
|
groupByUniq(
|
||||||
seq.collect(results, (p) =>
|
seq.collect(results, (p) =>
|
||||||
req.serverCtx.programConverter.programDaoToContentProgram(p),
|
req.serverCtx.programConverter.programOrmToContentProgram(p),
|
||||||
),
|
),
|
||||||
(p) => p.id,
|
(p) => p.id,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -240,13 +240,10 @@ export const systemApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
await Result.attemptAsync(() =>
|
await Result.attemptAsync(() =>
|
||||||
new ChildProcessHelper().getStdout(
|
new ChildProcessHelper().getStdout('nvidia-smi', [], {
|
||||||
'nvidia-smi',
|
swallowError: true,
|
||||||
[],
|
isPath: false,
|
||||||
true,
|
}),
|
||||||
{},
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
).either(identity, (err) => err.message),
|
).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 fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { DeepPartial } from 'ts-essentials';
|
import type { DeepPartial } from 'ts-essentials';
|
||||||
@@ -46,6 +48,8 @@ export async function bootstrapTunarr(
|
|||||||
opts: GlobalOptions = globalOptions(),
|
opts: GlobalOptions = globalOptions(),
|
||||||
initialSettings?: DeepPartial<SettingsFile>,
|
initialSettings?: DeepPartial<SettingsFile>,
|
||||||
) {
|
) {
|
||||||
|
languages.registerLocale(en);
|
||||||
|
|
||||||
const hasTunarrDb = await fileExists(opts.databaseDirectory);
|
const hasTunarrDb = await fileExists(opts.databaseDirectory);
|
||||||
if (!hasTunarrDb) {
|
if (!hasTunarrDb) {
|
||||||
RootLogger.info(
|
RootLogger.info(
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { LoadChannelCacheStartupTask } from './services/startup/LoadChannelCache
|
|||||||
import { ScheduleJobsStartupTask } from './services/startup/ScheduleJobsStartupTask.ts';
|
import { ScheduleJobsStartupTask } from './services/startup/ScheduleJobsStartupTask.ts';
|
||||||
import { SeedFfmpegInfoCache } from './services/startup/SeedFfmpegInfoCache.ts';
|
import { SeedFfmpegInfoCache } from './services/startup/SeedFfmpegInfoCache.ts';
|
||||||
import { SeedSystemDevicesStartupTask } from './services/startup/SeedSystemDevicesStartupTask.ts';
|
import { SeedSystemDevicesStartupTask } from './services/startup/SeedSystemDevicesStartupTask.ts';
|
||||||
|
import { StreamCacheMigratorStartupTask } from './services/startup/StreamCacheMigratorStartupTask.ts';
|
||||||
import { ChannelCache } from './stream/ChannelCache.ts';
|
import { ChannelCache } from './stream/ChannelCache.ts';
|
||||||
import { FixerRunner } from './tasks/fixers/FixerRunner.ts';
|
import { FixerRunner } from './tasks/fixers/FixerRunner.ts';
|
||||||
import { ChildProcessHelper } from './util/ChildProcessHelper.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(FixerRunner).inSingletonScope();
|
||||||
bind(KEYS.StartupTask).to(GenerateGuideStartupTask).inSingletonScope();
|
bind(KEYS.StartupTask).to(GenerateGuideStartupTask).inSingletonScope();
|
||||||
bind(KEYS.StartupTask).to(LoadChannelCacheStartupTask).inSingletonScope();
|
bind(KEYS.StartupTask).to(LoadChannelCacheStartupTask).inSingletonScope();
|
||||||
|
bind(KEYS.StartupTask).to(StreamCacheMigratorStartupTask).inSingletonScope();
|
||||||
|
|
||||||
if (getBooleanEnvVar(USE_WORKER_POOL_ENV_VAR, false)) {
|
if (getBooleanEnvVar(USE_WORKER_POOL_ENV_VAR, false)) {
|
||||||
bind(KEYS.WorkerPool).toService(TunarrWorkerPool);
|
bind(KEYS.WorkerPool).toService(TunarrWorkerPool);
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
filter,
|
filter,
|
||||||
forEach,
|
forEach,
|
||||||
groupBy,
|
groupBy,
|
||||||
identity,
|
|
||||||
isEmpty,
|
isEmpty,
|
||||||
isNil,
|
isNil,
|
||||||
isNull,
|
isNull,
|
||||||
@@ -100,6 +99,7 @@ import {
|
|||||||
import { SchemaBackedDbAdapter } from './json/SchemaBackedJsonDBAdapter.ts';
|
import { SchemaBackedDbAdapter } from './json/SchemaBackedJsonDBAdapter.ts';
|
||||||
import { calculateStartTimeOffsets } from './lineupUtil.ts';
|
import { calculateStartTimeOffsets } from './lineupUtil.ts';
|
||||||
import {
|
import {
|
||||||
|
AllProgramGroupingFields,
|
||||||
withFallbackPrograms,
|
withFallbackPrograms,
|
||||||
withMusicArtistAlbums,
|
withMusicArtistAlbums,
|
||||||
withProgramExternalIds,
|
withProgramExternalIds,
|
||||||
@@ -112,15 +112,15 @@ import {
|
|||||||
withTvShowSeasons,
|
withTvShowSeasons,
|
||||||
} from './programQueryHelpers.ts';
|
} from './programQueryHelpers.ts';
|
||||||
import {
|
import {
|
||||||
|
Channel,
|
||||||
ChannelUpdate,
|
ChannelUpdate,
|
||||||
NewChannel,
|
NewChannel,
|
||||||
NewChannelFillerShow,
|
|
||||||
NewChannelProgram,
|
|
||||||
Channel as RawChannel,
|
Channel as RawChannel,
|
||||||
} from './schema/Channel.ts';
|
} from './schema/Channel.ts';
|
||||||
|
import { NewChannelFillerShow } from './schema/ChannelFillerShow.ts';
|
||||||
|
import { NewChannelProgram } from './schema/ChannelPrograms.ts';
|
||||||
import { ProgramType } from './schema/Program.ts';
|
import { ProgramType } from './schema/Program.ts';
|
||||||
import {
|
import {
|
||||||
AllProgramGroupingFields,
|
|
||||||
MinimalProgramGroupingFields,
|
MinimalProgramGroupingFields,
|
||||||
ProgramGroupingType,
|
ProgramGroupingType,
|
||||||
} from './schema/ProgramGrouping.ts';
|
} from './schema/ProgramGrouping.ts';
|
||||||
@@ -130,12 +130,14 @@ import {
|
|||||||
} from './schema/SubtitlePreferences.ts';
|
} from './schema/SubtitlePreferences.ts';
|
||||||
import { DB } from './schema/db.ts';
|
import { DB } from './schema/db.ts';
|
||||||
import {
|
import {
|
||||||
|
ChannelOrmWithRelations,
|
||||||
ChannelWithPrograms,
|
ChannelWithPrograms,
|
||||||
ChannelWithRelations,
|
ChannelWithRelations,
|
||||||
MusicArtistWithExternalIds,
|
MusicArtistWithExternalIds,
|
||||||
ProgramWithRelations,
|
ProgramWithRelations,
|
||||||
TvShowWithExternalIds,
|
TvShowWithExternalIds,
|
||||||
} from './schema/derivedTypes.js';
|
} from './schema/derivedTypes.js';
|
||||||
|
import { DrizzleDBAccess } from './schema/index.ts';
|
||||||
|
|
||||||
// We use this to chunk super huge channel / program relation updates because
|
// 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 () ...").
|
// 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)
|
@inject(KEYS.WorkerPoolFactory)
|
||||||
private workerPoolProvider: interfaces.AutoFactory<IWorkerPool>,
|
private workerPoolProvider: interfaces.AutoFactory<IWorkerPool>,
|
||||||
@inject(FileSystemService) private fileSystemService: FileSystemService,
|
@inject(FileSystemService) private fileSystemService: FileSystemService,
|
||||||
|
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async channelExists(channelId: string) {
|
async channelExists(channelId: string) {
|
||||||
@@ -251,10 +254,28 @@ export class ChannelDB implements IChannelDB {
|
|||||||
id: string | number,
|
id: string | number,
|
||||||
includeFiller: true,
|
includeFiller: true,
|
||||||
): Promise<Maybe<MarkRequired<ChannelWithRelations, 'fillerShows'>>>;
|
): Promise<Maybe<MarkRequired<ChannelWithRelations, 'fillerShows'>>>;
|
||||||
getChannel(
|
async getChannel(
|
||||||
id: string | number,
|
id: string | number,
|
||||||
includeFiller: boolean = false,
|
includeFiller: boolean = false,
|
||||||
): Promise<Maybe<ChannelWithRelations>> {
|
): 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
|
return this.db
|
||||||
.selectFrom('channel')
|
.selectFrom('channel')
|
||||||
.$if(isString(id), (eb) => eb.where('channel.uuid', '=', id as string))
|
.$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);
|
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,
|
uuid: string,
|
||||||
typeFilter?: ContentProgramType,
|
|
||||||
): Promise<ChannelWithPrograms | undefined> {
|
): Promise<ChannelWithPrograms | undefined> {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('channel')
|
.selectFrom('channel')
|
||||||
@@ -296,41 +351,35 @@ export class ChannelDB implements IChannelDB {
|
|||||||
'channelPrograms.channelUuid',
|
'channelPrograms.channelUuid',
|
||||||
)
|
)
|
||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
withPrograms(
|
withPrograms(eb, {
|
||||||
eb,
|
joins: {
|
||||||
{
|
customShows: true,
|
||||||
joins: {
|
tvShow: [
|
||||||
customShows: true,
|
'programGrouping.uuid',
|
||||||
tvShow: [
|
'programGrouping.title',
|
||||||
'programGrouping.uuid',
|
'programGrouping.summary',
|
||||||
'programGrouping.title',
|
'programGrouping.type',
|
||||||
'programGrouping.summary',
|
],
|
||||||
'programGrouping.type',
|
tvSeason: [
|
||||||
],
|
'programGrouping.uuid',
|
||||||
tvSeason: [
|
'programGrouping.title',
|
||||||
'programGrouping.uuid',
|
'programGrouping.summary',
|
||||||
'programGrouping.title',
|
'programGrouping.type',
|
||||||
'programGrouping.summary',
|
],
|
||||||
'programGrouping.type',
|
trackArtist: [
|
||||||
],
|
'programGrouping.uuid',
|
||||||
trackArtist: [
|
'programGrouping.title',
|
||||||
'programGrouping.uuid',
|
'programGrouping.summary',
|
||||||
'programGrouping.title',
|
'programGrouping.type',
|
||||||
'programGrouping.summary',
|
],
|
||||||
'programGrouping.type',
|
trackAlbum: [
|
||||||
],
|
'programGrouping.uuid',
|
||||||
trackAlbum: [
|
'programGrouping.title',
|
||||||
'programGrouping.uuid',
|
'programGrouping.summary',
|
||||||
'programGrouping.title',
|
'programGrouping.type',
|
||||||
'programGrouping.summary',
|
],
|
||||||
'programGrouping.type',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
typeFilter
|
}),
|
||||||
? (eb) => eb.where('program.type', '=', typeFilter)
|
|
||||||
: identity,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.groupBy('channel.uuid')
|
.groupBy('channel.uuid')
|
||||||
.orderBy('channel.number asc')
|
.orderBy('channel.number asc')
|
||||||
@@ -839,7 +888,7 @@ export class ChannelDB implements IChannelDB {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllChannels(pageParams?: PageParams) {
|
getAllChannels(pageParams?: PageParams): Promise<Channel[]> {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('channel')
|
.selectFrom('channel')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -1266,7 +1315,7 @@ export class ChannelDB implements IChannelDB {
|
|||||||
|
|
||||||
async loadChannelAndLineup(
|
async loadChannelAndLineup(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
): Promise<{ channel: RawChannel; lineup: Lineup } | null> {
|
): Promise<{ channel: Channel; lineup: Lineup } | null> {
|
||||||
const channel = await this.getChannel(channelId);
|
const channel = await this.getChannel(channelId);
|
||||||
if (isNil(channel)) {
|
if (isNil(channel)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1281,7 +1330,7 @@ export class ChannelDB implements IChannelDB {
|
|||||||
async loadChannelWithProgamsAndLineup(
|
async loadChannelWithProgamsAndLineup(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
): Promise<{ channel: ChannelWithPrograms; lineup: Lineup } | null> {
|
): Promise<{ channel: ChannelWithPrograms; lineup: Lineup } | null> {
|
||||||
const channel = await this.getChannelAndPrograms(channelId);
|
const channel = await this.getChannelAndProgramsOld(channelId);
|
||||||
if (isNil(channel)) {
|
if (isNil(channel)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1302,7 +1351,7 @@ export class ChannelDB implements IChannelDB {
|
|||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
limit: number = -1,
|
limit: number = -1,
|
||||||
): Promise<ChannelProgramming | null> {
|
): Promise<ChannelProgramming | null> {
|
||||||
const channel = await this.getChannelAndPrograms(channelId);
|
const channel = await this.getChannelAndProgramsOld(channelId);
|
||||||
if (isNil(channel)) {
|
if (isNil(channel)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1668,7 +1717,7 @@ export class ChannelDB implements IChannelDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async buildCondensedLineup(
|
private async buildCondensedLineup(
|
||||||
channel: RawChannel,
|
channel: Channel,
|
||||||
dbProgramIds: Set<string>,
|
dbProgramIds: Set<string>,
|
||||||
lineup: LineupItem[],
|
lineup: LineupItem[],
|
||||||
): Promise<{ lineup: CondensedChannelProgram[]; offsets: number[] }> {
|
): Promise<{ lineup: CondensedChannelProgram[]; offsets: number[] }> {
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ import {
|
|||||||
AllProgramJoins,
|
AllProgramJoins,
|
||||||
withCustomShowPrograms,
|
withCustomShowPrograms,
|
||||||
} from './programQueryHelpers.ts';
|
} from './programQueryHelpers.ts';
|
||||||
import type {
|
import type { NewCustomShow } from './schema/CustomShow.ts';
|
||||||
NewCustomShow,
|
import type { NewCustomShowContent } from './schema/CustomShowContent.ts';
|
||||||
NewCustomShowContent,
|
|
||||||
} from './schema/CustomShow.ts';
|
|
||||||
import { DB } from './schema/db.ts';
|
import { DB } from './schema/db.ts';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ const DBModule = new ContainerModule((bind) => {
|
|||||||
bind<interfaces.Factory<Kysely<DB>>>(KEYS.DatabaseFactory).toAutoFactory(
|
bind<interfaces.Factory<Kysely<DB>>>(KEYS.DatabaseFactory).toAutoFactory(
|
||||||
KEYS.Database,
|
KEYS.Database,
|
||||||
);
|
);
|
||||||
|
bind<interfaces.Factory<DrizzleDBAccess>>(
|
||||||
|
KEYS.DrizzleDatabaseFactory,
|
||||||
|
).toAutoFactory(KEYS.DrizzleDB);
|
||||||
bind(KEYS.FillerListDB).to(FillerDB).inSingletonScope();
|
bind(KEYS.FillerListDB).to(FillerDB).inSingletonScope();
|
||||||
|
|
||||||
bind(ProgramDaoMinter).toSelf();
|
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';
|
} from './interfaces/IFillerListDB.ts';
|
||||||
import { createPendingProgramIndexMap } from './programHelpers.ts';
|
import { createPendingProgramIndexMap } from './programHelpers.ts';
|
||||||
import { withFillerPrograms } from './programQueryHelpers.ts';
|
import { withFillerPrograms } from './programQueryHelpers.ts';
|
||||||
import { ChannelFillerShow } from './schema/Channel.ts';
|
import { ChannelFillerShow } from './schema/ChannelFillerShow.ts';
|
||||||
import type {
|
import type { NewFillerShow } from './schema/FillerShow.ts';
|
||||||
NewFillerShow,
|
import type { NewFillerShowContent } from './schema/FillerShowContent.ts';
|
||||||
NewFillerShowContent,
|
|
||||||
} from './schema/FillerShow.ts';
|
|
||||||
import { DB } from './schema/db.ts';
|
import { DB } from './schema/db.ts';
|
||||||
import type { ChannelFillerShowWithContent } from './schema/derivedTypes.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 {
|
import type {
|
||||||
GetOrInsertResult,
|
|
||||||
IProgramDB,
|
IProgramDB,
|
||||||
ProgramGroupingChildCounts,
|
ProgramGroupingChildCounts,
|
||||||
ProgramGroupingExternalIdLookup,
|
ProgramGroupingExternalIdLookup,
|
||||||
ProgramUpsertRequest,
|
UpsertResult,
|
||||||
WithChannelIdFilter,
|
WithChannelIdFilter,
|
||||||
} from '@/db/interfaces/IProgramDB.js';
|
} from '@/db/interfaces/IProgramDB.js';
|
||||||
import { GlobalScheduler } from '@/services/Scheduler.js';
|
import { GlobalScheduler } from '@/services/Scheduler.js';
|
||||||
@@ -32,6 +31,7 @@ import {
|
|||||||
} from '@tunarr/types';
|
} from '@tunarr/types';
|
||||||
import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
|
import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||||
import { inject, injectable, interfaces } from 'inversify';
|
import { inject, injectable, interfaces } from 'inversify';
|
||||||
import {
|
import {
|
||||||
CaseWhenBuilder,
|
CaseWhenBuilder,
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite';
|
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite';
|
||||||
import {
|
import {
|
||||||
chunk,
|
chunk,
|
||||||
|
compact,
|
||||||
concat,
|
concat,
|
||||||
difference,
|
difference,
|
||||||
filter,
|
filter,
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
forEach,
|
forEach,
|
||||||
groupBy,
|
groupBy,
|
||||||
head,
|
head,
|
||||||
|
isArray,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
isNil,
|
isNil,
|
||||||
isNull,
|
isNull,
|
||||||
@@ -69,13 +71,18 @@ import {
|
|||||||
uniq,
|
uniq,
|
||||||
uniqBy,
|
uniqBy,
|
||||||
} from 'lodash-es';
|
} 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 { v4 } from 'uuid';
|
||||||
import { typedProperty } from '../types/path.ts';
|
import { typedProperty } from '../types/path.ts';
|
||||||
import { getNumericEnvVar, TUNARR_ENV_VARS } from '../util/env.ts';
|
import { getNumericEnvVar, TUNARR_ENV_VARS } from '../util/env.ts';
|
||||||
import {
|
import {
|
||||||
flatMapAsyncSeq,
|
flatMapAsyncSeq,
|
||||||
groupByFunc,
|
|
||||||
groupByUniq,
|
groupByUniq,
|
||||||
groupByUniqProp,
|
groupByUniqProp,
|
||||||
isDefined,
|
isDefined,
|
||||||
@@ -96,7 +103,7 @@ import {
|
|||||||
import { PageParams } from './interfaces/IChannelDB.ts';
|
import { PageParams } from './interfaces/IChannelDB.ts';
|
||||||
import {
|
import {
|
||||||
AllProgramFields,
|
AllProgramFields,
|
||||||
AllProgramJoins,
|
AllProgramGroupingFields,
|
||||||
ProgramUpsertFields,
|
ProgramUpsertFields,
|
||||||
selectProgramsBuilder,
|
selectProgramsBuilder,
|
||||||
withProgramByExternalId,
|
withProgramByExternalId,
|
||||||
@@ -107,7 +114,8 @@ import {
|
|||||||
withTvSeason,
|
withTvSeason,
|
||||||
withTvShow,
|
withTvShow,
|
||||||
} from './programQueryHelpers.ts';
|
} from './programQueryHelpers.ts';
|
||||||
import { MediaSourceType } from './schema/MediaSource.ts';
|
import { Artwork, NewArtwork } from './schema/Artwork.ts';
|
||||||
|
import { RemoteMediaSourceType } from './schema/MediaSource.ts';
|
||||||
import {
|
import {
|
||||||
NewProgramDao,
|
NewProgramDao,
|
||||||
ProgramDao,
|
ProgramDao,
|
||||||
@@ -120,12 +128,11 @@ import {
|
|||||||
NewProgramExternalId,
|
NewProgramExternalId,
|
||||||
NewSingleOrMultiExternalId,
|
NewSingleOrMultiExternalId,
|
||||||
ProgramExternalId,
|
ProgramExternalId,
|
||||||
ProgramExternalIdKeys,
|
|
||||||
toInsertableProgramExternalId,
|
toInsertableProgramExternalId,
|
||||||
} from './schema/ProgramExternalId.ts';
|
} from './schema/ProgramExternalId.ts';
|
||||||
import {
|
import {
|
||||||
AllProgramGroupingFields,
|
|
||||||
NewProgramGrouping,
|
NewProgramGrouping,
|
||||||
|
ProgramGrouping,
|
||||||
ProgramGroupingType,
|
ProgramGroupingType,
|
||||||
ProgramGroupingUpdate,
|
ProgramGroupingUpdate,
|
||||||
} from './schema/ProgramGrouping.ts';
|
} from './schema/ProgramGrouping.ts';
|
||||||
@@ -136,22 +143,37 @@ import {
|
|||||||
ProgramGroupingExternalIdFieldsWithAlias,
|
ProgramGroupingExternalIdFieldsWithAlias,
|
||||||
toInsertableProgramGroupingExternalId,
|
toInsertableProgramGroupingExternalId,
|
||||||
} from './schema/ProgramGroupingExternalId.ts';
|
} from './schema/ProgramGroupingExternalId.ts';
|
||||||
|
import {
|
||||||
|
NewProgramMediaFile,
|
||||||
|
ProgramMediaFile,
|
||||||
|
} from './schema/ProgramMediaFile.ts';
|
||||||
import {
|
import {
|
||||||
NewProgramMediaStream,
|
NewProgramMediaStream,
|
||||||
ProgramMediaStream,
|
ProgramMediaStream,
|
||||||
} from './schema/ProgramMediaStream.ts';
|
} from './schema/ProgramMediaStream.ts';
|
||||||
|
import {
|
||||||
|
NewProgramSubtitles,
|
||||||
|
ProgramSubtitles,
|
||||||
|
} from './schema/ProgramSubtitles.ts';
|
||||||
import { ProgramVersion } from './schema/ProgramVersion.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 { DB } from './schema/db.ts';
|
||||||
import type {
|
import type {
|
||||||
MusicAlbumWithExternalIds,
|
MusicAlbumWithExternalIds,
|
||||||
NewProgramGroupingWithExternalIds,
|
NewProgramGroupingWithRelations,
|
||||||
NewProgramVersion,
|
NewProgramVersion,
|
||||||
|
NewProgramWithRelations,
|
||||||
ProgramGroupingWithExternalIds,
|
ProgramGroupingWithExternalIds,
|
||||||
ProgramWithExternalIds,
|
ProgramWithExternalIds,
|
||||||
ProgramWithRelations,
|
ProgramWithRelations,
|
||||||
|
ProgramWithRelationsOrm,
|
||||||
TvSeasonWithExternalIds,
|
TvSeasonWithExternalIds,
|
||||||
} from './schema/derivedTypes.ts';
|
} from './schema/derivedTypes.ts';
|
||||||
|
import { DrizzleDBAccess } from './schema/index.ts';
|
||||||
|
|
||||||
type MintedNewProgramInfo = {
|
type MintedNewProgramInfo = {
|
||||||
program: NewProgramDao;
|
program: NewProgramDao;
|
||||||
@@ -197,17 +219,28 @@ export class ProgramDB implements IProgramDB {
|
|||||||
@inject(KEYS.Database) private db: Kysely<DB>,
|
@inject(KEYS.Database) private db: Kysely<DB>,
|
||||||
@inject(KEYS.ProgramDaoMinterFactory)
|
@inject(KEYS.ProgramDaoMinterFactory)
|
||||||
private programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
|
private programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
|
||||||
|
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
|
||||||
) {
|
) {
|
||||||
this.timer = new Timer(this.logger);
|
this.timer = new Timer(this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProgramById(id: string) {
|
async getProgramById(
|
||||||
return this.db
|
id: string,
|
||||||
.selectFrom('program')
|
): Promise<Maybe<MarkRequired<ProgramWithRelationsOrm, 'externalIds'>>> {
|
||||||
.selectAll()
|
return this.drizzleDB.query.program.findFirst({
|
||||||
.select((eb) => withProgramExternalIds(eb, ProgramExternalIdKeys))
|
where: (fields, { eq }) => eq(fields.uuid, id),
|
||||||
.where('program.uuid', '=', id)
|
with: {
|
||||||
.executeTakeFirst();
|
externalIds: true,
|
||||||
|
artwork: true,
|
||||||
|
subtitles: true,
|
||||||
|
versions: {
|
||||||
|
with: {
|
||||||
|
mediaStreams: true,
|
||||||
|
mediaFiles: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProgramExternalIds(
|
async getProgramExternalIds(
|
||||||
@@ -248,6 +281,28 @@ export class ProgramDB implements IProgramDB {
|
|||||||
async getProgramsByIds(
|
async getProgramsByIds(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
batchSize: number = 500,
|
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[]> {
|
): Promise<ProgramWithRelations[]> {
|
||||||
const results: ProgramWithRelations[] = [];
|
const results: ProgramWithRelations[] = [];
|
||||||
for (const idChunk of chunk(ids, batchSize)) {
|
for (const idChunk of chunk(ids, batchSize)) {
|
||||||
@@ -267,12 +322,13 @@ export class ProgramDB implements IProgramDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getProgramGrouping(id: string) {
|
async getProgramGrouping(id: string) {
|
||||||
return this.db
|
return this.drizzleDB.query.programGrouping.findFirst({
|
||||||
.selectFrom('programGrouping')
|
where: (fields, { eq }) => eq(fields.uuid, id),
|
||||||
.selectAll()
|
with: {
|
||||||
.select(withProgramGroupingExternalIds)
|
externalIds: true,
|
||||||
.where('uuid', '=', id)
|
artwork: true,
|
||||||
.executeTakeFirst();
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProgramGroupings(ids: string[]) {
|
async getProgramGroupings(ids: string[]) {
|
||||||
@@ -462,13 +518,14 @@ export class ProgramDB implements IProgramDB {
|
|||||||
)
|
)
|
||||||
.executeTakeFirstOrThrow(),
|
.executeTakeFirstOrThrow(),
|
||||||
baseQuery
|
baseQuery
|
||||||
.selectAll()
|
.selectAll('programGrouping')
|
||||||
.orderBy(childType === 'season' ? 'title asc' : 'year asc')
|
.orderBy(childType === 'season' ? 'title asc' : 'year asc')
|
||||||
.select(withProgramGroupingExternalIds)
|
.select(withProgramGroupingExternalIds)
|
||||||
.$if(!!params && params.limit >= 0, (eb) =>
|
.$if(!!params && params.limit >= 0, (eb) =>
|
||||||
eb.offset(params.offset),
|
eb.offset(params.offset),
|
||||||
)
|
)
|
||||||
.$if(!!params && params.limit >= 0, (eb) => eb.limit(params.limit))
|
.$if(!!params && params.limit >= 0, (eb) => eb.limit(params.limit))
|
||||||
|
.$narrowType<ProgramGroupingWithExternalIds[]>()
|
||||||
.execute(),
|
.execute(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -532,39 +589,39 @@ export class ProgramDB implements IProgramDB {
|
|||||||
chunkSize: number = 200,
|
chunkSize: number = 200,
|
||||||
) {
|
) {
|
||||||
const allIds = [...ids];
|
const allIds = [...ids];
|
||||||
const programs: ProgramWithRelations[] = [];
|
const programs: MarkRequired<ProgramWithRelationsOrm, 'externalIds'>[] = [];
|
||||||
for (const idChunk of chunk(allIds, chunkSize)) {
|
for (const idChunk of chunk(allIds, chunkSize)) {
|
||||||
programs.push(
|
const results = await this.drizzleDB.query.programExternalId.findMany({
|
||||||
...(await this.db
|
where: (fields, { or, and, eq }) => {
|
||||||
.selectFrom('programExternalId')
|
const ands = idChunk.map(([ps, es, ek]) =>
|
||||||
.select((eb) =>
|
and(
|
||||||
withProgramByExternalId(eb, { joins: AllProgramJoins }),
|
eq(fields.externalKey, ek),
|
||||||
)
|
eq(fields.sourceType, programSourceTypeFromString(ps)!),
|
||||||
.where((eb) =>
|
eq(fields.mediaSourceId, es),
|
||||||
eb.or(
|
|
||||||
map(idChunk, ([ps, es, ek]) =>
|
|
||||||
eb.and([
|
|
||||||
eb('programExternalId.externalKey', '=', ek),
|
|
||||||
eb('programExternalId.mediaSourceId', '=', es),
|
|
||||||
eb(
|
|
||||||
'programExternalId.sourceType',
|
|
||||||
'=',
|
|
||||||
programSourceTypeFromString(ps)!,
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.execute()
|
return or(...ands);
|
||||||
.then((_) => seq.collect(_, (eid) => eid.program))),
|
},
|
||||||
);
|
with: {
|
||||||
|
program: {
|
||||||
|
with: {
|
||||||
|
album: true,
|
||||||
|
artist: true,
|
||||||
|
season: true,
|
||||||
|
show: true,
|
||||||
|
externalIds: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
programs.push(...seq.collect(results, (r) => r.program));
|
||||||
}
|
}
|
||||||
|
|
||||||
return programs;
|
return programs;
|
||||||
}
|
}
|
||||||
|
|
||||||
async lookupByMediaSource(
|
async lookupByMediaSource(
|
||||||
sourceType: MediaSourceType,
|
sourceType: RemoteMediaSourceType,
|
||||||
sourceId: MediaSourceId,
|
sourceId: MediaSourceId,
|
||||||
programType: Maybe<ProgramType>,
|
programType: Maybe<ProgramType>,
|
||||||
chunkSize: number = 200,
|
chunkSize: number = 200,
|
||||||
@@ -888,10 +945,19 @@ export class ProgramDB implements IProgramDB {
|
|||||||
return upsertedPrograms;
|
return upsertedPrograms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upsertPrograms(
|
||||||
|
request: NewProgramWithRelations,
|
||||||
|
): Promise<ProgramWithExternalIds>;
|
||||||
|
upsertPrograms(
|
||||||
|
programs: NewProgramWithRelations[],
|
||||||
|
programUpsertBatchSize?: number,
|
||||||
|
): Promise<ProgramWithExternalIds[]>;
|
||||||
async upsertPrograms(
|
async upsertPrograms(
|
||||||
requests: ProgramUpsertRequest[],
|
requests: NewProgramWithRelations | NewProgramWithRelations[],
|
||||||
programUpsertBatchSize: number = 100,
|
programUpsertBatchSize: number = 100,
|
||||||
) {
|
): Promise<ProgramWithExternalIds | ProgramWithExternalIds[]> {
|
||||||
|
const wasSingleRequest = !isArray(requests);
|
||||||
|
requests = isArray(requests) ? requests : [requests];
|
||||||
if (isEmpty(requests)) {
|
if (isEmpty(requests)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -901,19 +967,12 @@ export class ProgramDB implements IProgramDB {
|
|||||||
// Group related items by canonicalId because the UUID we get back
|
// 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)
|
// from the upsert may not be the one we generated (if an existing entry)
|
||||||
// already exists
|
// already exists
|
||||||
const externalIdsByProgramCanonicalId = groupByFunc(
|
const requestsByCanonicalId = groupByUniq(
|
||||||
requests,
|
requests,
|
||||||
({ program }) => program.canonicalId,
|
({ program }) => program.canonicalId,
|
||||||
(program) => program.externalIds,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const programVersionsByCanonicalId = groupByFunc(
|
const result = await Promise.all(
|
||||||
requests,
|
|
||||||
({ program }) => program.canonicalId,
|
|
||||||
(request) => request.versions,
|
|
||||||
);
|
|
||||||
|
|
||||||
return await Promise.all(
|
|
||||||
chunk(requests, programUpsertBatchSize).map(async (c) => {
|
chunk(requests, programUpsertBatchSize).map(async (c) => {
|
||||||
const chunkResult = await db.transaction().execute((tx) =>
|
const chunkResult = await db.transaction().execute((tx) =>
|
||||||
tx
|
tx
|
||||||
@@ -936,28 +995,43 @@ export class ProgramDB implements IProgramDB {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allExternalIds = flatten(c.map((program) => program.externalIds));
|
const allExternalIds = flatten(c.map((program) => program.externalIds));
|
||||||
|
const versionsToInsert: NewProgramVersion[] = [];
|
||||||
|
const artworkToInsert: NewArtwork[] = [];
|
||||||
|
const subtitlesToInsert: NewProgramSubtitles[] = [];
|
||||||
for (const program of chunkResult) {
|
for (const program of chunkResult) {
|
||||||
const key = program.canonicalId;
|
const key = program.canonicalId;
|
||||||
const eids = externalIdsByProgramCanonicalId[key] ?? [];
|
const request: Maybe<NewProgramWithRelations> =
|
||||||
|
requestsByCanonicalId[key];
|
||||||
|
const eids = request?.externalIds ?? [];
|
||||||
for (const eid of eids) {
|
for (const eid of eids) {
|
||||||
eid.programUuid = program.uuid;
|
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 =
|
const externalIdsByProgramId =
|
||||||
await this.upsertProgramExternalIds(allExternalIds);
|
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.upsertProgramVersions(versionsToInsert);
|
||||||
|
|
||||||
|
await this.upsertArtwork(artworkToInsert);
|
||||||
|
|
||||||
|
await this.upsertSubtitles(subtitlesToInsert);
|
||||||
|
|
||||||
return chunkResult.map(
|
return chunkResult.map(
|
||||||
(upsertedProgram) =>
|
(upsertedProgram) =>
|
||||||
({
|
({
|
||||||
@@ -967,6 +1041,12 @@ export class ProgramDB implements IProgramDB {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
).then(flatten);
|
).then(flatten);
|
||||||
|
|
||||||
|
if (wasSingleRequest) {
|
||||||
|
return head(result)!;
|
||||||
|
} else {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async upsertProgramVersions(versions: NewProgramVersion[]) {
|
private async upsertProgramVersions(versions: NewProgramVersion[]) {
|
||||||
@@ -991,21 +1071,25 @@ export class ProgramDB implements IProgramDB {
|
|||||||
.insertInto('programVersion')
|
.insertInto('programVersion')
|
||||||
.values(
|
.values(
|
||||||
versionBatch.map((version) =>
|
versionBatch.map((version) =>
|
||||||
omit(version, ['chapters', 'mediaStreams']),
|
omit(version, ['chapters', 'mediaStreams', 'mediaFiles']),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.upsertProgramMediaStreams(
|
await this.upsertProgramMediaStreams(
|
||||||
versionBatch.flatMap(({ mediaStreams }) => mediaStreams),
|
versionBatch.flatMap(({ mediaStreams }) => mediaStreams),
|
||||||
tx,
|
tx,
|
||||||
),
|
),
|
||||||
this.upsertProgramChapters(
|
await this.upsertProgramChapters(
|
||||||
versionBatch.flatMap(({ chapters }) => chapters ?? []),
|
versionBatch.flatMap(({ chapters }) => chapters ?? []),
|
||||||
tx,
|
tx,
|
||||||
),
|
),
|
||||||
|
await this.upsertProgramMediaFiles(
|
||||||
|
versionBatch.flatMap(({ mediaFiles }) => mediaFiles),
|
||||||
|
tx,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
insertedVersions.push(...insertResult);
|
insertedVersions.push(...insertResult);
|
||||||
@@ -1065,6 +1149,207 @@ export class ProgramDB implements IProgramDB {
|
|||||||
return inserted;
|
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(
|
async upsertProgramExternalIds(
|
||||||
externalIds: NewSingleOrMultiExternalId[],
|
externalIds: NewSingleOrMultiExternalId[],
|
||||||
chunkSize: number = 100,
|
chunkSize: number = 100,
|
||||||
@@ -1234,7 +1519,7 @@ export class ProgramDB implements IProgramDB {
|
|||||||
async getProgramGroupingCanonicalIds(
|
async getProgramGroupingCanonicalIds(
|
||||||
mediaSourceLibraryId: string,
|
mediaSourceLibraryId: string,
|
||||||
type: ProgramGroupingType,
|
type: ProgramGroupingType,
|
||||||
sourceType: MediaSourceType,
|
sourceType: StrictExclude<MediaSourceType, 'local'>,
|
||||||
) {
|
) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('programGrouping')
|
.selectFrom('programGrouping')
|
||||||
@@ -1287,11 +1572,12 @@ export class ProgramDB implements IProgramDB {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrInsertProgramGrouping(
|
async upsertProgramGrouping(
|
||||||
dao: NewProgramGroupingWithExternalIds,
|
newGroupingAndRelations: NewProgramGroupingWithRelations,
|
||||||
externalId: ProgramGroupingExternalIdLookup,
|
externalId: ProgramGroupingExternalIdLookup,
|
||||||
forceUpdate: boolean = false,
|
forceUpdate: boolean = false,
|
||||||
): Promise<GetOrInsertResult<ProgramGroupingWithExternalIds>> {
|
): Promise<UpsertResult<ProgramGroupingWithExternalIds>> {
|
||||||
|
const { programGrouping: dao, externalIds } = newGroupingAndRelations;
|
||||||
const existing = await this.getProgramGroupingByExternalId(externalId);
|
const existing = await this.getProgramGroupingByExternalId(externalId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
let wasUpdated = false;
|
let wasUpdated = false;
|
||||||
@@ -1307,14 +1593,18 @@ export class ProgramDB implements IProgramDB {
|
|||||||
forceUpdate || differentVersion || missingAssociation;
|
forceUpdate || differentVersion || missingAssociation;
|
||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
dao.uuid = existing.uuid;
|
dao.uuid = existing.uuid;
|
||||||
dao.externalIds.forEach((externalId) => {
|
externalIds.forEach((externalId) => {
|
||||||
externalId.groupUuid = existing.uuid;
|
externalId.groupUuid = existing.uuid;
|
||||||
});
|
});
|
||||||
await this.db.transaction().execute(async (tx) => {
|
await this.db.transaction().execute(async (tx) => {
|
||||||
await this.updateProgramGrouping(dao, existing, tx);
|
await this.updateProgramGrouping(
|
||||||
|
newGroupingAndRelations,
|
||||||
|
existing,
|
||||||
|
tx,
|
||||||
|
);
|
||||||
await this.updateProgramGroupingExternalIds(
|
await this.updateProgramGroupingExternalIds(
|
||||||
existing.externalIds,
|
existing.externalIds,
|
||||||
dao.externalIds,
|
externalIds,
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1335,16 +1625,15 @@ export class ProgramDB implements IProgramDB {
|
|||||||
.values(omit(dao, 'externalIds'))
|
.values(omit(dao, 'externalIds'))
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
const externalIds: ProgramGroupingExternalId[] = [];
|
const insertedExternalIds: ProgramGroupingExternalId[] = [];
|
||||||
if (dao.externalIds.length > 0) {
|
if (insertedExternalIds.length > 0) {
|
||||||
externalIds.push(
|
insertedExternalIds.push(
|
||||||
...(await tx
|
...(await tx
|
||||||
.insertInto('programGroupingExternalId')
|
.insertInto('programGroupingExternalId')
|
||||||
.values(
|
.values(
|
||||||
dao.externalIds.map((eid) => ({
|
externalIds.map((eid) =>
|
||||||
...omit(eid, 'type'),
|
this.singleOrMultiProgramGroupingExternalIdToDao(eid),
|
||||||
groupUuid: grouping.uuid,
|
),
|
||||||
})),
|
|
||||||
)
|
)
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.execute()),
|
.execute()),
|
||||||
@@ -1355,14 +1644,173 @@ export class ProgramDB implements IProgramDB {
|
|||||||
wasUpdated: false,
|
wasUpdated: false,
|
||||||
entity: {
|
entity: {
|
||||||
...grouping,
|
...grouping,
|
||||||
externalIds,
|
externalIds: insertedExternalIds,
|
||||||
} satisfies ProgramGroupingWithExternalIds,
|
} 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(
|
private async updateProgramGrouping(
|
||||||
incoming: NewProgramGroupingWithExternalIds,
|
{ programGrouping: incoming }: NewProgramGroupingWithRelations,
|
||||||
existing: ProgramGroupingWithExternalIds,
|
existing: ProgramGroupingWithExternalIds,
|
||||||
tx: Kysely<DB> = this.db,
|
tx: Kysely<DB> = this.db,
|
||||||
) {
|
) {
|
||||||
@@ -1533,33 +1981,56 @@ export class ProgramDB implements IProgramDB {
|
|||||||
groupId: string,
|
groupId: string,
|
||||||
groupTypeHint?: ProgramGroupingType,
|
groupTypeHint?: ProgramGroupingType,
|
||||||
) {
|
) {
|
||||||
return this.db
|
return this.drizzleDB.query.program.findMany({
|
||||||
.selectFrom('program')
|
where: (fields, { or, eq }) => {
|
||||||
.$if(isUndefined(groupTypeHint), (qb) =>
|
if (groupTypeHint) {
|
||||||
qb.where((eb) =>
|
switch (groupTypeHint) {
|
||||||
eb.or([
|
case 'show':
|
||||||
eb('program.tvShowUuid', '=', groupId),
|
return eq(fields.tvShowUuid, groupId);
|
||||||
eb('program.albumUuid', '=', groupId),
|
case 'season':
|
||||||
eb('program.seasonUuid', '=', groupId),
|
return eq(fields.seasonUuid, groupId);
|
||||||
eb('program.artistUuid', '=', groupId),
|
case 'artist':
|
||||||
]),
|
return eq(fields.artistUuid, groupId);
|
||||||
),
|
case 'album':
|
||||||
)
|
return eq(fields.albumUuid, groupId);
|
||||||
.$if(isDefined(groupTypeHint), (qb) => {
|
}
|
||||||
switch (groupTypeHint!) {
|
} else {
|
||||||
case 'show':
|
return or(
|
||||||
return qb.where('program.tvShowUuid', '=', groupId);
|
eq(fields.albumUuid, groupId),
|
||||||
case 'season':
|
eq(fields.artistUuid, groupId),
|
||||||
return qb.where('program.seasonUuid', '=', groupId);
|
eq(fields.tvShowUuid, groupId),
|
||||||
case 'artist':
|
eq(fields.seasonUuid, groupId),
|
||||||
return qb.where('program.artistUuid', '=', groupId);
|
);
|
||||||
case 'album':
|
|
||||||
return qb.where('program.albumUuid', '=', groupId);
|
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
.selectAll()
|
with: {
|
||||||
.select(withProgramExternalIds)
|
album:
|
||||||
.execute();
|
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(
|
private async handleProgramGroupings(
|
||||||
@@ -1691,30 +2162,17 @@ export class ProgramDB implements IProgramDB {
|
|||||||
const existingGroupings = await this.timer.timeAsync(
|
const existingGroupings = await this.timer.timeAsync(
|
||||||
`selecting grouping external ids (${allGroupingKeys.length})`,
|
`selecting grouping external ids (${allGroupingKeys.length})`,
|
||||||
() =>
|
() =>
|
||||||
this.db
|
this.drizzleDB.query.programGroupingExternalId.findMany({
|
||||||
.selectFrom('programGroupingExternalId')
|
where: (fields, { eq, and, inArray }) =>
|
||||||
.where((eb) => {
|
and(
|
||||||
return eb.and([
|
eq(fields.sourceType, mediaSourceType),
|
||||||
eb('programGroupingExternalId.sourceType', '=', mediaSourceType),
|
eq(fields.mediaSourceId, mediaSourceId),
|
||||||
eb('programGroupingExternalId.mediaSourceId', '=', mediaSourceId),
|
inArray(fields.externalKey, allGroupingKeys),
|
||||||
eb(
|
),
|
||||||
'programGroupingExternalId.externalKey',
|
with: {
|
||||||
'in',
|
grouping: true,
|
||||||
allGroupingKeys,
|
},
|
||||||
),
|
}),
|
||||||
]);
|
|
||||||
})
|
|
||||||
.innerJoin(
|
|
||||||
'programGrouping',
|
|
||||||
'programGroupingExternalId.groupUuid',
|
|
||||||
'programGrouping.uuid',
|
|
||||||
)
|
|
||||||
.selectAll()
|
|
||||||
.groupBy([
|
|
||||||
'programGroupingExternalId.externalKey',
|
|
||||||
'programGrouping.uuid',
|
|
||||||
])
|
|
||||||
.execute(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const foundGroupingRatingKeys = map(existingGroupings, 'externalKey');
|
const foundGroupingRatingKeys = map(existingGroupings, 'externalKey');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ExternalId,
|
ExternalId,
|
||||||
FlexProgram,
|
FlexProgram,
|
||||||
Identifier,
|
Identifier,
|
||||||
|
MediaItem,
|
||||||
MediaStream,
|
MediaStream,
|
||||||
MusicAlbumContentProgram,
|
MusicAlbumContentProgram,
|
||||||
MusicArtistContentProgram,
|
MusicArtistContentProgram,
|
||||||
@@ -33,7 +34,9 @@ import { find, first, isNil, omitBy, orderBy } from 'lodash-es';
|
|||||||
import { isPromise } from 'node:util/types';
|
import { isPromise } from 'node:util/types';
|
||||||
import { DeepNullable, DeepPartial, MarkRequired } from 'ts-essentials';
|
import { DeepNullable, DeepPartial, MarkRequired } from 'ts-essentials';
|
||||||
import { match } from 'ts-pattern';
|
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 {
|
import {
|
||||||
LineupItem,
|
LineupItem,
|
||||||
OfflineItem,
|
OfflineItem,
|
||||||
@@ -117,26 +120,6 @@ export class ProgramConverter {
|
|||||||
return null;
|
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(
|
programDaoToTerminalProgram(
|
||||||
program: ProgramWithRelationsOrm,
|
program: ProgramWithRelationsOrm,
|
||||||
): TerminalProgram | null {
|
): TerminalProgram | null {
|
||||||
@@ -155,6 +138,7 @@ export class ProgramConverter {
|
|||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
...program,
|
...program,
|
||||||
|
sortTitle: titleToSortTitle(program.title),
|
||||||
type: program.type,
|
type: program.type,
|
||||||
mediaSourceId: untag(program.mediaSourceId),
|
mediaSourceId: untag(program.mediaSourceId),
|
||||||
canonicalId: program.canonicalId,
|
canonicalId: program.canonicalId,
|
||||||
@@ -234,8 +218,15 @@ export class ProgramConverter {
|
|||||||
],
|
],
|
||||||
['asc', 'asc'],
|
['asc', 'asc'],
|
||||||
),
|
),
|
||||||
locations: [],
|
locations:
|
||||||
};
|
version.mediaFiles?.map(
|
||||||
|
(file) =>
|
||||||
|
({
|
||||||
|
type: 'local',
|
||||||
|
path: file.path,
|
||||||
|
}) satisfies MediaLocation,
|
||||||
|
) ?? [],
|
||||||
|
} satisfies MediaItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
return typed;
|
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: TvShowWithExternalIds): TvShowContentProgram;
|
||||||
programGroupingDaoToDto(
|
programGroupingDaoToDto(
|
||||||
program: TvSeasonWithExternalIds,
|
program: TvSeasonWithExternalIds,
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.j
|
|||||||
import type { NewSingleOrMultiProgramGroupingExternalId } from '@/db/schema/ProgramGroupingExternalId.js';
|
import type { NewSingleOrMultiProgramGroupingExternalId } from '@/db/schema/ProgramGroupingExternalId.js';
|
||||||
import { isNonEmptyString } from '@/util/index.js';
|
import { isNonEmptyString } from '@/util/index.js';
|
||||||
import { seq } from '@tunarr/shared/util';
|
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 {
|
import {
|
||||||
isValidMultiExternalIdType,
|
isValidMultiExternalIdType,
|
||||||
isValidSingleExternalIdType,
|
isValidSingleExternalIdType,
|
||||||
@@ -15,19 +21,14 @@ import { v4 } from 'uuid';
|
|||||||
import {
|
import {
|
||||||
MediaSourceMusicAlbum,
|
MediaSourceMusicAlbum,
|
||||||
MediaSourceMusicArtist,
|
MediaSourceMusicArtist,
|
||||||
MediaSourceSeason,
|
|
||||||
MediaSourceShow,
|
|
||||||
} from '../../types/Media.ts';
|
} from '../../types/Media.ts';
|
||||||
import type { Nullable } from '../../types/util.ts';
|
import type { Nilable, Nullable } from '../../types/util.ts';
|
||||||
import { MediaSourceId, MediaSourceName } from '../schema/base.ts';
|
import { MediaSourceId, MediaSourceName } from '../schema/base.js';
|
||||||
|
import { NewProgramGroupingWithRelations } from '../schema/derivedTypes.js';
|
||||||
import {
|
import {
|
||||||
NewMusicAlbum,
|
MediaSourceLibraryOrm,
|
||||||
NewMusicArtist,
|
MediaSourceOrm,
|
||||||
NewProgramGroupingWithExternalIds,
|
} from '../schema/MediaSource.ts';
|
||||||
NewTvSeason,
|
|
||||||
NewTvShow,
|
|
||||||
} from '../schema/derivedTypes.js';
|
|
||||||
import { MediaSource, MediaSourceLibrary } from '../schema/MediaSource.ts';
|
|
||||||
import {
|
import {
|
||||||
ProgramGroupingType,
|
ProgramGroupingType,
|
||||||
type NewProgramGrouping,
|
type NewProgramGrouping,
|
||||||
@@ -98,7 +99,7 @@ export class ProgramGroupingMinter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.canonicalId || !item.libraryId) {
|
if (!item.canonicalId || !item.libraryId || !item.grandparent.externalKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +121,9 @@ export class ProgramGroupingMinter {
|
|||||||
year: item.grandparent.year,
|
year: item.grandparent.year,
|
||||||
canonicalId: item.canonicalId,
|
canonicalId: item.canonicalId,
|
||||||
libraryId: item.libraryId,
|
libraryId: item.libraryId,
|
||||||
|
sourceType: item.externalSourceType,
|
||||||
|
mediaSourceId: tag(item.externalSourceId),
|
||||||
|
externalKey: item.grandparent.externalKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +134,7 @@ export class ProgramGroupingMinter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.canonicalId || !item.libraryId) {
|
if (!item.canonicalId || !item.libraryId || !item.parent.externalKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,170 +156,153 @@ export class ProgramGroupingMinter {
|
|||||||
year: item.parent.year,
|
year: item.parent.year,
|
||||||
canonicalId: item.canonicalId,
|
canonicalId: item.canonicalId,
|
||||||
libraryId: item.libraryId,
|
libraryId: item.libraryId,
|
||||||
|
sourceType: item.externalSourceType,
|
||||||
|
mediaSourceId: tag(item.externalSourceId),
|
||||||
|
externalKey: item.parent.externalKey,
|
||||||
} satisfies NewProgramGrouping;
|
} satisfies NewProgramGrouping;
|
||||||
}
|
}
|
||||||
|
|
||||||
mintForMediaSourceShow(
|
mintForMediaSourceShow(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
mediaSourceLibrary: MediaSourceLibrary,
|
mediaSourceLibrary: MediaSourceLibraryOrm,
|
||||||
show: MediaSourceShow,
|
show: Show,
|
||||||
): NewTvShow {
|
): NewProgramGroupingWithRelations<'show'> {
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
const groupingId = v4();
|
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 {
|
return {
|
||||||
uuid: groupingId,
|
programGrouping: {
|
||||||
type: ProgramGroupingType.Show,
|
uuid: groupingId,
|
||||||
createdAt: now,
|
type: ProgramGroupingType.Show,
|
||||||
updatedAt: now,
|
createdAt: now,
|
||||||
// index: show.index,
|
updatedAt: now,
|
||||||
title: show.title,
|
// index: show.index,
|
||||||
summary: show.summary,
|
title: show.title,
|
||||||
year: show.year,
|
summary: show.summary ?? show.plot,
|
||||||
libraryId: mediaSourceLibrary.uuid,
|
year: show.year,
|
||||||
canonicalId: show.canonicalId,
|
libraryId: mediaSourceLibrary.uuid,
|
||||||
externalIds,
|
canonicalId: show.canonicalId,
|
||||||
} satisfies NewProgramGroupingWithExternalIds;
|
sourceType: mediaSource.type,
|
||||||
|
mediaSourceId: mediaSource.uuid,
|
||||||
|
externalKey: show.externalId,
|
||||||
|
},
|
||||||
|
externalIds: this.mintExternalIdsFromIdentifiers(
|
||||||
|
mediaSource,
|
||||||
|
groupingId,
|
||||||
|
show.identifiers,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
artwork: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mintForMediaSourceArtist(
|
mintForMediaSourceArtist(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
mediaSourceLibrary: MediaSourceLibrary,
|
mediaSourceLibrary: MediaSourceLibraryOrm,
|
||||||
artist: MediaSourceMusicArtist,
|
artist: MediaSourceMusicArtist,
|
||||||
): NewMusicArtist {
|
): NewProgramGroupingWithRelations<'artist'> {
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
const groupingId = v4();
|
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 {
|
return {
|
||||||
uuid: groupingId,
|
programGrouping: {
|
||||||
type: ProgramGroupingType.Artist,
|
uuid: groupingId,
|
||||||
createdAt: now,
|
type: ProgramGroupingType.Artist,
|
||||||
updatedAt: now,
|
createdAt: now,
|
||||||
// index: show.index,
|
updatedAt: now,
|
||||||
title: artist.title,
|
// index: show.index,
|
||||||
summary: artist.summary,
|
title: artist.title,
|
||||||
year: null,
|
summary: artist.summary,
|
||||||
libraryId: mediaSourceLibrary.uuid,
|
year: null,
|
||||||
canonicalId: artist.canonicalId,
|
libraryId: mediaSourceLibrary.uuid,
|
||||||
externalIds,
|
canonicalId: artist.canonicalId,
|
||||||
} satisfies NewMusicArtist;
|
sourceType: mediaSource.type,
|
||||||
|
mediaSourceId: mediaSource.uuid,
|
||||||
|
externalKey: artist.externalId,
|
||||||
|
},
|
||||||
|
externalIds: this.mintExternalIdsFromIdentifiers(
|
||||||
|
mediaSource,
|
||||||
|
groupingId,
|
||||||
|
artist.identifiers,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
artwork: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mintSeason(
|
mintSeason(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
mediaSourceLibrary: MediaSourceLibrary,
|
mediaSourceLibrary: MediaSourceLibraryOrm,
|
||||||
season: MediaSourceSeason,
|
season: Season,
|
||||||
): NewTvSeason {
|
): NewProgramGroupingWithRelations<'season'> {
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
const groupingId = v4();
|
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 {
|
return {
|
||||||
uuid: groupingId,
|
programGrouping: {
|
||||||
type: ProgramGroupingType.Season,
|
uuid: groupingId,
|
||||||
createdAt: now,
|
type: ProgramGroupingType.Season,
|
||||||
updatedAt: now,
|
createdAt: now,
|
||||||
index: season.index,
|
updatedAt: now,
|
||||||
title: season.title,
|
index: season.index,
|
||||||
summary: season.summary,
|
title: season.title,
|
||||||
libraryId: mediaSourceLibrary.uuid,
|
summary: season.summary,
|
||||||
canonicalId: season.canonicalId,
|
libraryId: mediaSourceLibrary.uuid,
|
||||||
externalIds,
|
canonicalId: season.canonicalId,
|
||||||
} satisfies NewProgramGroupingWithExternalIds;
|
sourceType: mediaSource.type,
|
||||||
|
mediaSourceId: mediaSource.uuid,
|
||||||
|
externalKey: season.externalId,
|
||||||
|
showUuid: season.show?.uuid,
|
||||||
|
},
|
||||||
|
externalIds: this.mintExternalIdsFromIdentifiers(
|
||||||
|
mediaSource,
|
||||||
|
groupingId,
|
||||||
|
season.identifiers,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
artwork: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mintMusicAlbum(
|
mintMusicAlbum(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
mediaSourceLibrary: MediaSourceLibrary,
|
mediaSourceLibrary: MediaSourceLibraryOrm,
|
||||||
album: MediaSourceMusicAlbum,
|
album: MediaSourceMusicAlbum,
|
||||||
): NewMusicAlbum {
|
): NewProgramGroupingWithRelations<'album'> {
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
const groupingId = v4();
|
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)) {
|
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||||
return {
|
return {
|
||||||
type: 'single',
|
type: 'single',
|
||||||
@@ -342,18 +329,5 @@ export class ProgramGroupingMinter {
|
|||||||
|
|
||||||
return;
|
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';
|
} from '@/db/schema/ProgramExternalId.js';
|
||||||
import { seq } from '@tunarr/shared/util';
|
import { seq } from '@tunarr/shared/util';
|
||||||
import {
|
import {
|
||||||
|
Episode,
|
||||||
isTerminalItemType,
|
isTerminalItemType,
|
||||||
ProgramLike,
|
ProgramLike,
|
||||||
tag,
|
tag,
|
||||||
@@ -32,7 +33,6 @@ import { match, P } from 'ts-pattern';
|
|||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import { Canonicalizer } from '../../services/Canonicalizer.ts';
|
import { Canonicalizer } from '../../services/Canonicalizer.ts';
|
||||||
import {
|
import {
|
||||||
MediaSourceEpisode,
|
|
||||||
MediaSourceMovie,
|
MediaSourceMovie,
|
||||||
MediaSourceMusicTrack,
|
MediaSourceMusicTrack,
|
||||||
MediaSourceOtherVideo,
|
MediaSourceOtherVideo,
|
||||||
@@ -43,11 +43,16 @@ import { parsePlexGuid } from '../../util/externalIds.ts';
|
|||||||
import { isNonEmptyString } from '../../util/index.ts';
|
import { isNonEmptyString } from '../../util/index.ts';
|
||||||
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||||
import { booleanToNumber } from '../../util/sqliteUtil.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 type { NewProgramDao } from '../schema/Program.ts';
|
||||||
import { ProgramType } from '../schema/Program.ts';
|
import { ProgramType } from '../schema/Program.ts';
|
||||||
|
import { NewProgramMediaFile } from '../schema/ProgramMediaFile.ts';
|
||||||
import { NewProgramMediaStream } from '../schema/ProgramMediaStream.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 {
|
import {
|
||||||
NewEpisodeProgram,
|
NewEpisodeProgram,
|
||||||
NewMovieProgram,
|
NewMovieProgram,
|
||||||
@@ -58,16 +63,6 @@ import {
|
|||||||
NewProgramWithRelations,
|
NewProgramWithRelations,
|
||||||
} from '../schema/derivedTypes.js';
|
} 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
|
* Generates Program DB entities for Plex media
|
||||||
*/
|
*/
|
||||||
@@ -121,8 +116,8 @@ export class ProgramDaoMinter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mint(
|
mint(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
library: MediaSourceLibrary,
|
library: MediaSourceLibraryOrm,
|
||||||
program: ContentProgramOriginalProgram,
|
program: ContentProgramOriginalProgram,
|
||||||
): NewProgramWithExternalIds {
|
): NewProgramWithExternalIds {
|
||||||
const ret = match(program)
|
const ret = match(program)
|
||||||
@@ -173,20 +168,20 @@ export class ProgramDaoMinter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mintMovie(
|
mintMovie(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
movie: MediaSourceMovie,
|
movie: MediaSourceMovie,
|
||||||
|
now: number = +dayjs(),
|
||||||
): NewProgramWithRelations<'movie'> {
|
): NewProgramWithRelations<'movie'> {
|
||||||
const programId = v4();
|
const programId = v4();
|
||||||
const now = +dayjs();
|
|
||||||
const newMovie = {
|
const newMovie = {
|
||||||
uuid: programId,
|
uuid: programId,
|
||||||
sourceType: movie.sourceType,
|
sourceType: movie.sourceType,
|
||||||
externalKey: movie.externalKey,
|
externalKey: movie.externalId,
|
||||||
originalAirDate: movie.releaseDate
|
originalAirDate: movie.releaseDate
|
||||||
? dayjs(movie.releaseDate)?.format()
|
? dayjs(movie.releaseDate)?.format()
|
||||||
: null,
|
: null,
|
||||||
duration: movie.duration,
|
duration: movie.duration ?? 0,
|
||||||
// filePath: file?.file ?? null,
|
// filePath: file?.file ?? null,
|
||||||
externalSourceId: mediaSource.name,
|
externalSourceId: mediaSource.name,
|
||||||
mediaSourceId: mediaSource.uuid,
|
mediaSourceId: mediaSource.uuid,
|
||||||
@@ -207,6 +202,7 @@ export class ProgramDaoMinter {
|
|||||||
program: newMovie,
|
program: newMovie,
|
||||||
externalIds: this.mintExternalIdsNew(programId, movie, mediaSource, now),
|
externalIds: this.mintExternalIdsNew(programId, movie, mediaSource, now),
|
||||||
versions: this.mintVersions(programId, movie, now),
|
versions: this.mintVersions(programId, movie, now),
|
||||||
|
subtitles: this.mintSubtitles(programId, movie),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,37 +233,48 @@ export class ProgramDaoMinter {
|
|||||||
} satisfies NewProgramMediaStream;
|
} satisfies NewProgramMediaStream;
|
||||||
});
|
});
|
||||||
|
|
||||||
const version: NewProgramVersion = {
|
const files = item.mediaItem.locations.map((loc) => {
|
||||||
uuid: versionId,
|
return {
|
||||||
createdAt: now,
|
path: loc.path,
|
||||||
updatedAt: now,
|
programVersionId: versionId,
|
||||||
programId,
|
uuid: v4(),
|
||||||
displayAspectRatio: item.mediaItem.displayAspectRatio,
|
} satisfies NewProgramMediaFile;
|
||||||
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.
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
return versions;
|
||||||
@@ -276,7 +283,7 @@ export class ProgramDaoMinter {
|
|||||||
mintExternalIdsNew(
|
mintExternalIdsNew(
|
||||||
programId: string,
|
programId: string,
|
||||||
item: ProgramLike,
|
item: ProgramLike,
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
now: number = +dayjs(),
|
now: number = +dayjs(),
|
||||||
) {
|
) {
|
||||||
return seq.collect(item.identifiers, (id) => {
|
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(
|
mintEpisode(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
episode: MediaSourceEpisode,
|
episode: Episode,
|
||||||
|
now: number = +dayjs(),
|
||||||
): NewProgramWithRelations<'episode'> {
|
): NewProgramWithRelations<'episode'> {
|
||||||
const programId = v4();
|
const programId = v4();
|
||||||
const now = +dayjs();
|
|
||||||
|
|
||||||
const newEpisode = {
|
const newEpisode = {
|
||||||
uuid: programId,
|
uuid: programId,
|
||||||
sourceType: episode.sourceType,
|
sourceType: episode.sourceType,
|
||||||
externalKey: episode.externalKey,
|
externalKey: episode.externalId,
|
||||||
originalAirDate: episode.releaseDate
|
originalAirDate: episode.releaseDate
|
||||||
? dayjs(episode.releaseDate).format()
|
? dayjs(episode.releaseDate).format()
|
||||||
: null,
|
: null,
|
||||||
duration: episode.duration,
|
duration: episode.duration ?? 0,
|
||||||
// filePath: file?.file ?? null,
|
// filePath: file?.file ?? null,
|
||||||
externalSourceId: mediaSource.name,
|
externalSourceId: mediaSource.name,
|
||||||
mediaSourceId: mediaSource.uuid,
|
mediaSourceId: mediaSource.uuid,
|
||||||
@@ -365,21 +423,21 @@ export class ProgramDaoMinter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mintMusicTrack(
|
mintMusicTrack(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
track: MediaSourceMusicTrack,
|
track: MediaSourceMusicTrack,
|
||||||
|
now: number = +dayjs(),
|
||||||
): NewProgramWithRelations<'track'> {
|
): NewProgramWithRelations<'track'> {
|
||||||
const programId = v4();
|
const programId = v4();
|
||||||
const now = +dayjs();
|
|
||||||
|
|
||||||
const newTrack = {
|
const newTrack = {
|
||||||
uuid: programId,
|
uuid: programId,
|
||||||
sourceType: track.sourceType,
|
sourceType: track.sourceType,
|
||||||
externalKey: track.externalKey,
|
externalKey: track.externalId,
|
||||||
originalAirDate: track.releaseDate
|
originalAirDate: track.releaseDate
|
||||||
? dayjs(track.releaseDate)?.format()
|
? dayjs(track.releaseDate)?.format()
|
||||||
: null,
|
: null,
|
||||||
duration: track.duration,
|
duration: track.duration ?? 0,
|
||||||
// filePath: file?.file ?? null,
|
// filePath: file?.file ?? null,
|
||||||
externalSourceId: mediaSource.name,
|
externalSourceId: mediaSource.name,
|
||||||
mediaSourceId: mediaSource.uuid,
|
mediaSourceId: mediaSource.uuid,
|
||||||
@@ -404,8 +462,8 @@ export class ProgramDaoMinter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mintOtherVideo(
|
mintOtherVideo(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
video: MediaSourceOtherVideo,
|
video: MediaSourceOtherVideo,
|
||||||
): NewProgramWithRelations<'other_video'> {
|
): NewProgramWithRelations<'other_video'> {
|
||||||
const programId = v4();
|
const programId = v4();
|
||||||
@@ -413,11 +471,11 @@ export class ProgramDaoMinter {
|
|||||||
const newVideo = {
|
const newVideo = {
|
||||||
uuid: programId,
|
uuid: programId,
|
||||||
sourceType: video.sourceType,
|
sourceType: video.sourceType,
|
||||||
externalKey: video.externalKey,
|
externalKey: video.externalId,
|
||||||
originalAirDate: video.releaseDate
|
originalAirDate: video.releaseDate
|
||||||
? dayjs(video.releaseDate)?.format()
|
? dayjs(video.releaseDate)?.format()
|
||||||
: null,
|
: null,
|
||||||
duration: video.duration,
|
duration: video.duration ?? 0,
|
||||||
// filePath: file?.file ?? null,
|
// filePath: file?.file ?? null,
|
||||||
externalSourceId: mediaSource.name,
|
externalSourceId: mediaSource.name,
|
||||||
mediaSourceId: mediaSource.uuid,
|
mediaSourceId: mediaSource.uuid,
|
||||||
@@ -442,8 +500,8 @@ export class ProgramDaoMinter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mintProgramForPlexMovie(
|
private mintProgramForPlexMovie(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
plexMovie: ApiPlexMovie,
|
plexMovie: ApiPlexMovie,
|
||||||
): NewProgramDao {
|
): NewProgramDao {
|
||||||
const file = first(first(plexMovie.Media)?.Part ?? []);
|
const file = first(first(plexMovie.Media)?.Part ?? []);
|
||||||
@@ -471,7 +529,7 @@ export class ProgramDaoMinter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mintProgramForJellyfinItem(
|
private mintProgramForJellyfinItem(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
item: Omit<JellyfinItem, 'Type'> & {
|
item: Omit<JellyfinItem, 'Type'> & {
|
||||||
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
|
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
|
||||||
},
|
},
|
||||||
@@ -523,8 +581,8 @@ export class ProgramDaoMinter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mintProgramForPlexEpisode(
|
private mintProgramForPlexEpisode(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
plexEpisode: PlexEpisode,
|
plexEpisode: PlexEpisode,
|
||||||
): NewProgramDao {
|
): NewProgramDao {
|
||||||
const file = first(first(plexEpisode.Media)?.Part ?? []);
|
const file = first(first(plexEpisode.Media)?.Part ?? []);
|
||||||
@@ -558,8 +616,8 @@ export class ProgramDaoMinter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mintProgramForPlexTrack(
|
private mintProgramForPlexTrack(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceOrm,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
plexTrack: PlexMusicTrack,
|
plexTrack: PlexMusicTrack,
|
||||||
): NewProgramDao {
|
): NewProgramDao {
|
||||||
const file = first(first(plexTrack.Media)?.Part ?? []);
|
const file = first(first(plexTrack.Media)?.Part ?? []);
|
||||||
@@ -608,6 +666,7 @@ export class ProgramDaoMinter {
|
|||||||
.with({ externalSourceType: 'emby' }, () =>
|
.with({ externalSourceType: 'emby' }, () =>
|
||||||
this.mintEmbyExternalIds(serverName, serverId, programId, program),
|
this.mintEmbyExternalIds(serverName, serverId, programId, program),
|
||||||
)
|
)
|
||||||
|
.with({ externalSourceType: 'local' }, () => [])
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,33 +2,27 @@
|
|||||||
// but contain a bit more context and are used during an
|
// but contain a bit more context and are used during an
|
||||||
// active streaming session
|
// active streaming session
|
||||||
|
|
||||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
import type { MarkRequired, StrictOmit } from 'ts-essentials';
|
||||||
import { tag } from '@tunarr/types';
|
|
||||||
import { ContentProgramTypeSchema } from '@tunarr/types/schemas';
|
|
||||||
import type { StrictOmit } from 'ts-essentials';
|
|
||||||
import { z } from 'zod/v4';
|
|
||||||
import type { EmbyT, JellyfinT } from '../../types/internal.ts';
|
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';
|
import type { ProgramType } from '../schema/Program.ts';
|
||||||
|
|
||||||
const baseStreamLineupItemSchema = z.object({
|
type BaseStreamLineupItem = {
|
||||||
streamDuration: z
|
streamDuration: number;
|
||||||
.number()
|
startOffset?: number;
|
||||||
.nonnegative()
|
programBeginMs: number;
|
||||||
.describe('The amount of time left in the stream'),
|
duration: number;
|
||||||
// beginningOffset: z.number().nonnegative().optional(),
|
};
|
||||||
title: z.string().optional(),
|
|
||||||
startOffset: z
|
export type StreamLineupProgram = MarkNotNilable<
|
||||||
.number()
|
MarkRequired<ProgramWithRelationsOrm, 'externalIds'>,
|
||||||
.nonnegative()
|
'mediaSourceId'
|
||||||
.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'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function isOfflineLineupItem(
|
export function isOfflineLineupItem(
|
||||||
item: StreamLineupItem,
|
item: StreamLineupItem,
|
||||||
@@ -59,7 +53,7 @@ export function isPlexBackedLineupItem(
|
|||||||
): item is PlexBackedStreamLineupItem {
|
): item is PlexBackedStreamLineupItem {
|
||||||
return (
|
return (
|
||||||
isContentBackedLineupItem(item) &&
|
isContentBackedLineupItem(item) &&
|
||||||
item.externalSource === MediaSourceType.Plex
|
item.program.sourceType === MediaSourceType.Plex
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +62,7 @@ export function isJellyfinBackedLineupItem(
|
|||||||
): item is SpecificSourceContentBackedStreamLineupItem<JellyfinT> {
|
): item is SpecificSourceContentBackedStreamLineupItem<JellyfinT> {
|
||||||
return (
|
return (
|
||||||
isContentBackedLineupItem(item) &&
|
isContentBackedLineupItem(item) &&
|
||||||
item.externalSource === MediaSourceType.Jellyfin
|
item.program.sourceType === MediaSourceType.Jellyfin
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +71,7 @@ export function isEmnyBackedLineupItem(
|
|||||||
): item is SpecificSourceContentBackedStreamLineupItem<EmbyT> {
|
): item is SpecificSourceContentBackedStreamLineupItem<EmbyT> {
|
||||||
return (
|
return (
|
||||||
isContentBackedLineupItem(item) &&
|
isContentBackedLineupItem(item) &&
|
||||||
item.externalSource === MediaSourceType.Emby
|
item.program.sourceType === MediaSourceType.Emby
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +91,8 @@ export type MinimalContentStreamLineupItem = {
|
|||||||
|
|
||||||
export type SpecificSourceContentBackedStreamLineupItem<
|
export type SpecificSourceContentBackedStreamLineupItem<
|
||||||
Typ extends MediaSourceType,
|
Typ extends MediaSourceType,
|
||||||
> = StrictOmit<ContentBackedStreamLineupItem, 'externalSource'> & {
|
> = StrictOmit<ContentBackedStreamLineupItem, 'program'> & {
|
||||||
externalSource: Typ;
|
program: SpecificProgramSourceOrmType<Typ, StreamLineupProgram>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlexBackedStreamLineupItem =
|
export type PlexBackedStreamLineupItem =
|
||||||
@@ -110,93 +104,47 @@ export type SpecificMinimalContentStreamLineupItem<
|
|||||||
externalSource: Typ;
|
externalSource: Typ;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MinimalPlexBackedStreamLineupItem =
|
export type MinimalPlexBackedStreamLineupItem = SpecificProgramSourceOrmType<
|
||||||
SpecificMinimalContentStreamLineupItem<typeof MediaSourceType.Plex>;
|
typeof MediaSourceType.Plex,
|
||||||
|
StreamLineupProgram
|
||||||
export const OfflineStreamLineupItemSchema = baseStreamLineupItemSchema.extend({
|
|
||||||
type: z.literal('offline'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type OfflineStreamLineupItem = z.infer<
|
|
||||||
typeof OfflineStreamLineupItemSchema
|
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const BaseContentBackedStreamLineupItemSchema =
|
export type OfflineStreamLineupItem = BaseStreamLineupItem & {
|
||||||
baseStreamLineupItemSchema.extend({
|
type: 'offline';
|
||||||
// ID in the program DB table
|
duration: number;
|
||||||
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'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const CommercialStreamLineupItemSchema =
|
type BaseContentBackedStreamLineupItem = BaseStreamLineupItem & {
|
||||||
BaseContentBackedStreamLineupItemSchema.extend({
|
program: StreamLineupProgram;
|
||||||
type: z.literal('commercial'),
|
infiniteLoop: boolean;
|
||||||
fillerId: z.string(),
|
};
|
||||||
});
|
|
||||||
|
|
||||||
export type CommercialStreamLineupItem = z.infer<
|
export type CommercialStreamLineupItem = BaseContentBackedStreamLineupItem & {
|
||||||
typeof CommercialStreamLineupItemSchema
|
type: 'commercial';
|
||||||
>;
|
fillerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
const ProgramStreamLineupItemSchema =
|
export type ProgramStreamLineupItem = BaseContentBackedStreamLineupItem & {
|
||||||
BaseContentBackedStreamLineupItemSchema.extend({
|
type: 'program';
|
||||||
type: z.literal('program'),
|
};
|
||||||
}).required({ title: true });
|
|
||||||
|
|
||||||
export type ProgramStreamLineupItem = z.infer<
|
export type RedirectStreamLineupItem = BaseStreamLineupItem & {
|
||||||
typeof ProgramStreamLineupItemSchema
|
type: 'redirect';
|
||||||
>;
|
channel: string;
|
||||||
|
duration: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const RedirectStreamLineupItemSchema = baseStreamLineupItemSchema.extend(
|
export type ErrorStreamLineupItem = BaseStreamLineupItem & {
|
||||||
{
|
type: 'error';
|
||||||
type: z.literal('redirect'),
|
error: Error | string | boolean;
|
||||||
channel: z.string().uuid(),
|
};
|
||||||
duration: z.number().positive(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ErrorStreamLineupItemSchema = baseStreamLineupItemSchema.extend({
|
export type StreamLineupItem =
|
||||||
type: z.literal('error'),
|
| ProgramStreamLineupItem
|
||||||
error: z.instanceof(Error).or(z.string()).or(z.boolean()),
|
| CommercialStreamLineupItem
|
||||||
});
|
| OfflineStreamLineupItem
|
||||||
|
| RedirectStreamLineupItem
|
||||||
export type RedirectStreamLineupItem = z.infer<
|
| ErrorStreamLineupItem;
|
||||||
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 function createOfflineStreamLineupItem(
|
export function createOfflineStreamLineupItem(
|
||||||
duration: number,
|
duration: number,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Channel } from '@/db/schema/Channel.js';
|
|||||||
import type { ProgramDao } from '@/db/schema/Program.js';
|
import type { ProgramDao } from '@/db/schema/Program.js';
|
||||||
import type { ProgramExternalId } from '@/db/schema/ProgramExternalId.js';
|
import type { ProgramExternalId } from '@/db/schema/ProgramExternalId.js';
|
||||||
import type {
|
import type {
|
||||||
ChannelWithPrograms,
|
ChannelOrmWithRelations,
|
||||||
ChannelWithRelations,
|
ChannelWithRelations,
|
||||||
MusicArtistWithExternalIds,
|
MusicArtistWithExternalIds,
|
||||||
ProgramWithRelations,
|
ProgramWithRelations,
|
||||||
@@ -57,7 +57,7 @@ export interface IChannelDB {
|
|||||||
getChannelAndPrograms(
|
getChannelAndPrograms(
|
||||||
uuid: string,
|
uuid: string,
|
||||||
typeFilter?: ContentProgramType,
|
typeFilter?: ContentProgramType,
|
||||||
): Promise<ChannelWithPrograms | undefined>;
|
): Promise<Maybe<MarkRequired<ChannelOrmWithRelations, 'programs'>>>;
|
||||||
|
|
||||||
getChannelTvShows(
|
getChannelTvShows(
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -14,23 +14,35 @@ import type {
|
|||||||
import type { ProgramExternalIdSourceType } from '@/db/schema/base.js';
|
import type { ProgramExternalIdSourceType } from '@/db/schema/base.js';
|
||||||
import type {
|
import type {
|
||||||
MusicAlbumWithExternalIds,
|
MusicAlbumWithExternalIds,
|
||||||
NewProgramGroupingWithExternalIds,
|
NewProgramGroupingWithRelations,
|
||||||
NewProgramVersion,
|
NewProgramVersion,
|
||||||
|
NewProgramWithRelations,
|
||||||
|
ProgramGroupingOrmWithRelations,
|
||||||
ProgramGroupingWithExternalIds,
|
ProgramGroupingWithExternalIds,
|
||||||
ProgramWithExternalIds,
|
ProgramWithExternalIds,
|
||||||
ProgramWithRelations,
|
ProgramWithRelations,
|
||||||
|
ProgramWithRelationsOrm,
|
||||||
TvSeasonWithExternalIds,
|
TvSeasonWithExternalIds,
|
||||||
} from '@/db/schema/derivedTypes.js';
|
} from '@/db/schema/derivedTypes.js';
|
||||||
import type { MarkNonNullable, Maybe, PagedResult } from '@/types/util.js';
|
import type { MarkNonNullable, Maybe, PagedResult } from '@/types/util.js';
|
||||||
import type { ChannelProgram } from '@tunarr/types';
|
import type { ChannelProgram } from '@tunarr/types';
|
||||||
import type { Dictionary, MarkOptional } from 'ts-essentials';
|
import type {
|
||||||
import type { MediaSourceType } from '../schema/MediaSource.ts';
|
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 { 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';
|
import type { PageParams } from './IChannelDB.ts';
|
||||||
|
|
||||||
export interface IProgramDB {
|
export interface IProgramDB {
|
||||||
getProgramById(id: string): Promise<Maybe<ProgramWithExternalIds>>;
|
// TODO: Allow null narrowing on mediaSourceId
|
||||||
|
getProgramById(
|
||||||
|
id: string,
|
||||||
|
): Promise<Maybe<MarkRequired<ProgramWithRelationsOrm, 'externalIds'>>>;
|
||||||
|
|
||||||
getProgramExternalIds(
|
getProgramExternalIds(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -44,11 +56,16 @@ export interface IProgramDB {
|
|||||||
getProgramsByIds(
|
getProgramsByIds(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
batchSize?: number,
|
batchSize?: number,
|
||||||
|
): Promise<ProgramWithRelationsOrm[]>;
|
||||||
|
|
||||||
|
getProgramsByIdsOld(
|
||||||
|
ids: string[],
|
||||||
|
batchSize?: number,
|
||||||
): Promise<ProgramWithRelations[]>;
|
): Promise<ProgramWithRelations[]>;
|
||||||
|
|
||||||
getProgramGrouping(
|
getProgramGrouping(
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
|
): Promise<Maybe<ProgramGroupingOrmWithRelations>>;
|
||||||
|
|
||||||
getProgramGroupings(
|
getProgramGroupings(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
@@ -89,17 +106,17 @@ export interface IProgramDB {
|
|||||||
sourceType: ProgramSourceType;
|
sourceType: ProgramSourceType;
|
||||||
externalSourceId: string;
|
externalSourceId: string;
|
||||||
externalKey: string;
|
externalKey: string;
|
||||||
}): Promise<Maybe<ProgramWithRelations>>;
|
}): Promise<Maybe<MarkRequired<ProgramWithRelationsOrm, 'externalIds'>>>;
|
||||||
|
|
||||||
lookupByExternalIds(
|
lookupByExternalIds(
|
||||||
ids:
|
ids:
|
||||||
| Set<[string, MediaSourceId, string]>
|
| Set<[string, MediaSourceId, string]>
|
||||||
| Set<readonly [string, MediaSourceId, string]>,
|
| Set<readonly [string, MediaSourceId, string]>,
|
||||||
chunkSize?: number,
|
chunkSize?: number,
|
||||||
): Promise<ProgramWithRelations[]>;
|
): Promise<MarkRequired<ProgramWithRelationsOrm, 'externalIds'>[]>;
|
||||||
|
|
||||||
lookupByMediaSource(
|
lookupByMediaSource(
|
||||||
sourceType: MediaSourceType,
|
sourceType: RemoteMediaSourceType,
|
||||||
sourceId: MediaSourceId,
|
sourceId: MediaSourceId,
|
||||||
mediaType?: ProgramType,
|
mediaType?: ProgramType,
|
||||||
chunkSize?: number,
|
chunkSize?: number,
|
||||||
@@ -136,9 +153,16 @@ export interface IProgramDB {
|
|||||||
): Promise<MarkNonNullable<ProgramDao, 'mediaSourceId'>[]>;
|
): Promise<MarkNonNullable<ProgramDao, 'mediaSourceId'>[]>;
|
||||||
|
|
||||||
upsertPrograms(
|
upsertPrograms(
|
||||||
programs: ProgramUpsertRequest[],
|
program: NewProgramWithRelations,
|
||||||
|
): Promise<ProgramWithExternalIds>;
|
||||||
|
upsertPrograms(
|
||||||
|
programs: NewProgramWithRelations | NewProgramWithRelations[],
|
||||||
programUpsertBatchSize?: number,
|
programUpsertBatchSize?: number,
|
||||||
): Promise<ProgramWithExternalIds[]>;
|
): Promise<ProgramWithExternalIds[]>;
|
||||||
|
upsertPrograms(
|
||||||
|
programs: NewProgramWithRelations | NewProgramWithRelations[],
|
||||||
|
programUpsertBatchSize?: number,
|
||||||
|
): Promise<NewProgramWithRelations | ProgramWithExternalIds[]>;
|
||||||
|
|
||||||
programIdsByExternalIds(
|
programIdsByExternalIds(
|
||||||
ids: Set<[string, string, string]>,
|
ids: Set<[string, string, string]>,
|
||||||
@@ -167,14 +191,19 @@ export interface IProgramDB {
|
|||||||
getProgramGroupingCanonicalIds(
|
getProgramGroupingCanonicalIds(
|
||||||
mediaSourceLibraryId: string,
|
mediaSourceLibraryId: string,
|
||||||
type: ProgramGroupingType,
|
type: ProgramGroupingType,
|
||||||
sourceType: MediaSourceType,
|
sourceType: StrictExclude<MediaSourceType, 'local'>,
|
||||||
): Promise<Dictionary<ProgramGroupingCanonicalIdLookupResult>>;
|
): Promise<Dictionary<ProgramGroupingCanonicalIdLookupResult>>;
|
||||||
|
|
||||||
getOrInsertProgramGrouping(
|
upsertProgramGrouping(
|
||||||
dao: NewProgramGroupingWithExternalIds,
|
newGroupingAndRelations: NewProgramGroupingWithRelations,
|
||||||
externalId: ProgramGroupingExternalIdLookup,
|
externalId: ProgramGroupingExternalIdLookup,
|
||||||
forceUpdate?: boolean,
|
forceUpdate?: boolean,
|
||||||
): Promise<GetOrInsertResult<ProgramGroupingWithExternalIds>>;
|
): Promise<UpsertResult<ProgramGroupingWithExternalIds>>;
|
||||||
|
|
||||||
|
upsertLocalProgramGrouping(
|
||||||
|
newGroupingAndRelations: NewProgramGroupingWithRelations,
|
||||||
|
libraryId: string,
|
||||||
|
): Promise<UpsertResult<ProgramGroupingWithExternalIds>>;
|
||||||
|
|
||||||
getShowSeasons(showUuid: string): Promise<ProgramGroupingWithExternalIds[]>;
|
getShowSeasons(showUuid: string): Promise<ProgramGroupingWithExternalIds[]>;
|
||||||
|
|
||||||
@@ -189,7 +218,7 @@ export interface IProgramDB {
|
|||||||
getProgramGroupingDescendants(
|
getProgramGroupingDescendants(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
groupTypeHint?: ProgramGroupingType,
|
groupTypeHint?: ProgramGroupingType,
|
||||||
): Promise<ProgramWithExternalIds[]>;
|
): Promise<ProgramWithRelationsOrm[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WithChannelIdFilter<T> = T & {
|
export type WithChannelIdFilter<T> = T & {
|
||||||
@@ -215,7 +244,7 @@ export type ProgramGroupingExternalIdLookup = {
|
|||||||
externalSourceId: MediaSourceId;
|
externalSourceId: MediaSourceId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetOrInsertResult<Entity> = {
|
export type UpsertResult<Entity> = {
|
||||||
wasInserted: boolean;
|
wasInserted: boolean;
|
||||||
wasUpdated: boolean;
|
wasUpdated: boolean;
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
@@ -231,4 +260,5 @@ export type ProgramUpsertRequest = {
|
|||||||
program: NewProgramDao;
|
program: NewProgramDao;
|
||||||
externalIds: NewSingleOrMultiExternalId[];
|
externalIds: NewSingleOrMultiExternalId[];
|
||||||
versions: NewProgramVersion[];
|
versions: NewProgramVersion[];
|
||||||
|
artwork?: NewArtwork[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import type {
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
chunk,
|
chunk,
|
||||||
|
differenceWith,
|
||||||
first,
|
first,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
isNil,
|
isNil,
|
||||||
isUndefined,
|
|
||||||
keys,
|
keys,
|
||||||
map,
|
map,
|
||||||
mapValues,
|
mapValues,
|
||||||
@@ -22,20 +22,22 @@ import { v4 } from 'uuid';
|
|||||||
import { type IChannelDB } from '@/db/interfaces/IChannelDB.js';
|
import { type IChannelDB } from '@/db/interfaces/IChannelDB.js';
|
||||||
import { KEYS } from '@/types/inject.js';
|
import { KEYS } from '@/types/inject.js';
|
||||||
import { booleanToNumber } from '@/util/sqliteUtil.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 { inject, injectable, interfaces } from 'inversify';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/sqlite';
|
|
||||||
import { MarkRequired } from 'ts-essentials';
|
import { MarkRequired } from 'ts-essentials';
|
||||||
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.ts';
|
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.ts';
|
||||||
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
|
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
|
||||||
import { withLibraries } from './mediaSourceQueryHelpers.ts';
|
|
||||||
import {
|
import {
|
||||||
withProgramChannels,
|
withProgramChannels,
|
||||||
withProgramCustomShows,
|
withProgramCustomShows,
|
||||||
withProgramFillerShows,
|
withProgramFillerShows,
|
||||||
} from './programQueryHelpers.ts';
|
} 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 { DB } from './schema/db.ts';
|
||||||
import {
|
import {
|
||||||
EmbyMediaSource,
|
EmbyMediaSource,
|
||||||
@@ -43,12 +45,10 @@ import {
|
|||||||
MediaSourceWithLibraries,
|
MediaSourceWithLibraries,
|
||||||
PlexMediaSource,
|
PlexMediaSource,
|
||||||
} from './schema/derivedTypes.js';
|
} from './schema/derivedTypes.js';
|
||||||
|
import { DrizzleDBAccess } from './schema/index.ts';
|
||||||
import {
|
import {
|
||||||
MediaSource,
|
|
||||||
MediaSourceFields,
|
|
||||||
MediaSourceLibrary,
|
|
||||||
MediaSourceLibraryUpdate,
|
MediaSourceLibraryUpdate,
|
||||||
MediaSourceType,
|
MediaSourceOrm,
|
||||||
MediaSourceUpdate,
|
MediaSourceUpdate,
|
||||||
NewMediaSourceLibrary,
|
NewMediaSourceLibrary,
|
||||||
} from './schema/MediaSource.ts';
|
} from './schema/MediaSource.ts';
|
||||||
@@ -76,47 +76,40 @@ export class MediaSourceDB {
|
|||||||
@inject(KEYS.Database) private db: Kysely<DB>,
|
@inject(KEYS.Database) private db: Kysely<DB>,
|
||||||
@inject(KEYS.MediaSourceLibraryRefresher)
|
@inject(KEYS.MediaSourceLibraryRefresher)
|
||||||
private mediaSourceLibraryRefresher: interfaces.AutoFactory<MediaSourceLibraryRefresher>,
|
private mediaSourceLibraryRefresher: interfaces.AutoFactory<MediaSourceLibraryRefresher>,
|
||||||
|
@inject(KEYS.DrizzleDB)
|
||||||
|
private drizzleDB: DrizzleDBAccess,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAll(): Promise<MediaSourceWithLibraries[]> {
|
async getAll(): Promise<MediaSourceWithLibraries[]> {
|
||||||
return this.db
|
return this.drizzleDB.query.mediaSource.findMany({
|
||||||
.selectFrom('mediaSource')
|
with: {
|
||||||
.select(withLibraries)
|
libraries: true,
|
||||||
.selectAll()
|
paths: true,
|
||||||
.execute();
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: MediaSourceId): Promise<Maybe<MediaSourceWithLibraries>> {
|
async getById(id: MediaSourceId): Promise<Maybe<MediaSourceWithLibraries>> {
|
||||||
return this.db
|
return this.drizzleDB.query.mediaSource.findFirst({
|
||||||
.selectFrom('mediaSource')
|
where: (ms, { eq }) => eq(ms.uuid, id),
|
||||||
.select(withLibraries)
|
with: {
|
||||||
.selectAll()
|
libraries: true,
|
||||||
.where('mediaSource.uuid', '=', id)
|
paths: true,
|
||||||
.executeTakeFirst();
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLibrary(id: string) {
|
async getLibrary(id: string) {
|
||||||
return (
|
return this.drizzleDB.query.mediaSourceLibrary.findFirst({
|
||||||
this.db
|
where: (lib, { eq }) => eq(lib.uuid, id),
|
||||||
.selectFrom('mediaSourceLibrary')
|
with: {
|
||||||
.where('uuid', '=', id)
|
mediaSource: {
|
||||||
.select((eb) =>
|
with: {
|
||||||
jsonObjectFrom(
|
paths: true,
|
||||||
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()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByType(
|
async findByType(
|
||||||
@@ -140,15 +133,19 @@ export class MediaSourceDB {
|
|||||||
type: MediaSourceType,
|
type: MediaSourceType,
|
||||||
nameOrId?: MediaSourceId,
|
nameOrId?: MediaSourceId,
|
||||||
): Promise<MediaSourceWithLibraries[] | Maybe<MediaSourceWithLibraries>> {
|
): Promise<MediaSourceWithLibraries[] | Maybe<MediaSourceWithLibraries>> {
|
||||||
const found = await this.db
|
const found = await this.drizzleDB.query.mediaSource.findMany({
|
||||||
.selectFrom('mediaSource')
|
where: (ms, { eq, and }) => {
|
||||||
.selectAll()
|
if (isNonEmptyString(nameOrId)) {
|
||||||
.select(withLibraries)
|
return and(eq(ms.type, type), eq(ms.uuid, nameOrId));
|
||||||
.where('mediaSource.type', '=', type)
|
} else {
|
||||||
.$if(isNonEmptyString(nameOrId), (qb) =>
|
return eq(ms.type, type);
|
||||||
qb.where('mediaSource.uuid', '=', retag<MediaSourceId>(nameOrId!)),
|
}
|
||||||
)
|
},
|
||||||
.execute();
|
with: {
|
||||||
|
libraries: true,
|
||||||
|
paths: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (isNonEmptyString(nameOrId)) {
|
if (isNonEmptyString(nameOrId)) {
|
||||||
return first(found);
|
return first(found);
|
||||||
@@ -189,39 +186,96 @@ export class MediaSourceDB {
|
|||||||
return { deletedServer };
|
return { deletedServer };
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMediaSource(server: UpdateMediaSourceRequest) {
|
async updateMediaSource(updateReq: UpdateMediaSourceRequest) {
|
||||||
const id = server.id;
|
const id = tag<MediaSourceId>(updateReq.id);
|
||||||
|
|
||||||
const mediaSource = await this.getById(tag(id));
|
const mediaSource = await this.getById(id);
|
||||||
|
|
||||||
if (isNil(mediaSource)) {
|
if (isNil(mediaSource)) {
|
||||||
throw new Error("Server doesn't exist.");
|
throw new Error("Server doesn't exist.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendGuideUpdates =
|
if (updateReq.type === 'local') {
|
||||||
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
|
await this.db.transaction().execute(async (tx) => {
|
||||||
const sendChannelUpdates =
|
await tx
|
||||||
server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
|
.updateTable('mediaSource')
|
||||||
|
.set({
|
||||||
|
mediaType: updateReq.mediaType,
|
||||||
|
name: tag<MediaSourceName>(updateReq.name),
|
||||||
|
})
|
||||||
|
.where('mediaSource.uuid', '=', id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
await this.db
|
const newPaths = differenceWith(
|
||||||
.updateTable('mediaSource')
|
updateReq.paths,
|
||||||
.set({
|
mediaSource.libraries,
|
||||||
name: tag<MediaSourceName>(server.name),
|
(incomingPath, { externalKey }) => incomingPath === externalKey,
|
||||||
uri: trimEnd(server.uri, '/'),
|
);
|
||||||
accessToken: server.accessToken,
|
const deletePaths = differenceWith(
|
||||||
sendGuideUpdates: booleanToNumber(sendGuideUpdates),
|
mediaSource.libraries,
|
||||||
sendChannelUpdates: booleanToNumber(sendChannelUpdates),
|
updateReq.paths,
|
||||||
updatedAt: +dayjs(),
|
({ externalKey }, incomingPath) => externalKey === incomingPath,
|
||||||
// This allows clearing the values
|
).map(({ externalKey }) => externalKey);
|
||||||
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();
|
|
||||||
|
|
||||||
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(
|
const report = await this.fixupProgramReferences(
|
||||||
tag(id),
|
tag(id),
|
||||||
@@ -251,13 +305,18 @@ export class MediaSourceDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
|
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
|
||||||
const name = tag<MediaSourceName>(
|
const name = tag<MediaSourceName>(server.name);
|
||||||
isUndefined(server.name) ? 'plex' : server.name,
|
|
||||||
);
|
|
||||||
const sendGuideUpdates =
|
const sendGuideUpdates =
|
||||||
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
|
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
|
||||||
const sendChannelUpdates =
|
const sendChannelUpdates =
|
||||||
server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
|
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
|
const index = await this.db
|
||||||
.selectFrom('mediaSource')
|
.selectFrom('mediaSource')
|
||||||
.select((eb) => eb.fn.count<number>('uuid').as('count'))
|
.select((eb) => eb.fn.count<number>('uuid').as('count'))
|
||||||
@@ -265,24 +324,60 @@ export class MediaSourceDB {
|
|||||||
.then((_) => _?.count ?? 0);
|
.then((_) => _?.count ?? 0);
|
||||||
|
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
const newServer = await this.db
|
const newServer = await this.db.transaction().execute(async (tx) => {
|
||||||
.insertInto('mediaSource')
|
const newServer = await tx
|
||||||
.values({
|
.insertInto('mediaSource')
|
||||||
...server,
|
.values({
|
||||||
uuid: tag<MediaSourceId>(v4()),
|
// ...server,
|
||||||
name,
|
uuid: tag<MediaSourceId>(v4()),
|
||||||
uri: trimEnd(server.uri, '/'),
|
name,
|
||||||
sendChannelUpdates: sendChannelUpdates ? 1 : 0,
|
uri: server.type === 'local' ? '' : trimEnd(server.uri, '/'),
|
||||||
sendGuideUpdates: sendGuideUpdates ? 1 : 0,
|
sendChannelUpdates: sendChannelUpdates ? 1 : 0,
|
||||||
createdAt: now,
|
sendGuideUpdates: sendGuideUpdates ? 1 : 0,
|
||||||
updatedAt: now,
|
createdAt: now,
|
||||||
index,
|
updatedAt: now,
|
||||||
type: server.type,
|
index,
|
||||||
userId: isNonEmptyString(server.userId) ? server.userId : null,
|
type: server.type,
|
||||||
username: isNonEmptyString(server.username) ? server.username : null,
|
userId:
|
||||||
})
|
server.type === 'local'
|
||||||
.returning('uuid')
|
? null
|
||||||
.executeTakeFirstOrThrow();
|
: 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);
|
await this.mediaSourceLibraryRefresher().refreshMediaSource(newServer.uuid);
|
||||||
|
|
||||||
@@ -299,13 +394,6 @@ export class MediaSourceDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (updates.updatedLibraries.length > 0) {
|
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) {
|
for (const update of updates.updatedLibraries) {
|
||||||
await tx
|
await tx
|
||||||
.updateTable('mediaSourceLibrary')
|
.updateTable('mediaSourceLibrary')
|
||||||
@@ -318,11 +406,7 @@ export class MediaSourceDB {
|
|||||||
if (updates.deletedLibraries.length > 0) {
|
if (updates.deletedLibraries.length > 0) {
|
||||||
await tx
|
await tx
|
||||||
.deleteFrom('mediaSourceLibrary')
|
.deleteFrom('mediaSourceLibrary')
|
||||||
.where(
|
.where('uuid', 'in', updates.deletedLibraries)
|
||||||
'uuid',
|
|
||||||
'in',
|
|
||||||
updates.deletedLibraries.map((lib) => lib.uuid),
|
|
||||||
)
|
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -357,7 +441,7 @@ export class MediaSourceDB {
|
|||||||
private async fixupProgramReferences(
|
private async fixupProgramReferences(
|
||||||
serverId: MediaSourceId,
|
serverId: MediaSourceId,
|
||||||
serverType: MediaSourceType,
|
serverType: MediaSourceType,
|
||||||
newServer?: MediaSource,
|
newServer?: MediaSourceOrm,
|
||||||
) {
|
) {
|
||||||
// TODO: We need to update this to:
|
// TODO: We need to update this to:
|
||||||
// 1. handle different source types
|
// 1. handle different source types
|
||||||
@@ -479,5 +563,5 @@ export class MediaSourceDB {
|
|||||||
export type MediaSourceLibrariesUpdate = {
|
export type MediaSourceLibrariesUpdate = {
|
||||||
addedLibraries: NewMediaSourceLibrary[];
|
addedLibraries: NewMediaSourceLibrary[];
|
||||||
updatedLibraries: MarkRequired<MediaSourceLibraryUpdate, 'uuid'>[];
|
updatedLibraries: MarkRequired<MediaSourceLibraryUpdate, 'uuid'>[];
|
||||||
deletedLibraries: MediaSourceLibrary[];
|
deletedLibraries: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,3 +11,17 @@ export function withLibraries(eb: ExpressionBuilder<DB, 'mediaSource'>) {
|
|||||||
.select(MediaSourceLibraryColumns),
|
.select(MediaSourceLibraryColumns),
|
||||||
).as('libraries');
|
).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) &&
|
isContentProgram(p) &&
|
||||||
isNonEmptyString(p.externalSourceId) &&
|
isNonEmptyString(p.externalSourceId) &&
|
||||||
isNonEmptyString(p.externalSourceType) &&
|
isNonEmptyString(p.externalSourceType) &&
|
||||||
isNonEmptyString(p.externalKey)
|
isNonEmptyString(p.externalKey) &&
|
||||||
|
p.externalSourceType !== 'local'
|
||||||
) {
|
) {
|
||||||
acc[
|
acc[
|
||||||
createExternalId(
|
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 TupleToUnion } from '@tunarr/types';
|
||||||
import type {
|
import type {
|
||||||
CaseWhenBuilder,
|
CaseWhenBuilder,
|
||||||
@@ -9,7 +10,15 @@ import type {
|
|||||||
UpdateResult,
|
UpdateResult,
|
||||||
} from 'kysely';
|
} from 'kysely';
|
||||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite';
|
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 { DeepPartial, DeepRequired, StrictExclude } from 'ts-essentials';
|
||||||
import type { Replace } from '../types/util.ts';
|
import type { Replace } from '../types/util.ts';
|
||||||
import type { FillerShowTable as RawFillerShow } from './schema/FillerShow.js';
|
import type { FillerShowTable as RawFillerShow } from './schema/FillerShow.js';
|
||||||
@@ -20,9 +29,12 @@ import type {
|
|||||||
import { ProgramType } from './schema/Program.ts';
|
import { ProgramType } from './schema/Program.ts';
|
||||||
import type { ProgramExternalId } from './schema/ProgramExternalId.ts';
|
import type { ProgramExternalId } from './schema/ProgramExternalId.ts';
|
||||||
import { ProgramExternalIdFieldsWithAlias } 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 {
|
import {
|
||||||
AllProgramGroupingFields,
|
|
||||||
AllProgramGroupingFieldsAliased,
|
AllProgramGroupingFieldsAliased,
|
||||||
ProgramGroupingType,
|
ProgramGroupingType,
|
||||||
} from './schema/ProgramGrouping.ts';
|
} from './schema/ProgramGrouping.ts';
|
||||||
@@ -308,6 +320,8 @@ export const AllProgramFields = [
|
|||||||
'program.sourceType',
|
'program.sourceType',
|
||||||
'program.tvShowUuid',
|
'program.tvShowUuid',
|
||||||
'program.mediaSourceId',
|
'program.mediaSourceId',
|
||||||
|
'program.localMediaFolderId',
|
||||||
|
'program.localMediaSourcePathId',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type ProgramUpsertFields = StrictExclude<
|
type ProgramUpsertFields = StrictExclude<
|
||||||
@@ -318,11 +332,10 @@ type ProgramUpsertFields = StrictExclude<
|
|||||||
const ProgramUpsertIgnoreFields = [
|
const ProgramUpsertIgnoreFields = [
|
||||||
'program.uuid',
|
'program.uuid',
|
||||||
'program.createdAt',
|
'program.createdAt',
|
||||||
'program.tvShowUuid',
|
// 'program.tvShowUuid',
|
||||||
'program.albumUuid',
|
// 'program.albumUuid',
|
||||||
'program.artistUuid',
|
// 'program.artistUuid',
|
||||||
'program.seasonUuid',
|
// 'program.seasonUuid',
|
||||||
// 'program.libraryId',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type KnownProgramUpsertFields = StrictExclude<
|
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 = {
|
export type WithProgramsOptions = {
|
||||||
joins?: Partial<ProgramJoins>;
|
joins?: Partial<ProgramJoins>;
|
||||||
fields?: ProgramFields;
|
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 {
|
import {
|
||||||
getTableConfig,
|
getTableConfig,
|
||||||
integer,
|
integer,
|
||||||
primaryKey,
|
|
||||||
sqliteTable,
|
sqliteTable,
|
||||||
text,
|
text,
|
||||||
} from 'drizzle-orm/sqlite-core';
|
} from 'drizzle-orm/sqlite-core';
|
||||||
@@ -13,10 +14,10 @@ import {
|
|||||||
type ChannelTranscodingSettings,
|
type ChannelTranscodingSettings,
|
||||||
type ChannelWatermark,
|
type ChannelWatermark,
|
||||||
} from './base.ts';
|
} from './base.ts';
|
||||||
import { CustomShow } from './CustomShow.ts';
|
import { ChannelCustomShow } from './ChannelCustomShow.ts';
|
||||||
import { FillerShow } from './FillerShow.ts';
|
import { ChannelFillerShow } from './ChannelFillerShow.ts';
|
||||||
|
import { ChannelPrograms } from './ChannelPrograms.ts';
|
||||||
import type { KyselifyBetter } from './KyselifyBetter.ts';
|
import type { KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
import { Program } from './Program.ts';
|
|
||||||
|
|
||||||
export const Channel = sqliteTable('channel', {
|
export const Channel = sqliteTable('channel', {
|
||||||
uuid: text().primaryKey(),
|
uuid: text().primaryKey(),
|
||||||
@@ -57,75 +58,10 @@ export const AllChannelTableKeys: ChannelFields = ChannelTableKeys.map(
|
|||||||
export type Channel = Selectable<ChannelTable>;
|
export type Channel = Selectable<ChannelTable>;
|
||||||
export type NewChannel = Insertable<ChannelTable>;
|
export type NewChannel = Insertable<ChannelTable>;
|
||||||
export type ChannelUpdate = Updateable<ChannelTable>;
|
export type ChannelUpdate = Updateable<ChannelTable>;
|
||||||
|
export type ChannelOrm = InferSelectModel<typeof Channel>;
|
||||||
|
|
||||||
export const ChannelFillerShow = sqliteTable(
|
export const ChannelRelations = relations(Channel, ({ many }) => ({
|
||||||
'channel_filler_show',
|
channelPrograms: many(ChannelPrograms),
|
||||||
{
|
channelCustomShows: many(ChannelCustomShow),
|
||||||
channelUuid: text()
|
channelFillerShow: many(ChannelFillerShow),
|
||||||
.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>;
|
|
||||||
|
|||||||
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 {
|
import { relations } from 'drizzle-orm';
|
||||||
integer,
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
primaryKey,
|
|
||||||
sqliteTable,
|
|
||||||
text,
|
|
||||||
} from 'drizzle-orm/sqlite-core';
|
|
||||||
import type { Insertable, Selectable } from 'kysely';
|
import type { Insertable, Selectable } from 'kysely';
|
||||||
|
import { ChannelCustomShow } from './ChannelCustomShow.ts';
|
||||||
|
import { CustomShowContent } from './CustomShowContent.ts';
|
||||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
|
|
||||||
export const CustomShow = sqliteTable('custom_show', {
|
export const CustomShow = sqliteTable('custom_show', {
|
||||||
@@ -15,24 +13,10 @@ export const CustomShow = sqliteTable('custom_show', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type CustomShowTable = KyselifyBetter<typeof CustomShow>;
|
export type CustomShowTable = KyselifyBetter<typeof CustomShow>;
|
||||||
|
|
||||||
export type CustomShow = Selectable<CustomShowTable>;
|
export type CustomShow = Selectable<CustomShowTable>;
|
||||||
export type NewCustomShow = Insertable<CustomShowTable>;
|
export type NewCustomShow = Insertable<CustomShowTable>;
|
||||||
|
|
||||||
export const CustomShowContent = sqliteTable(
|
export const CustomShowRelations = relations(CustomShow, ({ many }) => ({
|
||||||
'custom_show_content',
|
channelCustomShows: many(ChannelCustomShow),
|
||||||
{
|
content: many(CustomShowContent),
|
||||||
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>;
|
|
||||||
|
|||||||
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 {
|
import { relations } from 'drizzle-orm';
|
||||||
integer,
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
primaryKey,
|
|
||||||
sqliteTable,
|
|
||||||
text,
|
|
||||||
} from 'drizzle-orm/sqlite-core';
|
|
||||||
import type { Insertable, Selectable } from 'kysely';
|
import type { Insertable, Selectable } from 'kysely';
|
||||||
|
import { FillerShowContent } from './FillerShowContent.ts';
|
||||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
import { Program } from './Program.ts';
|
|
||||||
|
|
||||||
export const FillerShow = sqliteTable('filler_show', {
|
export const FillerShow = sqliteTable('filler_show', {
|
||||||
uuid: text().primaryKey(),
|
uuid: text().primaryKey(),
|
||||||
@@ -16,26 +12,9 @@ export const FillerShow = sqliteTable('filler_show', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type FillerShowTable = KyselifyBetter<typeof FillerShow>;
|
export type FillerShowTable = KyselifyBetter<typeof FillerShow>;
|
||||||
|
|
||||||
export type FillerShow = Selectable<FillerShowTable>;
|
export type FillerShow = Selectable<FillerShowTable>;
|
||||||
export type NewFillerShow = Insertable<FillerShowTable>;
|
export type NewFillerShow = Insertable<FillerShowTable>;
|
||||||
|
|
||||||
export const FillerShowContent = sqliteTable(
|
export const FillerShowRelations = relations(FillerShow, ({ many }) => ({
|
||||||
'filler_show_content',
|
fillerShowContent: many(FillerShowContent),
|
||||||
{
|
}));
|
||||||
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>;
|
|
||||||
|
|||||||
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 { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
import type { Updateable } from 'kysely';
|
import type { Updateable } from 'kysely';
|
||||||
import { type Insertable, type Selectable } from 'kysely';
|
import { type Insertable, type Selectable } from 'kysely';
|
||||||
import type { MediaSourceName } from './base.ts';
|
import type { StrictExclude } from 'ts-essentials';
|
||||||
import { type MediaSourceId } from './base.ts';
|
import type { MediaSourceName, MediaSourceType } from './base.ts';
|
||||||
|
import {
|
||||||
|
MediaLibraryTypes,
|
||||||
|
MediaSourceTypes,
|
||||||
|
type MediaSourceId,
|
||||||
|
} from './base.ts';
|
||||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
|
import { LocalMediaSourcePath } from './LocalMediaSourcePath.ts';
|
||||||
import { Program } from './Program.ts';
|
import { Program } from './Program.ts';
|
||||||
|
|
||||||
export const MediaSourceTypes = ['plex', 'jellyfin', 'emby'] as const;
|
export type RemoteMediaSourceType = StrictExclude<MediaSourceType, 'local'>;
|
||||||
|
|
||||||
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 const MediaSource = sqliteTable(
|
export const MediaSource = sqliteTable(
|
||||||
'media_source',
|
'media_source',
|
||||||
@@ -39,6 +33,7 @@ export const MediaSource = sqliteTable(
|
|||||||
uri: text().notNull(),
|
uri: text().notNull(),
|
||||||
username: text(),
|
username: text(),
|
||||||
userId: text(),
|
userId: text(),
|
||||||
|
mediaType: text({ enum: MediaLibraryTypes }), // Only present for local media sources
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
check(
|
check(
|
||||||
@@ -51,6 +46,7 @@ export const MediaSource = sqliteTable(
|
|||||||
export const MediaSourceRelations = relations(MediaSource, ({ many }) => ({
|
export const MediaSourceRelations = relations(MediaSource, ({ many }) => ({
|
||||||
libraries: many(MediaSourceLibrary),
|
libraries: many(MediaSourceLibrary),
|
||||||
programs: many(Program),
|
programs: many(Program),
|
||||||
|
paths: many(LocalMediaSourcePath),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const MediaSourceFields: (keyof MediaSourceTable)[] = [
|
export const MediaSourceFields: (keyof MediaSourceTable)[] = [
|
||||||
@@ -71,17 +67,10 @@ export const MediaSourceFields: (keyof MediaSourceTable)[] = [
|
|||||||
|
|
||||||
export type MediaSourceTable = KyselifyBetter<typeof MediaSource>;
|
export type MediaSourceTable = KyselifyBetter<typeof MediaSource>;
|
||||||
export type MediaSource = Selectable<MediaSourceTable>;
|
export type MediaSource = Selectable<MediaSourceTable>;
|
||||||
|
export type MediaSourceOrm = InferSelectModel<typeof MediaSource>;
|
||||||
export type NewMediaSource = Insertable<MediaSourceTable>;
|
export type NewMediaSource = Insertable<MediaSourceTable>;
|
||||||
export type MediaSourceUpdate = Updateable<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 type MediaLibraryType = TupleToUnion<typeof MediaLibraryTypes>;
|
||||||
|
|
||||||
export const MediaSourceLibrary = sqliteTable(
|
export const MediaSourceLibrary = sqliteTable(
|
||||||
@@ -110,7 +99,7 @@ export const MediaSourceLibraryRelations = relations(
|
|||||||
MediaSourceLibrary,
|
MediaSourceLibrary,
|
||||||
({ one, many }) => ({
|
({ one, many }) => ({
|
||||||
programs: many(Program),
|
programs: many(Program),
|
||||||
one: one(MediaSource, {
|
mediaSource: one(MediaSource, {
|
||||||
fields: [MediaSourceLibrary.mediaSourceId],
|
fields: [MediaSourceLibrary.mediaSourceId],
|
||||||
references: [MediaSource.uuid],
|
references: [MediaSource.uuid],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ import {
|
|||||||
} from 'drizzle-orm/sqlite-core';
|
} from 'drizzle-orm/sqlite-core';
|
||||||
import type { Insertable, Selectable, Updateable } from 'kysely';
|
import type { Insertable, Selectable, Updateable } from 'kysely';
|
||||||
import type { MarkNotNilable } from '../../types/util.ts';
|
import type { MarkNotNilable } from '../../types/util.ts';
|
||||||
|
import { Artwork } from './Artwork.ts';
|
||||||
import type { MediaSourceName } from './base.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 { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
import {
|
import { LocalMediaFolder } from './LocalMediaFolder.ts';
|
||||||
MediaSource,
|
import { LocalMediaSourcePath } from './LocalMediaSourcePath.ts';
|
||||||
MediaSourceLibrary,
|
import { MediaSource, MediaSourceLibrary } from './MediaSource.ts';
|
||||||
MediaSourceTypes,
|
|
||||||
} from './MediaSource.ts';
|
|
||||||
import { ProgramExternalId } from './ProgramExternalId.ts';
|
import { ProgramExternalId } from './ProgramExternalId.ts';
|
||||||
import { ProgramGrouping } from './ProgramGrouping.ts';
|
import { ProgramGrouping } from './ProgramGrouping.ts';
|
||||||
|
import { ProgramSubtitles } from './ProgramSubtitles.ts';
|
||||||
import { ProgramVersion } from './ProgramVersion.ts';
|
import { ProgramVersion } from './ProgramVersion.ts';
|
||||||
|
|
||||||
export const ProgramTypes = [
|
export const ProgramTypes = [
|
||||||
@@ -61,6 +61,8 @@ export const Program = sqliteTable(
|
|||||||
})
|
})
|
||||||
.$type<MediaSourceId>(),
|
.$type<MediaSourceId>(),
|
||||||
libraryId: text().references(() => MediaSourceLibrary.uuid),
|
libraryId: text().references(() => MediaSourceLibrary.uuid),
|
||||||
|
localMediaFolderId: text().references(() => LocalMediaFolder.uuid),
|
||||||
|
localMediaSourcePathId: text().references(() => LocalMediaSourcePath.uuid),
|
||||||
filePath: text(),
|
filePath: text(),
|
||||||
grandparentExternalKey: text(),
|
grandparentExternalKey: text(),
|
||||||
icon: text(),
|
icon: text(),
|
||||||
@@ -137,6 +139,16 @@ export const ProgramRelations = relations(Program, ({ many, one }) => ({
|
|||||||
references: [MediaSourceLibrary.uuid],
|
references: [MediaSourceLibrary.uuid],
|
||||||
}),
|
}),
|
||||||
externalIds: many(ProgramExternalId),
|
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>;
|
export type ProgramTable = KyselifyBetter<typeof Program>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { TupleToUnion } from '@tunarr/types';
|
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 { relations } from 'drizzle-orm';
|
||||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
import type { Insertable, Selectable } from 'kysely';
|
import type { Insertable, Selectable } from 'kysely';
|
||||||
@@ -33,4 +33,5 @@ export const ProgramChapterRelations = relations(ProgramChapter, ({ one }) => ({
|
|||||||
export type ProgramChapterTable = KyselifyBetter<typeof ProgramChapter>;
|
export type ProgramChapterTable = KyselifyBetter<typeof ProgramChapter>;
|
||||||
export type ProgramChapter = Selectable<ProgramChapterTable>;
|
export type ProgramChapter = Selectable<ProgramChapterTable>;
|
||||||
export type ProgramChapterOrm = InferSelectModel<typeof ProgramChapter>;
|
export type ProgramChapterOrm = InferSelectModel<typeof ProgramChapter>;
|
||||||
|
export type NewProgramChapterOrm = InferInsertModel<typeof ProgramChapter>;
|
||||||
export type NewProgramChapter = Insertable<ProgramChapterTable>;
|
export type NewProgramChapter = Insertable<ProgramChapterTable>;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type TupleToUnion } from '@tunarr/types';
|
import { type TupleToUnion } from '@tunarr/types';
|
||||||
|
import type { InferSelectModel } from 'drizzle-orm';
|
||||||
import { inArray, relations } from 'drizzle-orm';
|
import { inArray, relations } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
type AnySQLiteColumn,
|
type AnySQLiteColumn,
|
||||||
@@ -10,10 +11,14 @@ import {
|
|||||||
} from 'drizzle-orm/sqlite-core';
|
} from 'drizzle-orm/sqlite-core';
|
||||||
import type { Insertable, Selectable, Updateable } from 'kysely';
|
import type { Insertable, Selectable, Updateable } from 'kysely';
|
||||||
import type { MarkRequiredNotNull } from '../../types/util.ts';
|
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 { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
import { MediaSourceLibrary } from './MediaSource.ts';
|
import { MediaSource, MediaSourceLibrary } from './MediaSource.ts';
|
||||||
import { Program } from './Program.ts';
|
import { Program } from './Program.ts';
|
||||||
import type { ProgramGroupingTable as RawProgramGrouping } from './ProgramGrouping.ts';
|
import type { ProgramGroupingTable as RawProgramGrouping } from './ProgramGrouping.ts';
|
||||||
|
import { ProgramGroupingExternalId } from './ProgramGroupingExternalId.ts';
|
||||||
|
|
||||||
export const ProgramGroupingType = {
|
export const ProgramGroupingType = {
|
||||||
Show: 'show',
|
Show: 'show',
|
||||||
@@ -47,6 +52,13 @@ export const ProgramGrouping = sqliteTable(
|
|||||||
artistUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
artistUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
||||||
showUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
showUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
||||||
libraryId: text().references(() => MediaSourceLibrary.uuid),
|
libraryId: text().references(() => MediaSourceLibrary.uuid),
|
||||||
|
sourceType: text({ enum: MediaSourceTypes }),
|
||||||
|
externalKey: text(),
|
||||||
|
mediaSourceId: text()
|
||||||
|
.references(() => MediaSource.uuid, {
|
||||||
|
onDelete: 'cascade',
|
||||||
|
})
|
||||||
|
.$type<MediaSourceId>(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('program_grouping_show_uuid_index').on(table.showUuid),
|
index('program_grouping_show_uuid_index').on(table.showUuid),
|
||||||
@@ -72,6 +84,20 @@ export const ProgramGroupingRelations = relations(
|
|||||||
relationName: 'show',
|
relationName: 'show',
|
||||||
}),
|
}),
|
||||||
children: many(Program),
|
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 ProgramGrouping = Selectable<ProgramGroupingTable>;
|
||||||
export type NewProgramGrouping = MarkRequiredNotNull<
|
export type NewProgramGrouping = MarkRequiredNotNull<
|
||||||
Insertable<ProgramGroupingTable>,
|
Insertable<ProgramGroupingTable>,
|
||||||
'canonicalId' | 'libraryId'
|
'canonicalId' | 'libraryId' | 'mediaSourceId' | 'sourceType' | 'externalKey'
|
||||||
>;
|
>;
|
||||||
export type ProgramGroupingUpdate = Updateable<ProgramGroupingTable>;
|
export type ProgramGroupingUpdate = Updateable<ProgramGroupingTable>;
|
||||||
|
export type ProgramGroupingOrm = InferSelectModel<typeof ProgramGrouping>;
|
||||||
|
|
||||||
const ProgramGroupingKeys: (keyof RawProgramGrouping)[] = [
|
const ProgramGroupingKeys: (keyof RawProgramGrouping)[] = [
|
||||||
'artistUuid',
|
'artistUuid',
|
||||||
@@ -96,10 +123,8 @@ const ProgramGroupingKeys: (keyof RawProgramGrouping)[] = [
|
|||||||
'uuid',
|
'uuid',
|
||||||
'year',
|
'year',
|
||||||
];
|
];
|
||||||
// TODO move this definition to the ProgramGrouping DAO file
|
|
||||||
|
|
||||||
export const AllProgramGroupingFields: ProgramGroupingFields =
|
// TODO move this definition to the ProgramGrouping DAO file
|
||||||
ProgramGroupingKeys.map((key) => `programGrouping.${key}` as const);
|
|
||||||
|
|
||||||
export const AllProgramGroupingFieldsAliased = <Alias extends string>(
|
export const AllProgramGroupingFieldsAliased = <Alias extends string>(
|
||||||
alias: Alias,
|
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 {
|
import {
|
||||||
check,
|
check,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
sqliteTable,
|
sqliteTable,
|
||||||
text,
|
text,
|
||||||
|
uniqueIndex,
|
||||||
} from 'drizzle-orm/sqlite-core';
|
} from 'drizzle-orm/sqlite-core';
|
||||||
import type { Insertable, Selectable } from 'kysely';
|
import type { Insertable, Selectable } from 'kysely';
|
||||||
import { omit } from 'lodash-es';
|
import { omit } from 'lodash-es';
|
||||||
@@ -47,9 +49,29 @@ export const ProgramGroupingExternalId = sqliteTable(
|
|||||||
'source_type_check',
|
'source_type_check',
|
||||||
inArray(table.sourceType, table.sourceType.enumValues).inlineParams(),
|
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<
|
export type ProgramGroupingExternalIdTable = KyselifyBetter<
|
||||||
typeof ProgramGroupingExternalId
|
typeof ProgramGroupingExternalId
|
||||||
>;
|
>;
|
||||||
@@ -67,6 +89,9 @@ export type NewSingleOrMultiProgramGroupingExternalId =
|
|||||||
Insertable<ProgramGroupingExternalIdTable>,
|
Insertable<ProgramGroupingExternalIdTable>,
|
||||||
'externalSourceId' | 'mediaSourceId'
|
'externalSourceId' | 'mediaSourceId'
|
||||||
> & { type: 'multi' });
|
> & { type: 'multi' });
|
||||||
|
export type ProgramGroupingExternalIdOrm = InferSelectModel<
|
||||||
|
typeof ProgramGroupingExternalId
|
||||||
|
>;
|
||||||
|
|
||||||
export function toInsertableProgramGroupingExternalId(
|
export function toInsertableProgramGroupingExternalId(
|
||||||
eid: NewProgramGroupingExternalId | NewSingleOrMultiProgramGroupingExternalId,
|
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', {
|
export const ProgramMediaFile = sqliteTable(
|
||||||
// uuid: text().primaryKey(),
|
'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 { relations } from 'drizzle-orm';
|
||||||
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
import type { Insertable, Selectable } from 'kysely';
|
import type { Insertable, Selectable } from 'kysely';
|
||||||
@@ -19,7 +19,7 @@ export const ProgramMediaStream = sqliteTable(
|
|||||||
uuid: text().primaryKey(),
|
uuid: text().primaryKey(),
|
||||||
index: integer().notNull(),
|
index: integer().notNull(),
|
||||||
codec: text().notNull(),
|
codec: text().notNull(),
|
||||||
profile: text().notNull(),
|
profile: text(), //.notNull(),
|
||||||
streamKind: text({ enum: MediaStreamKind }).notNull(),
|
streamKind: text({ enum: MediaStreamKind }).notNull(),
|
||||||
title: text(),
|
title: text(),
|
||||||
|
|
||||||
@@ -59,3 +59,6 @@ export type ProgramMediaStreamTable = KyselifyBetter<typeof ProgramMediaStream>;
|
|||||||
export type ProgramMediaStream = Selectable<ProgramMediaStreamTable>;
|
export type ProgramMediaStream = Selectable<ProgramMediaStreamTable>;
|
||||||
export type ProgramMediaStreamOrm = InferSelectModel<typeof ProgramMediaStream>;
|
export type ProgramMediaStreamOrm = InferSelectModel<typeof ProgramMediaStream>;
|
||||||
export type NewProgramMediaStream = Insertable<ProgramMediaStreamTable>;
|
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 { relations } from 'drizzle-orm';
|
||||||
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
import type { Insertable, Selectable, Updateable } from 'kysely';
|
import type { Insertable, Selectable, Updateable } from 'kysely';
|
||||||
import type { KyselifyBetter } from './KyselifyBetter.ts';
|
import type { KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
import { Program } from './Program.ts';
|
import { Program } from './Program.ts';
|
||||||
import { ProgramChapter } from './ProgramChapter.ts';
|
import { ProgramChapter } from './ProgramChapter.ts';
|
||||||
|
import { ProgramMediaFile } from './ProgramMediaFile.ts';
|
||||||
import { ProgramMediaStream } from './ProgramMediaStream.ts';
|
import { ProgramMediaStream } from './ProgramMediaStream.ts';
|
||||||
|
|
||||||
export const VideoScanKind = ['unknown', 'progressive', 'interlaced'] as const;
|
export const VideoScanKind = ['unknown', 'progressive', 'interlaced'] as const;
|
||||||
@@ -13,15 +14,15 @@ export const ProgramVersion = sqliteTable(
|
|||||||
'program_version',
|
'program_version',
|
||||||
{
|
{
|
||||||
uuid: text().primaryKey(),
|
uuid: text().primaryKey(),
|
||||||
createdAt: integer().notNull(),
|
createdAt: integer({ mode: 'timestamp_ms' }).notNull(),
|
||||||
updatedAt: integer().notNull(),
|
updatedAt: integer({ mode: 'timestamp_ms' }).notNull(),
|
||||||
duration: integer().notNull(),
|
duration: integer().notNull(),
|
||||||
sampleAspectRatio: text().notNull(),
|
sampleAspectRatio: text(),
|
||||||
displayAspectRatio: text().notNull(),
|
displayAspectRatio: text(),
|
||||||
frameRate: text(),
|
frameRate: text(),
|
||||||
scanKind: text({ enum: VideoScanKind }),
|
scanKind: text({ enum: VideoScanKind }).notNull(),
|
||||||
width: integer(),
|
width: integer().notNull(),
|
||||||
height: integer(),
|
height: integer().notNull(),
|
||||||
|
|
||||||
// Join
|
// Join
|
||||||
programId: text()
|
programId: text()
|
||||||
@@ -41,6 +42,7 @@ export const ProgramVersionRelations = relations(
|
|||||||
}),
|
}),
|
||||||
mediaStreams: many(ProgramMediaStream),
|
mediaStreams: many(ProgramMediaStream),
|
||||||
chapters: many(ProgramChapter),
|
chapters: many(ProgramChapter),
|
||||||
|
mediaFiles: many(ProgramMediaFile),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -48,4 +50,5 @@ export type ProgramVersionTable = KyselifyBetter<typeof ProgramVersion>;
|
|||||||
export type ProgramVersion = Selectable<ProgramVersionTable>;
|
export type ProgramVersion = Selectable<ProgramVersionTable>;
|
||||||
export type ProgramVersionOrm = InferSelectModel<typeof ProgramVersion>;
|
export type ProgramVersionOrm = InferSelectModel<typeof ProgramVersion>;
|
||||||
export type NewProgramVersionDao = Insertable<ProgramVersionTable>;
|
export type NewProgramVersionDao = Insertable<ProgramVersionTable>;
|
||||||
|
export type NewProgramVersionOrm = InferInsertModel<typeof ProgramVersion>;
|
||||||
export type ProgramVersionUpdate = Updateable<ProgramVersionTable>;
|
export type ProgramVersionUpdate = Updateable<ProgramVersionTable>;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const ChannelSubtitlePreferences = sqliteTable(
|
|||||||
...commonSubtitlePreferenceCols,
|
...commonSubtitlePreferenceCols,
|
||||||
channelId: text()
|
channelId: text()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => Channel.uuid),
|
.references(() => Channel.uuid, { onDelete: 'cascade' }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('channel_priority_index').on(table.channelId, table.priority),
|
index('channel_priority_index').on(table.channelId, table.priority),
|
||||||
@@ -44,7 +44,7 @@ export const CustomShowSubtitlePreferences = sqliteTable(
|
|||||||
...commonSubtitlePreferenceCols,
|
...commonSubtitlePreferenceCols,
|
||||||
customShowId: text()
|
customShowId: text()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => CustomShow.uuid),
|
.references(() => CustomShow.uuid, { onDelete: 'cascade' }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('custom_show_priority_index').on(table.customShowId, table.priority),
|
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 MediaSourceId = Tag<string, 'mediaSourceId'>;
|
||||||
export type MediaSourceName = Tag<string, 'mediaSourceName'>;
|
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 { CachedImageTable } from './CachedImage.js';
|
||||||
import type {
|
import type { ChannelTable } from './Channel.ts';
|
||||||
ChannelCustomShowsTable,
|
import type { ChannelCustomShowsTable } from './ChannelCustomShow.ts';
|
||||||
ChannelFallbackTable,
|
import type { ChannelFallbackTable } from './ChannelFallback.ts';
|
||||||
ChannelFillerShowTable,
|
import type { ChannelFillerShowTable } from './ChannelFillerShow.ts';
|
||||||
ChannelProgramsTable,
|
import type { ChannelProgramsTable } from './ChannelPrograms.ts';
|
||||||
ChannelTable,
|
import type { CustomShowTable } from './CustomShow.js';
|
||||||
} from './Channel.ts';
|
import type { CustomShowContentTable } from './CustomShowContent.ts';
|
||||||
import type { CustomShowContentTable, CustomShowTable } from './CustomShow.js';
|
import type { FillerShowTable } from './FillerShow.js';
|
||||||
import type { FillerShowContentTable, FillerShowTable } from './FillerShow.js';
|
import type { FillerShowContentTable } from './FillerShowContent.ts';
|
||||||
|
import type { LocalMediaFolderTable } from './LocalMediaFolder.ts';
|
||||||
|
import type { LocalMediaSourcePathTable } from './LocalMediaSourcePath.ts';
|
||||||
import type {
|
import type {
|
||||||
MediaSourceLibraryTable,
|
MediaSourceLibraryTable,
|
||||||
MediaSourceTable,
|
MediaSourceTable,
|
||||||
@@ -18,6 +20,7 @@ import type { ProgramChapterTable } from './ProgramChapter.ts';
|
|||||||
import type { ProgramExternalIdTable } from './ProgramExternalId.ts';
|
import type { ProgramExternalIdTable } from './ProgramExternalId.ts';
|
||||||
import type { ProgramGroupingTable } from './ProgramGrouping.ts';
|
import type { ProgramGroupingTable } from './ProgramGrouping.ts';
|
||||||
import type { ProgramGroupingExternalIdTable } from './ProgramGroupingExternalId.ts';
|
import type { ProgramGroupingExternalIdTable } from './ProgramGroupingExternalId.ts';
|
||||||
|
import type { ProgramMediaFileTable } from './ProgramMediaFile.ts';
|
||||||
import type { ProgramMediaStreamTable } from './ProgramMediaStream.ts';
|
import type { ProgramMediaStreamTable } from './ProgramMediaStream.ts';
|
||||||
import type { ProgramVersionTable } from './ProgramVersion.ts';
|
import type { ProgramVersionTable } from './ProgramVersion.ts';
|
||||||
import type {
|
import type {
|
||||||
@@ -39,12 +42,15 @@ export interface DB {
|
|||||||
customShowSubtitlePreferences: CustomShowSubtitlePreferencesTable;
|
customShowSubtitlePreferences: CustomShowSubtitlePreferencesTable;
|
||||||
fillerShow: FillerShowTable;
|
fillerShow: FillerShowTable;
|
||||||
fillerShowContent: FillerShowContentTable;
|
fillerShowContent: FillerShowContentTable;
|
||||||
|
localMediaSourcePath: LocalMediaSourcePathTable;
|
||||||
|
localMediaFolder: LocalMediaFolderTable;
|
||||||
mediaSource: MediaSourceTable;
|
mediaSource: MediaSourceTable;
|
||||||
mediaSourceLibrary: MediaSourceLibraryTable;
|
mediaSourceLibrary: MediaSourceLibraryTable;
|
||||||
program: ProgramTable;
|
program: ProgramTable;
|
||||||
programChapter: ProgramChapterTable;
|
programChapter: ProgramChapterTable;
|
||||||
programExternalId: ProgramExternalIdTable;
|
programExternalId: ProgramExternalIdTable;
|
||||||
programMediaStream: ProgramMediaStreamTable;
|
programMediaStream: ProgramMediaStreamTable;
|
||||||
|
programMediaFile: ProgramMediaFileTable;
|
||||||
programVersion: ProgramVersionTable;
|
programVersion: ProgramVersionTable;
|
||||||
programGrouping: ProgramGroupingTable;
|
programGrouping: ProgramGroupingTable;
|
||||||
programGroupingExternalId: ProgramGroupingExternalIdTable;
|
programGroupingExternalId: ProgramGroupingExternalIdTable;
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
|
|||||||
import type { MarkNonNullable, Nullable } from '@/types/util.js';
|
import type { MarkNonNullable, Nullable } from '@/types/util.js';
|
||||||
import type { Insertable } from 'kysely';
|
import type { Insertable } from 'kysely';
|
||||||
import type { DeepNullable, MarkRequired, StrictOmit } from 'ts-essentials';
|
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 { FillerShow } from './FillerShow.ts';
|
||||||
|
import type {
|
||||||
|
LocalMediaSourcePath,
|
||||||
|
LocalMediaSourcePathOrm,
|
||||||
|
} from './LocalMediaSourcePath.ts';
|
||||||
import type {
|
import type {
|
||||||
MediaSource,
|
MediaSource,
|
||||||
MediaSourceLibrary,
|
MediaSourceLibrary,
|
||||||
MediaSourceLibraryOrm,
|
MediaSourceLibraryOrm,
|
||||||
MediaSourceType,
|
MediaSourceOrm,
|
||||||
} from './MediaSource.ts';
|
} from './MediaSource.ts';
|
||||||
import type {
|
import type {
|
||||||
NewProgramDao,
|
NewProgramDao,
|
||||||
@@ -17,6 +24,7 @@ import type {
|
|||||||
ProgramType,
|
ProgramType,
|
||||||
} from './Program.ts';
|
} from './Program.ts';
|
||||||
import type {
|
import type {
|
||||||
|
NewProgramChapterOrm,
|
||||||
ProgramChapter,
|
ProgramChapter,
|
||||||
ProgramChapterOrm,
|
ProgramChapterOrm,
|
||||||
ProgramChapterTable,
|
ProgramChapterTable,
|
||||||
@@ -28,19 +36,31 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
NewProgramGrouping,
|
NewProgramGrouping,
|
||||||
ProgramGrouping,
|
ProgramGrouping,
|
||||||
|
ProgramGroupingOrm,
|
||||||
ProgramGroupingType,
|
ProgramGroupingType,
|
||||||
} from './ProgramGrouping.ts';
|
} from './ProgramGrouping.ts';
|
||||||
import type {
|
import type {
|
||||||
NewSingleOrMultiProgramGroupingExternalId,
|
NewSingleOrMultiProgramGroupingExternalId,
|
||||||
ProgramGroupingExternalId,
|
ProgramGroupingExternalId,
|
||||||
|
ProgramGroupingExternalIdOrm,
|
||||||
} from './ProgramGroupingExternalId.ts';
|
} from './ProgramGroupingExternalId.ts';
|
||||||
|
import type {
|
||||||
|
NewProgramMediaFile,
|
||||||
|
ProgramMediaFile,
|
||||||
|
} from './ProgramMediaFile.ts';
|
||||||
import type {
|
import type {
|
||||||
NewProgramMediaStream,
|
NewProgramMediaStream,
|
||||||
|
NewProgramMediaStreamOrm,
|
||||||
ProgramMediaStream,
|
ProgramMediaStream,
|
||||||
ProgramMediaStreamOrm,
|
ProgramMediaStreamOrm,
|
||||||
} from './ProgramMediaStream.ts';
|
} from './ProgramMediaStream.ts';
|
||||||
|
import type {
|
||||||
|
NewProgramSubtitles,
|
||||||
|
ProgramSubtitles,
|
||||||
|
} from './ProgramSubtitles.ts';
|
||||||
import type {
|
import type {
|
||||||
NewProgramVersionDao,
|
NewProgramVersionDao,
|
||||||
|
NewProgramVersionOrm,
|
||||||
ProgramVersion,
|
ProgramVersion,
|
||||||
ProgramVersionOrm,
|
ProgramVersionOrm,
|
||||||
} from './ProgramVersion.ts';
|
} from './ProgramVersion.ts';
|
||||||
@@ -53,9 +73,15 @@ export type ProgramVersionWithRelations = ProgramVersion & {
|
|||||||
|
|
||||||
export type ProgramVersionOrmWithRelations = ProgramVersionOrm & {
|
export type ProgramVersionOrmWithRelations = ProgramVersionOrm & {
|
||||||
mediaStreams?: ProgramMediaStreamOrm[];
|
mediaStreams?: ProgramMediaStreamOrm[];
|
||||||
|
mediaFiles?: ProgramMediaFile[];
|
||||||
chapters?: ProgramChapterOrm[];
|
chapters?: ProgramChapterOrm[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NewProgramVersionOrmWithRelations = NewProgramVersionOrm & {
|
||||||
|
mediaStreams?: NewProgramMediaStreamOrm[];
|
||||||
|
chapters?: NewProgramChapterOrm[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ProgramWithRelations = ProgramDao & {
|
export type ProgramWithRelations = ProgramDao & {
|
||||||
tvShow?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
|
tvShow?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
|
||||||
tvSeason?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
|
tvSeason?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
|
||||||
@@ -76,8 +102,19 @@ export type ProgramWithRelationsOrm = ProgramOrm & {
|
|||||||
externalIds?: MinimalProgramExternalId[];
|
externalIds?: MinimalProgramExternalId[];
|
||||||
versions?: ProgramVersionOrmWithRelations[];
|
versions?: ProgramVersionOrmWithRelations[];
|
||||||
mediaLibrary?: Nullable<MediaSourceLibraryOrm>;
|
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<
|
export type SpecificProgramGroupingType<
|
||||||
Typ extends ProgramGroupingType,
|
Typ extends ProgramGroupingType,
|
||||||
ProgramGroupingT extends { type: ProgramGroupingType } = ProgramGrouping,
|
ProgramGroupingT extends { type: ProgramGroupingType } = ProgramGrouping,
|
||||||
@@ -119,6 +156,14 @@ export type ChannelWithRelations = Channel & {
|
|||||||
subtitlePreferences?: ChannelSubtitlePreferences[];
|
subtitlePreferences?: ChannelSubtitlePreferences[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChannelOrmWithRelations = ChannelOrm & {
|
||||||
|
programs?: ProgramWithRelationsOrm[];
|
||||||
|
fillerContent?: ProgramWithRelationsOrm[];
|
||||||
|
fillerShows?: ChannelFillerShow[];
|
||||||
|
transcodeConfig?: TranscodeConfig;
|
||||||
|
subtitlePreferences?: ChannelSubtitlePreferences[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ChannelWithTranscodeConfig = MarkRequired<
|
export type ChannelWithTranscodeConfig = MarkRequired<
|
||||||
ChannelWithRelations,
|
ChannelWithRelations,
|
||||||
'transcodeConfig'
|
'transcodeConfig'
|
||||||
@@ -132,6 +177,11 @@ export type ChannelWithPrograms = MarkRequired<
|
|||||||
'programs'
|
'programs'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type ChannelOrmWithPrograms = MarkRequired<
|
||||||
|
ChannelOrmWithRelations,
|
||||||
|
'programs'
|
||||||
|
>;
|
||||||
|
|
||||||
export type ChannelFillerShowWithRelations = ChannelFillerShow & {
|
export type ChannelFillerShowWithRelations = ChannelFillerShow & {
|
||||||
fillerShow: MarkNonNullable<DeepNullable<FillerShow>, 'uuid'>;
|
fillerShow: MarkNonNullable<DeepNullable<FillerShow>, 'uuid'>;
|
||||||
fillerContent?: ProgramWithRelations[];
|
fillerContent?: ProgramWithRelations[];
|
||||||
@@ -153,6 +203,7 @@ export type ProgramWithExternalIds = ProgramDao & {
|
|||||||
|
|
||||||
export type NewProgramVersion = NewProgramVersionDao & {
|
export type NewProgramVersion = NewProgramVersionDao & {
|
||||||
mediaStreams: NewProgramMediaStream[];
|
mediaStreams: NewProgramMediaStream[];
|
||||||
|
mediaFiles: NewProgramMediaFile[];
|
||||||
chapters?: Insertable<ProgramChapterTable>[];
|
chapters?: Insertable<ProgramChapterTable>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,6 +211,8 @@ export type NewProgramWithRelations<Type extends ProgramType = ProgramType> = {
|
|||||||
program: SpecificProgramType<Type, NewProgramDao>;
|
program: SpecificProgramType<Type, NewProgramDao>;
|
||||||
externalIds: NewSingleOrMultiExternalId[];
|
externalIds: NewSingleOrMultiExternalId[];
|
||||||
versions: NewProgramVersion[];
|
versions: NewProgramVersion[];
|
||||||
|
artwork?: NewArtwork[];
|
||||||
|
subtitles?: NewProgramSubtitles[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NewProgramWithExternalIds = NewProgramDao & {
|
export type NewProgramWithExternalIds = NewProgramDao & {
|
||||||
@@ -178,6 +231,11 @@ export type ProgramGroupingWithExternalIds = ProgramGrouping & {
|
|||||||
externalIds: ProgramGroupingExternalId[];
|
externalIds: ProgramGroupingExternalId[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProgramGroupingOrmWithRelations = ProgramGroupingOrm & {
|
||||||
|
externalIds: ProgramGroupingExternalIdOrm[];
|
||||||
|
artwork?: Artwork[];
|
||||||
|
};
|
||||||
|
|
||||||
type SpecificSubtype<
|
type SpecificSubtype<
|
||||||
BaseType extends { type: string },
|
BaseType extends { type: string },
|
||||||
Value extends BaseType['type'],
|
Value extends BaseType['type'],
|
||||||
@@ -220,11 +278,16 @@ type WithNewGroupingExternalIds = {
|
|||||||
export type NewProgramGroupingWithExternalIds = NewProgramGrouping &
|
export type NewProgramGroupingWithExternalIds = NewProgramGrouping &
|
||||||
WithNewGroupingExternalIds;
|
WithNewGroupingExternalIds;
|
||||||
|
|
||||||
export type NewTvShow = SpecificProgramGroupingType<
|
export type NewProgramGroupingWithRelations<
|
||||||
'show',
|
Typ extends ProgramGroupingType = ProgramGroupingType,
|
||||||
NewProgramGrouping
|
> = {
|
||||||
> &
|
programGrouping: SpecificProgramGroupingType<Typ, NewProgramGrouping>;
|
||||||
WithNewGroupingExternalIds;
|
externalIds: NewSingleOrMultiProgramGroupingExternalId[];
|
||||||
|
artwork: NewArtwork[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NewTvShow = SpecificProgramGroupingType<'show', NewProgramGrouping>;
|
||||||
|
|
||||||
export type NewTvSeason = SpecificProgramGroupingType<
|
export type NewTvSeason = SpecificProgramGroupingType<
|
||||||
'season',
|
'season',
|
||||||
NewProgramGrouping
|
NewProgramGrouping
|
||||||
@@ -244,9 +307,16 @@ export type NewMusicAlbum = SpecificProgramGroupingType<
|
|||||||
|
|
||||||
export type NewMusicTrack = SpecificProgramType<'track', NewProgramDao>;
|
export type NewMusicTrack = SpecificProgramType<'track', NewProgramDao>;
|
||||||
|
|
||||||
export type MediaSourceWithLibraries = MediaSource & {
|
export type MediaSourceWithLibrariesDirect = MediaSource & {
|
||||||
libraries: MediaSourceLibrary[];
|
libraries: MediaSourceLibrary[];
|
||||||
|
paths: LocalMediaSourcePath[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MediaSourceWithLibraries = MediaSourceOrm & {
|
||||||
|
libraries: MediaSourceLibraryOrm[];
|
||||||
|
paths: LocalMediaSourcePathOrm[];
|
||||||
|
};
|
||||||
|
|
||||||
export type SpecificMediaSourceType<Typ extends MediaSourceType> = StrictOmit<
|
export type SpecificMediaSourceType<Typ extends MediaSourceType> = StrictOmit<
|
||||||
MediaSourceWithLibraries,
|
MediaSourceWithLibraries,
|
||||||
'type'
|
'type'
|
||||||
|
|||||||
@@ -1,9 +1,41 @@
|
|||||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
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 {
|
import {
|
||||||
MediaSource,
|
MediaSource,
|
||||||
MediaSourceLibrary,
|
MediaSourceLibrary,
|
||||||
MediaSourceLibraryRelations,
|
MediaSourceLibraryRelations,
|
||||||
|
MediaSourceRelations,
|
||||||
} from './MediaSource.ts';
|
} from './MediaSource.ts';
|
||||||
import { Program, ProgramRelations } from './Program.ts';
|
import { Program, ProgramRelations } from './Program.ts';
|
||||||
import { ProgramChapter, ProgramChapterRelations } from './ProgramChapter.ts';
|
import { ProgramChapter, ProgramChapterRelations } from './ProgramChapter.ts';
|
||||||
@@ -15,16 +47,43 @@ import {
|
|||||||
ProgramGrouping,
|
ProgramGrouping,
|
||||||
ProgramGroupingRelations,
|
ProgramGroupingRelations,
|
||||||
} from './ProgramGrouping.ts';
|
} from './ProgramGrouping.ts';
|
||||||
|
import {
|
||||||
|
ProgramGroupingExternalId,
|
||||||
|
ProgramGroupingExternalIdRelations,
|
||||||
|
} from './ProgramGroupingExternalId.ts';
|
||||||
|
import {
|
||||||
|
ProgramMediaFile,
|
||||||
|
ProgramMediaFileRelations,
|
||||||
|
} from './ProgramMediaFile.ts';
|
||||||
import {
|
import {
|
||||||
ProgramMediaStream,
|
ProgramMediaStream,
|
||||||
ProgramMediaStreamRelations,
|
ProgramMediaStreamRelations,
|
||||||
} from './ProgramMediaStream.ts';
|
} from './ProgramMediaStream.ts';
|
||||||
|
import {
|
||||||
|
ProgramSubtitles,
|
||||||
|
ProgramSubtitlesRelations,
|
||||||
|
} from './ProgramSubtitles.ts';
|
||||||
import { ProgramVersion, ProgramVersionRelations } from './ProgramVersion.ts';
|
import { ProgramVersion, ProgramVersionRelations } from './ProgramVersion.ts';
|
||||||
|
|
||||||
// export { Program } from './Program.ts';
|
// export { Program } from './Program.ts';
|
||||||
|
|
||||||
export const schema = {
|
export const schema = {
|
||||||
channels: Channel,
|
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,
|
program: Program,
|
||||||
programVersion: ProgramVersion,
|
programVersion: ProgramVersion,
|
||||||
programRelations: ProgramRelations,
|
programRelations: ProgramRelations,
|
||||||
@@ -33,13 +92,26 @@ export const schema = {
|
|||||||
programGroupingRelations: ProgramGroupingRelations,
|
programGroupingRelations: ProgramGroupingRelations,
|
||||||
programExternalId: ProgramExternalId,
|
programExternalId: ProgramExternalId,
|
||||||
programExternalIdRelations: ProgramExternalIdRelations,
|
programExternalIdRelations: ProgramExternalIdRelations,
|
||||||
|
programGroupingExternalId: ProgramGroupingExternalId,
|
||||||
|
programGroupingExternalIdRelations: ProgramGroupingExternalIdRelations,
|
||||||
programMediaStream: ProgramMediaStream,
|
programMediaStream: ProgramMediaStream,
|
||||||
programMediaStreamRelations: ProgramMediaStreamRelations,
|
programMediaStreamRelations: ProgramMediaStreamRelations,
|
||||||
programChapter: ProgramChapter,
|
programChapter: ProgramChapter,
|
||||||
programChapterRelations: ProgramChapterRelations,
|
programChapterRelations: ProgramChapterRelations,
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSource,
|
||||||
|
mediaSourceRelations: MediaSourceRelations,
|
||||||
mediaSourceLibrary: MediaSourceLibrary,
|
mediaSourceLibrary: MediaSourceLibrary,
|
||||||
mediaSourceLibraryRelations: MediaSourceLibraryRelations,
|
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>;
|
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 { MediaSourceDB } from '@/db/mediaSourceDB.js';
|
||||||
import type { MediaSource } from '@/db/schema/MediaSource.js';
|
import { MediaSourceType } from '@/db/schema/base.js';
|
||||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
import type { MediaSource, MediaSourceOrm } from '@/db/schema/MediaSource.js';
|
||||||
import type { Maybe } from '@/types/util.js';
|
import type { Maybe } from '@/types/util.js';
|
||||||
import { isDefined, isNonEmptyString } from '@/util/index.js';
|
import { isDefined, isNonEmptyString } from '@/util/index.js';
|
||||||
import type { FindChild } from '@tunarr/types';
|
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 { forEach, isBoolean, isEmpty, isNil } from 'lodash-es';
|
||||||
import NodeCache from 'node-cache';
|
import NodeCache from 'node-cache';
|
||||||
import type { ISettingsDB } from '../db/interfaces/ISettingsDB.ts';
|
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 { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||||
import { KEYS } from '../types/inject.ts';
|
import { KEYS } from '../types/inject.ts';
|
||||||
import { Result } from '../types/result.ts';
|
import { Result } from '../types/result.ts';
|
||||||
@@ -126,19 +126,10 @@ export class MediaSourceApiFactory {
|
|||||||
getPlexApiClientForMediaSource(
|
getPlexApiClientForMediaSource(
|
||||||
mediaSource: MediaSourceWithLibraries,
|
mediaSource: MediaSourceWithLibraries,
|
||||||
): Promise<PlexApiClient> {
|
): Promise<PlexApiClient> {
|
||||||
// const opts = mediaSourceToApiOptions(mediaSource);
|
|
||||||
return this.getPlexApiClient({ mediaSource });
|
return this.getPlexApiClient({ mediaSource });
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlexApiClient(opts: ApiClientOptions): Promise<PlexApiClient> {
|
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(
|
return Promise.resolve(
|
||||||
this.plexApiClientFactory({
|
this.plexApiClientFactory({
|
||||||
...opts,
|
...opts,
|
||||||
@@ -187,7 +178,7 @@ export class MediaSourceApiFactory {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteCachedClient(mediaSource: MediaSource) {
|
deleteCachedClient(mediaSource: MediaSourceOrm) {
|
||||||
const key = this.getCacheKeyForMediaSource(mediaSource);
|
const key = this.getCacheKeyForMediaSource(mediaSource);
|
||||||
return MediaSourceApiFactory.cache.del(key) === 1;
|
return MediaSourceApiFactory.cache.del(key) === 1;
|
||||||
}
|
}
|
||||||
@@ -224,7 +215,9 @@ export class MediaSourceApiFactory {
|
|||||||
return `${type}|${uri}|${accessToken}`;
|
return `${type}|${uri}|${accessToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCacheKeyForMediaSource(mediaSource: MediaSource): string {
|
private getCacheKeyForMediaSource(
|
||||||
|
mediaSource: MediaSource | MediaSourceOrm,
|
||||||
|
): string {
|
||||||
return this.getCacheKey(
|
return this.getCacheKey(
|
||||||
mediaSource.type,
|
mediaSource.type,
|
||||||
mediaSource.uri,
|
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,
|
NamedEntity,
|
||||||
} from '../../types/Media.ts';
|
} from '../../types/Media.ts';
|
||||||
import { Result } from '../../types/result.ts';
|
import { Result } from '../../types/result.ts';
|
||||||
|
import { titleToSortTitle } from '../../util/programs.ts';
|
||||||
import {
|
import {
|
||||||
QueryError,
|
QueryError,
|
||||||
type ApiClientOptions,
|
type ApiClientOptions,
|
||||||
@@ -898,6 +899,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(movie),
|
canonicalId: this.canonicalizer.getCanonicalId(movie),
|
||||||
title: movie.Name!,
|
title: movie.Name!,
|
||||||
|
sortTitle: titleToSortTitle(movie.Name ?? ''),
|
||||||
originalTitle: movie.OriginalTitle ?? null,
|
originalTitle: movie.OriginalTitle ?? null,
|
||||||
year: movie.ProductionYear ?? null,
|
year: movie.ProductionYear ?? null,
|
||||||
releaseDate: isError(parsedReleaseDate)
|
releaseDate: isError(parsedReleaseDate)
|
||||||
@@ -924,7 +926,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
tags: movie.Tags?.filter(isNonEmptyString) ?? [],
|
tags: movie.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
summary: null,
|
summary: null,
|
||||||
type: 'movie',
|
type: 'movie',
|
||||||
externalKey: movie.Id,
|
|
||||||
mediaItem,
|
mediaItem,
|
||||||
identifiers: collectEmbyItemIdentifiers(
|
identifiers: collectEmbyItemIdentifiers(
|
||||||
movie,
|
movie,
|
||||||
@@ -1064,6 +1065,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
externalId: series.Id,
|
externalId: series.Id,
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(series),
|
canonicalId: this.canonicalizer.getCanonicalId(series),
|
||||||
title: series.Name!,
|
title: series.Name!,
|
||||||
|
sortTitle: titleToSortTitle(series.Name ?? ''),
|
||||||
// originalTitle: series.OriginalTitle ?? null,
|
// originalTitle: series.OriginalTitle ?? null,
|
||||||
year: series.ProductionYear ?? null,
|
year: series.ProductionYear ?? null,
|
||||||
releaseDate: isError(parsedReleaseDate)
|
releaseDate: isError(parsedReleaseDate)
|
||||||
@@ -1092,7 +1094,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
tags: series.Tags?.filter(isNonEmptyString) ?? [],
|
tags: series.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
summary: null,
|
summary: null,
|
||||||
type: 'show',
|
type: 'show',
|
||||||
externalKey: series.Id,
|
|
||||||
// mediaItem,
|
// mediaItem,
|
||||||
identifiers: collectEmbyItemIdentifiers(
|
identifiers: collectEmbyItemIdentifiers(
|
||||||
series,
|
series,
|
||||||
@@ -1112,6 +1113,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
externalId: season.Id,
|
externalId: season.Id,
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(season),
|
canonicalId: this.canonicalizer.getCanonicalId(season),
|
||||||
title: season.Name!,
|
title: season.Name!,
|
||||||
|
sortTitle: titleToSortTitle(season.Name ?? ''),
|
||||||
// originalTitle: season.OriginalTitle ?? null,
|
// originalTitle: season.OriginalTitle ?? null,
|
||||||
year: season.ProductionYear ?? null,
|
year: season.ProductionYear ?? null,
|
||||||
mediaSourceId: this.options.mediaSource.uuid,
|
mediaSourceId: this.options.mediaSource.uuid,
|
||||||
@@ -1140,7 +1142,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
tags: season.Tags?.filter(isNonEmptyString) ?? [],
|
tags: season.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
summary: null,
|
summary: null,
|
||||||
type: 'season',
|
type: 'season',
|
||||||
externalKey: season.Id,
|
|
||||||
// mediaItem,
|
// mediaItem,
|
||||||
identifiers: collectEmbyItemIdentifiers(
|
identifiers: collectEmbyItemIdentifiers(
|
||||||
season,
|
season,
|
||||||
@@ -1186,6 +1187,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
externalId: episode.Id,
|
externalId: episode.Id,
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(episode),
|
canonicalId: this.canonicalizer.getCanonicalId(episode),
|
||||||
title: episode.Name!,
|
title: episode.Name!,
|
||||||
|
sortTitle: titleToSortTitle(episode.Name ?? ''),
|
||||||
originalTitle: episode.OriginalTitle ?? null,
|
originalTitle: episode.OriginalTitle ?? null,
|
||||||
year: episode.ProductionYear ?? null,
|
year: episode.ProductionYear ?? null,
|
||||||
releaseDate: isError(parsedReleaseDate)
|
releaseDate: isError(parsedReleaseDate)
|
||||||
@@ -1215,7 +1217,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
tags: episode.Tags?.filter(isNonEmptyString) ?? [],
|
tags: episode.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
summary: null,
|
summary: null,
|
||||||
type: 'episode',
|
type: 'episode',
|
||||||
externalKey: episode.Id,
|
|
||||||
mediaItem,
|
mediaItem,
|
||||||
identifiers: collectEmbyItemIdentifiers(
|
identifiers: collectEmbyItemIdentifiers(
|
||||||
episode,
|
episode,
|
||||||
@@ -1229,8 +1230,8 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
private embyApiMusicArtistInjection(artist: ApiEmbyMusicArtist) {
|
private embyApiMusicArtistInjection(artist: ApiEmbyMusicArtist) {
|
||||||
return {
|
return {
|
||||||
title: artist.Name ?? '',
|
title: artist.Name ?? '',
|
||||||
|
sortTitle: titleToSortTitle(artist.Name ?? ''),
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(artist),
|
canonicalId: this.canonicalizer.getCanonicalId(artist),
|
||||||
externalKey: artist.Id,
|
|
||||||
genres:
|
genres:
|
||||||
artist.Genres?.map((genre) => ({
|
artist.Genres?.map((genre) => ({
|
||||||
name: genre,
|
name: genre,
|
||||||
@@ -1259,8 +1260,8 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
type: 'album',
|
type: 'album',
|
||||||
externalId: album.Id,
|
externalId: album.Id,
|
||||||
title: album.Name ?? '',
|
title: album.Name ?? '',
|
||||||
|
sortTitle: titleToSortTitle(album.Name ?? ''),
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(album),
|
canonicalId: this.canonicalizer.getCanonicalId(album),
|
||||||
externalKey: album.Id,
|
|
||||||
genres:
|
genres:
|
||||||
album.Genres?.map((genre) => ({
|
album.Genres?.map((genre) => ({
|
||||||
name: genre,
|
name: genre,
|
||||||
@@ -1312,9 +1313,9 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(track),
|
canonicalId: this.canonicalizer.getCanonicalId(track),
|
||||||
title: track.Name ?? '',
|
title: track.Name ?? '',
|
||||||
|
sortTitle: titleToSortTitle(track.Name ?? ''),
|
||||||
actors: [],
|
actors: [],
|
||||||
directors: [],
|
directors: [],
|
||||||
externalKey: track.Id,
|
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: track.Tags?.filter(isNonEmptyString) ?? [],
|
tags: track.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
year: track.ProductionYear ?? null,
|
year: track.ProductionYear ?? null,
|
||||||
@@ -1378,6 +1379,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(video),
|
canonicalId: this.canonicalizer.getCanonicalId(video),
|
||||||
title: video.Name!,
|
title: video.Name!,
|
||||||
|
sortTitle: titleToSortTitle(video.Name ?? ''),
|
||||||
originalTitle: video.OriginalTitle ?? null,
|
originalTitle: video.OriginalTitle ?? null,
|
||||||
year: video.ProductionYear ?? null,
|
year: video.ProductionYear ?? null,
|
||||||
releaseDate: isError(parsedReleaseDate)
|
releaseDate: isError(parsedReleaseDate)
|
||||||
@@ -1404,7 +1406,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
tags: video.Tags?.filter(isNonEmptyString) ?? [],
|
tags: video.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
// summary: null,
|
// summary: null,
|
||||||
type: 'music_video',
|
type: 'music_video',
|
||||||
externalKey: video.Id,
|
|
||||||
mediaItem,
|
mediaItem,
|
||||||
identifiers: collectEmbyItemIdentifiers(
|
identifiers: collectEmbyItemIdentifiers(
|
||||||
video,
|
video,
|
||||||
@@ -1447,6 +1448,7 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(video),
|
canonicalId: this.canonicalizer.getCanonicalId(video),
|
||||||
title: video.Name!,
|
title: video.Name!,
|
||||||
|
sortTitle: titleToSortTitle(video.Name ?? ''),
|
||||||
originalTitle: video.OriginalTitle ?? null,
|
originalTitle: video.OriginalTitle ?? null,
|
||||||
year: video.ProductionYear ?? null,
|
year: video.ProductionYear ?? null,
|
||||||
releaseDate: isError(parsedReleaseDate)
|
releaseDate: isError(parsedReleaseDate)
|
||||||
@@ -1473,7 +1475,6 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
|
|||||||
tags: video.Tags?.filter(isNonEmptyString) ?? [],
|
tags: video.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
// summary: null,
|
// summary: null,
|
||||||
type: 'other_video',
|
type: 'other_video',
|
||||||
externalKey: video.Id,
|
|
||||||
mediaItem,
|
mediaItem,
|
||||||
identifiers: collectEmbyItemIdentifiers(
|
identifiers: collectEmbyItemIdentifiers(
|
||||||
video,
|
video,
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import type {
|
|||||||
NamedEntity,
|
NamedEntity,
|
||||||
} from '../../types/Media.ts';
|
} from '../../types/Media.ts';
|
||||||
import { Result } from '../../types/result.ts';
|
import { Result } from '../../types/result.ts';
|
||||||
|
import { titleToSortTitle } from '../../util/programs.ts';
|
||||||
import {
|
import {
|
||||||
QueryError,
|
QueryError,
|
||||||
type ApiClientOptions,
|
type ApiClientOptions,
|
||||||
@@ -965,6 +966,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(movie),
|
canonicalId: this.canonicalizer.getCanonicalId(movie),
|
||||||
title: movie.Name!,
|
title: movie.Name!,
|
||||||
|
sortTitle: titleToSortTitle(movie.Name!),
|
||||||
originalTitle: movie.OriginalTitle ?? null,
|
originalTitle: movie.OriginalTitle ?? null,
|
||||||
year: movie.ProductionYear ?? null,
|
year: movie.ProductionYear ?? null,
|
||||||
releaseDate: isError(parsedReleaseDate)
|
releaseDate: isError(parsedReleaseDate)
|
||||||
@@ -991,7 +993,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
tags: movie.Tags?.filter(isNonEmptyString) ?? [],
|
tags: movie.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
summary: null,
|
summary: null,
|
||||||
type: 'movie',
|
type: 'movie',
|
||||||
externalKey: movie.Id,
|
|
||||||
mediaItem,
|
mediaItem,
|
||||||
identifiers: collectJellyfinItemIdentifiers(
|
identifiers: collectJellyfinItemIdentifiers(
|
||||||
movie,
|
movie,
|
||||||
@@ -1162,6 +1163,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
externalId: series.Id,
|
externalId: series.Id,
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(series),
|
canonicalId: this.canonicalizer.getCanonicalId(series),
|
||||||
title: series.Name!,
|
title: series.Name!,
|
||||||
|
sortTitle: titleToSortTitle(series.Name!),
|
||||||
// originalTitle: series.OriginalTitle ?? null,
|
// originalTitle: series.OriginalTitle ?? null,
|
||||||
year: series.ProductionYear ?? null,
|
year: series.ProductionYear ?? null,
|
||||||
releaseDate: isError(parsedReleaseDate)
|
releaseDate: isError(parsedReleaseDate)
|
||||||
@@ -1190,7 +1192,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
tags: series.Tags?.filter(isNonEmptyString) ?? [],
|
tags: series.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
summary: null,
|
summary: null,
|
||||||
type: 'show',
|
type: 'show',
|
||||||
externalKey: series.Id,
|
|
||||||
// mediaItem,
|
// mediaItem,
|
||||||
identifiers: collectJellyfinItemIdentifiers(
|
identifiers: collectJellyfinItemIdentifiers(
|
||||||
series,
|
series,
|
||||||
@@ -1212,6 +1213,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
externalId: season.Id,
|
externalId: season.Id,
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(season),
|
canonicalId: this.canonicalizer.getCanonicalId(season),
|
||||||
title: season.Name!,
|
title: season.Name!,
|
||||||
|
sortTitle: titleToSortTitle(season.Name!),
|
||||||
// originalTitle: season.OriginalTitle ?? null,
|
// originalTitle: season.OriginalTitle ?? null,
|
||||||
year: season.ProductionYear ?? null,
|
year: season.ProductionYear ?? null,
|
||||||
mediaSourceId: this.options.mediaSource.uuid,
|
mediaSourceId: this.options.mediaSource.uuid,
|
||||||
@@ -1240,7 +1242,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
tags: season.Tags?.filter(isNonEmptyString) ?? [],
|
tags: season.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
summary: null,
|
summary: null,
|
||||||
type: 'season',
|
type: 'season',
|
||||||
externalKey: season.Id,
|
|
||||||
// mediaItem,
|
// mediaItem,
|
||||||
identifiers: collectJellyfinItemIdentifiers(
|
identifiers: collectJellyfinItemIdentifiers(
|
||||||
season,
|
season,
|
||||||
@@ -1286,6 +1287,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
externalId: episode.Id,
|
externalId: episode.Id,
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(episode),
|
canonicalId: this.canonicalizer.getCanonicalId(episode),
|
||||||
title: episode.Name!,
|
title: episode.Name!,
|
||||||
|
sortTitle: titleToSortTitle(episode.Name!),
|
||||||
originalTitle: episode.OriginalTitle ?? null,
|
originalTitle: episode.OriginalTitle ?? null,
|
||||||
year: episode.ProductionYear ?? null,
|
year: episode.ProductionYear ?? null,
|
||||||
releaseDate: isError(parsedReleaseDate)
|
releaseDate: isError(parsedReleaseDate)
|
||||||
@@ -1315,7 +1317,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
tags: episode.Tags?.filter(isNonEmptyString) ?? [],
|
tags: episode.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
summary: null,
|
summary: null,
|
||||||
type: 'episode',
|
type: 'episode',
|
||||||
externalKey: episode.Id,
|
|
||||||
mediaItem,
|
mediaItem,
|
||||||
identifiers: collectJellyfinItemIdentifiers(
|
identifiers: collectJellyfinItemIdentifiers(
|
||||||
episode,
|
episode,
|
||||||
@@ -1330,7 +1331,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
return {
|
return {
|
||||||
title: artist.Name ?? '',
|
title: artist.Name ?? '',
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(artist),
|
canonicalId: this.canonicalizer.getCanonicalId(artist),
|
||||||
externalKey: artist.Id,
|
|
||||||
genres:
|
genres:
|
||||||
artist.Genres?.map((genre) => ({
|
artist.Genres?.map((genre) => ({
|
||||||
name: genre,
|
name: genre,
|
||||||
@@ -1351,6 +1351,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
mediaSourceId: this.options.mediaSource.uuid,
|
mediaSourceId: this.options.mediaSource.uuid,
|
||||||
childCount: artist.ChildCount ?? undefined,
|
childCount: artist.ChildCount ?? undefined,
|
||||||
externalId: artist.Id,
|
externalId: artist.Id,
|
||||||
|
sortTitle: titleToSortTitle(artist.Name ?? ''),
|
||||||
} satisfies JellyfinMusicArtist;
|
} satisfies JellyfinMusicArtist;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1361,8 +1362,8 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
type: 'album',
|
type: 'album',
|
||||||
externalId: album.Id,
|
externalId: album.Id,
|
||||||
title: album.Name ?? '',
|
title: album.Name ?? '',
|
||||||
|
sortTitle: titleToSortTitle(album.Name!),
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(album),
|
canonicalId: this.canonicalizer.getCanonicalId(album),
|
||||||
externalKey: album.Id,
|
|
||||||
genres:
|
genres:
|
||||||
album.Genres?.map((genre) => ({
|
album.Genres?.map((genre) => ({
|
||||||
name: genre,
|
name: genre,
|
||||||
@@ -1414,9 +1415,9 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(track),
|
canonicalId: this.canonicalizer.getCanonicalId(track),
|
||||||
title: track.Name ?? '',
|
title: track.Name ?? '',
|
||||||
|
sortTitle: titleToSortTitle(track.Name!),
|
||||||
actors: [],
|
actors: [],
|
||||||
directors: [],
|
directors: [],
|
||||||
externalKey: track.Id,
|
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: track.Tags?.filter(isNonEmptyString) ?? [],
|
tags: track.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
year: track.ProductionYear ?? null,
|
year: track.ProductionYear ?? null,
|
||||||
@@ -1483,6 +1484,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(video),
|
canonicalId: this.canonicalizer.getCanonicalId(video),
|
||||||
title: video.Name!,
|
title: video.Name!,
|
||||||
|
sortTitle: titleToSortTitle(video.Name!),
|
||||||
originalTitle: video.OriginalTitle ?? null,
|
originalTitle: video.OriginalTitle ?? null,
|
||||||
year: video.ProductionYear ?? null,
|
year: video.ProductionYear ?? null,
|
||||||
releaseDate: isError(parsedReleaseDate)
|
releaseDate: isError(parsedReleaseDate)
|
||||||
@@ -1509,7 +1511,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
tags: video.Tags?.filter(isNonEmptyString) ?? [],
|
tags: video.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
// summary: null,
|
// summary: null,
|
||||||
type: 'music_video',
|
type: 'music_video',
|
||||||
externalKey: video.Id,
|
|
||||||
mediaItem,
|
mediaItem,
|
||||||
identifiers: collectJellyfinItemIdentifiers(
|
identifiers: collectJellyfinItemIdentifiers(
|
||||||
video,
|
video,
|
||||||
@@ -1555,6 +1556,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
canonicalId: this.canonicalizer.getCanonicalId(video),
|
canonicalId: this.canonicalizer.getCanonicalId(video),
|
||||||
title: video.Name!,
|
title: video.Name!,
|
||||||
|
sortTitle: titleToSortTitle(video.Name!),
|
||||||
originalTitle: video.OriginalTitle ?? null,
|
originalTitle: video.OriginalTitle ?? null,
|
||||||
year: video.ProductionYear ?? null,
|
year: video.ProductionYear ?? null,
|
||||||
releaseDate: isError(parsedReleaseDate)
|
releaseDate: isError(parsedReleaseDate)
|
||||||
@@ -1581,7 +1583,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
|
|||||||
tags: video.Tags?.filter(isNonEmptyString) ?? [],
|
tags: video.Tags?.filter(isNonEmptyString) ?? [],
|
||||||
// summary: null,
|
// summary: null,
|
||||||
type: 'other_video',
|
type: 'other_video',
|
||||||
externalKey: video.Id,
|
|
||||||
mediaItem,
|
mediaItem,
|
||||||
identifiers: collectJellyfinItemIdentifiers(
|
identifiers: collectJellyfinItemIdentifiers(
|
||||||
video,
|
video,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ProgramDaoMinter } from '@/db/converters/ProgramMinter.js';
|
import { ProgramDaoMinter } from '@/db/converters/ProgramMinter.js';
|
||||||
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
|
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
|
||||||
import { ProgramType } from '@/db/schema/Program.js';
|
import { ProgramType } from '@/db/schema/Program.js';
|
||||||
|
import { MediaSourceType } from '@/db/schema/base.js';
|
||||||
import type { ProgramWithExternalIds } from '@/db/schema/derivedTypes.js';
|
import type { ProgramWithExternalIds } from '@/db/schema/derivedTypes.js';
|
||||||
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
||||||
import { GlobalScheduler } from '@/services/Scheduler.js';
|
import { GlobalScheduler } from '@/services/Scheduler.js';
|
||||||
@@ -24,8 +25,7 @@ import {
|
|||||||
programExternalIdTypeFromJellyfinProvider,
|
programExternalIdTypeFromJellyfinProvider,
|
||||||
} from '../../db/custom_types/ProgramExternalIdType.ts';
|
} from '../../db/custom_types/ProgramExternalIdType.ts';
|
||||||
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
|
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
|
||||||
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
|
import { MediaSourceId } from '../../db/schema/base.js';
|
||||||
import { MediaSourceId } from '../../db/schema/base.ts';
|
|
||||||
import { ReconcileProgramDurationsTaskFactory } from '../../tasks/TasksModule.ts';
|
import { ReconcileProgramDurationsTaskFactory } from '../../tasks/TasksModule.ts';
|
||||||
import { JellyfinGetItemsQuery } from './JellyfinApiClient.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 { Nilable, Nullable } from '@/types/util.js';
|
||||||
import { type Maybe } from '@/types/util.js';
|
import { type Maybe } from '@/types/util.js';
|
||||||
import { getChannelId } from '@/util/channels.js';
|
import { getChannelId } from '@/util/channels.js';
|
||||||
@@ -89,8 +90,7 @@ import { match, P } from 'ts-pattern';
|
|||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import type { z } from 'zod/v4';
|
import type { z } from 'zod/v4';
|
||||||
import type { PageParams } from '../../db/interfaces/IChannelDB.ts';
|
import type { PageParams } from '../../db/interfaces/IChannelDB.ts';
|
||||||
import type { MediaSourceLibrary } from '../../db/schema/MediaSource.ts';
|
import type { MediaSourceLibraryOrm } from '../../db/schema/MediaSource.ts';
|
||||||
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
|
|
||||||
import { ProgramType, ProgramTypes } from '../../db/schema/Program.js';
|
import { ProgramType, ProgramTypes } from '../../db/schema/Program.js';
|
||||||
import { ProgramGroupingType } from '../../db/schema/ProgramGrouping.js';
|
import { ProgramGroupingType } from '../../db/schema/ProgramGrouping.js';
|
||||||
import type { Canonicalizer } from '../../services/Canonicalizer.ts';
|
import type { Canonicalizer } from '../../services/Canonicalizer.ts';
|
||||||
@@ -112,6 +112,7 @@ import type {
|
|||||||
import { Result } from '../../types/result.ts';
|
import { Result } from '../../types/result.ts';
|
||||||
import { parsePlexGuid } from '../../util/externalIds.ts';
|
import { parsePlexGuid } from '../../util/externalIds.ts';
|
||||||
import iterators from '../../util/iterator.ts';
|
import iterators from '../../util/iterator.ts';
|
||||||
|
import { titleToSortTitle } from '../../util/programs.ts';
|
||||||
import type { ApiClientOptions } from '../BaseApiClient.js';
|
import type { ApiClientOptions } from '../BaseApiClient.js';
|
||||||
import { QueryError, type QueryResult } from '../BaseApiClient.js';
|
import { QueryError, type QueryResult } from '../BaseApiClient.js';
|
||||||
import { MediaSourceApiClient } from '../MediaSourceApiClient.ts';
|
import { MediaSourceApiClient } from '../MediaSourceApiClient.ts';
|
||||||
@@ -366,7 +367,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
schema: z.ZodType<PlexMetadataResponse<ItemType>>,
|
schema: z.ZodType<PlexMetadataResponse<ItemType>>,
|
||||||
converter: (
|
converter: (
|
||||||
item: ItemType,
|
item: ItemType,
|
||||||
libraryId: MediaSourceLibrary,
|
libraryId: MediaSourceLibraryOrm,
|
||||||
) => Result<OutType>,
|
) => Result<OutType>,
|
||||||
pageSize: number = 50,
|
pageSize: number = 50,
|
||||||
key: string = `/library/sections/${libraryId}/all`,
|
key: string = `/library/sections/${libraryId}/all`,
|
||||||
@@ -659,7 +660,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
schema: z.ZodType<PlexMetadataResponse<ItemType>>,
|
schema: z.ZodType<PlexMetadataResponse<ItemType>>,
|
||||||
converter: (
|
converter: (
|
||||||
plexItem: ItemType,
|
plexItem: ItemType,
|
||||||
library: MediaSourceLibrary,
|
library: MediaSourceLibraryOrm,
|
||||||
) => Result<OutType>,
|
) => Result<OutType>,
|
||||||
): Promise<QueryResult<OutType>> {
|
): Promise<QueryResult<OutType>> {
|
||||||
const queryResult = await this.getItemMetadataInternal(externalKey, schema);
|
const queryResult = await this.getItemMetadataInternal(externalKey, schema);
|
||||||
@@ -678,7 +679,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
private findLibraryFromPlexMedia(
|
private findLibraryFromPlexMedia(
|
||||||
media: PlexMediaNoCollectionOrPlaylist,
|
media: PlexMediaNoCollectionOrPlaylist,
|
||||||
libraryId?: string,
|
libraryId?: string,
|
||||||
): QueryResult<MediaSourceLibrary> {
|
): QueryResult<MediaSourceLibraryOrm> {
|
||||||
libraryId ??= media.librarySectionID?.toString();
|
libraryId ??= media.librarySectionID?.toString();
|
||||||
if (!isNonEmptyString(libraryId)) {
|
if (!isNonEmptyString(libraryId)) {
|
||||||
return this.makeErrorResult(
|
return this.makeErrorResult(
|
||||||
@@ -1094,7 +1095,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
|
|
||||||
private plexShowInjection(
|
private plexShowInjection(
|
||||||
plexShow: ApiPlexTvShow,
|
plexShow: ApiPlexTvShow,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
): Result<PlexShow> {
|
): Result<PlexShow> {
|
||||||
return Result.success({
|
return Result.success({
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
@@ -1103,7 +1104,6 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
libraryId: mediaLibrary.uuid,
|
libraryId: mediaLibrary.uuid,
|
||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
sourceType: MediaSourceType.Plex,
|
sourceType: MediaSourceType.Plex,
|
||||||
externalKey: plexShow.ratingKey,
|
|
||||||
title: plexShow.title,
|
title: plexShow.title,
|
||||||
type: ProgramGroupingType.Show,
|
type: ProgramGroupingType.Show,
|
||||||
year: plexShow.year ?? null,
|
year: plexShow.year ?? null,
|
||||||
@@ -1145,12 +1145,13 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
externalId: plexShow.ratingKey,
|
externalId: plexShow.ratingKey,
|
||||||
childCount: plexShow.childCount,
|
childCount: plexShow.childCount,
|
||||||
grandchildCount: plexShow.leafCount,
|
grandchildCount: plexShow.leafCount,
|
||||||
|
sortTitle: titleToSortTitle(plexShow.title),
|
||||||
} satisfies PlexShow);
|
} satisfies PlexShow);
|
||||||
}
|
}
|
||||||
|
|
||||||
private plexSeasonInjection(
|
private plexSeasonInjection(
|
||||||
plexSeason: ApiPlexTvSeason,
|
plexSeason: ApiPlexTvSeason,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
): Result<PlexSeason> {
|
): Result<PlexSeason> {
|
||||||
return Result.success({
|
return Result.success({
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
@@ -1159,8 +1160,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
libraryId: mediaLibrary.uuid,
|
libraryId: mediaLibrary.uuid,
|
||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
sourceType: MediaSourceType.Plex,
|
sourceType: MediaSourceType.Plex,
|
||||||
externalKey: plexSeason.ratingKey,
|
|
||||||
title: plexSeason.title,
|
title: plexSeason.title,
|
||||||
|
sortTitle: titleToSortTitle(plexSeason.title),
|
||||||
type: ProgramGroupingType.Season,
|
type: ProgramGroupingType.Season,
|
||||||
index: plexSeason.index,
|
index: plexSeason.index,
|
||||||
releaseDate: null,
|
releaseDate: null,
|
||||||
@@ -1196,8 +1197,10 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
childCount: plexSeason.leafCount,
|
childCount: plexSeason.leafCount,
|
||||||
show: plexSeason.parentRatingKey
|
show: plexSeason.parentRatingKey
|
||||||
? ({
|
? ({
|
||||||
|
sortTitle: plexSeason.parentTitle
|
||||||
|
? titleToSortTitle(plexSeason.parentTitle)
|
||||||
|
: '',
|
||||||
externalId: plexSeason.parentRatingKey,
|
externalId: plexSeason.parentRatingKey,
|
||||||
externalKey: plexSeason.parentRatingKey,
|
|
||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
identifiers: compact([
|
identifiers: compact([
|
||||||
plexSeason.parentRatingKey
|
plexSeason.parentRatingKey
|
||||||
@@ -1239,7 +1242,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
|
|
||||||
private plexEpisodeInjection(
|
private plexEpisodeInjection(
|
||||||
plexEpisode: ApiPlexEpisode,
|
plexEpisode: ApiPlexEpisode,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
): Result<PlexEpisode> {
|
): Result<PlexEpisode> {
|
||||||
if (isNil(plexEpisode.duration) || plexEpisode.duration <= 0) {
|
if (isNil(plexEpisode.duration) || plexEpisode.duration <= 0) {
|
||||||
return Result.forError(
|
return Result.forError(
|
||||||
@@ -1271,8 +1274,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
type: ProgramType.Episode,
|
type: ProgramType.Episode,
|
||||||
sourceType: MediaSourceType.Plex,
|
sourceType: MediaSourceType.Plex,
|
||||||
externalKey: plexEpisode.ratingKey,
|
|
||||||
title: plexEpisode.title,
|
title: plexEpisode.title,
|
||||||
|
sortTitle: titleToSortTitle(plexEpisode.title),
|
||||||
originalTitle: null,
|
originalTitle: null,
|
||||||
year: null,
|
year: null,
|
||||||
summary: plexEpisode.summary ?? null,
|
summary: plexEpisode.summary ?? null,
|
||||||
@@ -1315,7 +1318,6 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
season: plexEpisode.parentRatingKey
|
season: plexEpisode.parentRatingKey
|
||||||
? {
|
? {
|
||||||
externalId: plexEpisode.parentRatingKey,
|
externalId: plexEpisode.parentRatingKey,
|
||||||
externalKey: plexEpisode.parentRatingKey,
|
|
||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
identifiers: compact([
|
identifiers: compact([
|
||||||
plexEpisode.parentRatingKey
|
plexEpisode.parentRatingKey
|
||||||
@@ -1341,6 +1343,9 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
studios: [],
|
studios: [],
|
||||||
sourceType: 'plex',
|
sourceType: 'plex',
|
||||||
title: plexEpisode.parentTitle ?? '',
|
title: plexEpisode.parentTitle ?? '',
|
||||||
|
sortTitle: plexEpisode.parentTitle
|
||||||
|
? titleToSortTitle(plexEpisode.parentTitle)
|
||||||
|
: '',
|
||||||
summary: null,
|
summary: null,
|
||||||
tagline: null,
|
tagline: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -1351,7 +1356,6 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
show: plexEpisode.grandparentRatingKey
|
show: plexEpisode.grandparentRatingKey
|
||||||
? ({
|
? ({
|
||||||
externalId: plexEpisode.grandparentRatingKey,
|
externalId: plexEpisode.grandparentRatingKey,
|
||||||
externalKey: plexEpisode.grandparentRatingKey,
|
|
||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
identifiers: compact([
|
identifiers: compact([
|
||||||
plexEpisode.grandparentRatingKey
|
plexEpisode.grandparentRatingKey
|
||||||
@@ -1376,6 +1380,9 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
studios: [],
|
studios: [],
|
||||||
sourceType: 'plex',
|
sourceType: 'plex',
|
||||||
title: plexEpisode.grandparentTitle ?? '',
|
title: plexEpisode.grandparentTitle ?? '',
|
||||||
|
sortTitle: plexEpisode.grandparentTitle
|
||||||
|
? titleToSortTitle(plexEpisode.grandparentTitle)
|
||||||
|
: '',
|
||||||
summary: null,
|
summary: null,
|
||||||
tagline: null,
|
tagline: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -1397,7 +1404,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
|
|
||||||
private plexMovieInjection(
|
private plexMovieInjection(
|
||||||
plexMovie: ApiPlexMovie,
|
plexMovie: ApiPlexMovie,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
): Result<PlexMovie> {
|
): Result<PlexMovie> {
|
||||||
if (isNil(plexMovie.duration) || plexMovie.duration <= 0) {
|
if (isNil(plexMovie.duration) || plexMovie.duration <= 0) {
|
||||||
return Result.forError(
|
return Result.forError(
|
||||||
@@ -1432,8 +1439,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
libraryId: mediaLibrary.uuid,
|
libraryId: mediaLibrary.uuid,
|
||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
sourceType: MediaSourceType.Plex,
|
sourceType: MediaSourceType.Plex,
|
||||||
externalKey: plexMovie.ratingKey,
|
|
||||||
title: plexMovie.title,
|
title: plexMovie.title,
|
||||||
|
sortTitle: titleToSortTitle(plexMovie.title),
|
||||||
originalTitle: null,
|
originalTitle: null,
|
||||||
year: plexMovie.year ?? null,
|
year: plexMovie.year ?? null,
|
||||||
releaseDate: plexMovie.originallyAvailableAt
|
releaseDate: plexMovie.originallyAvailableAt
|
||||||
@@ -1480,7 +1487,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
|
|
||||||
private plexOtherVideoInjection(
|
private plexOtherVideoInjection(
|
||||||
plexClip: ApiPlexMovie,
|
plexClip: ApiPlexMovie,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
): Result<PlexOtherVideo> {
|
): Result<PlexOtherVideo> {
|
||||||
if (isNil(plexClip.duration) || plexClip.duration <= 0) {
|
if (isNil(plexClip.duration) || plexClip.duration <= 0) {
|
||||||
return Result.forError(
|
return Result.forError(
|
||||||
@@ -1515,6 +1522,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
sourceType: MediaSourceType.Plex,
|
sourceType: MediaSourceType.Plex,
|
||||||
externalKey: plexClip.ratingKey,
|
externalKey: plexClip.ratingKey,
|
||||||
title: plexClip.title,
|
title: plexClip.title,
|
||||||
|
sortTitle: titleToSortTitle(plexClip.title),
|
||||||
originalTitle: null,
|
originalTitle: null,
|
||||||
year: plexClip.year ?? null,
|
year: plexClip.year ?? null,
|
||||||
releaseDate: plexClip.originallyAvailableAt
|
releaseDate: plexClip.originallyAvailableAt
|
||||||
@@ -1560,7 +1568,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
|
|
||||||
private plexMusicArtistInjection(
|
private plexMusicArtistInjection(
|
||||||
plexArtist: ApiPlexMusicArtist,
|
plexArtist: ApiPlexMusicArtist,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
): Result<PlexArtist> {
|
): Result<PlexArtist> {
|
||||||
return Result.success({
|
return Result.success({
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
@@ -1569,8 +1577,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
libraryId: mediaLibrary.uuid,
|
libraryId: mediaLibrary.uuid,
|
||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
sourceType: MediaSourceType.Plex,
|
sourceType: MediaSourceType.Plex,
|
||||||
externalKey: plexArtist.ratingKey,
|
|
||||||
title: plexArtist.title,
|
title: plexArtist.title,
|
||||||
|
sortTitle: titleToSortTitle(plexArtist.title),
|
||||||
type: ProgramGroupingType.Artist,
|
type: ProgramGroupingType.Artist,
|
||||||
tagline: null,
|
tagline: null,
|
||||||
genres: plexJoinItemInject(plexArtist.Genre),
|
genres: plexJoinItemInject(plexArtist.Genre),
|
||||||
@@ -1602,7 +1610,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
|
|
||||||
private plexAlbumInjection(
|
private plexAlbumInjection(
|
||||||
plexAlbum: ApiPlexMusicAlbum,
|
plexAlbum: ApiPlexMusicAlbum,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
): Result<PlexAlbum> {
|
): Result<PlexAlbum> {
|
||||||
return Result.success({
|
return Result.success({
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
@@ -1611,8 +1619,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
libraryId: mediaLibrary.uuid,
|
libraryId: mediaLibrary.uuid,
|
||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
sourceType: MediaSourceType.Plex,
|
sourceType: MediaSourceType.Plex,
|
||||||
externalKey: plexAlbum.ratingKey,
|
|
||||||
title: plexAlbum.title,
|
title: plexAlbum.title,
|
||||||
|
sortTitle: titleToSortTitle(plexAlbum.title),
|
||||||
type: ProgramGroupingType.Album,
|
type: ProgramGroupingType.Album,
|
||||||
index: plexAlbum.index,
|
index: plexAlbum.index,
|
||||||
genres: plexJoinItemInject(plexAlbum.Genre),
|
genres: plexJoinItemInject(plexAlbum.Genre),
|
||||||
@@ -1653,7 +1661,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
|
|
||||||
private plexTrackInjection(
|
private plexTrackInjection(
|
||||||
plexTrack: ApiPlexMusicTrack,
|
plexTrack: ApiPlexMusicTrack,
|
||||||
mediaLibrary: MediaSourceLibrary,
|
mediaLibrary: MediaSourceLibraryOrm,
|
||||||
): Result<PlexTrack, WrappedError> {
|
): Result<PlexTrack, WrappedError> {
|
||||||
if (isNil(plexTrack.duration) || plexTrack.duration <= 0) {
|
if (isNil(plexTrack.duration) || plexTrack.duration <= 0) {
|
||||||
return Result.forError(
|
return Result.forError(
|
||||||
@@ -1679,8 +1687,8 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
type: ProgramType.Track,
|
type: ProgramType.Track,
|
||||||
sourceType: MediaSourceType.Plex,
|
sourceType: MediaSourceType.Plex,
|
||||||
externalKey: plexTrack.ratingKey,
|
|
||||||
title: plexTrack.title,
|
title: plexTrack.title,
|
||||||
|
sortTitle: titleToSortTitle(plexTrack.title),
|
||||||
originalTitle: null,
|
originalTitle: null,
|
||||||
year: plexTrack.parentYear ?? null,
|
year: plexTrack.parentYear ?? null,
|
||||||
duration: plexTrack.duration ?? 0,
|
duration: plexTrack.duration ?? 0,
|
||||||
@@ -1722,7 +1730,6 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
album: plexTrack.parentRatingKey
|
album: plexTrack.parentRatingKey
|
||||||
? {
|
? {
|
||||||
externalId: plexTrack.parentRatingKey,
|
externalId: plexTrack.parentRatingKey,
|
||||||
externalKey: plexTrack.parentRatingKey,
|
|
||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
identifiers: compact([
|
identifiers: compact([
|
||||||
plexTrack.parentRatingKey
|
plexTrack.parentRatingKey
|
||||||
@@ -1748,6 +1755,9 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
studios: [],
|
studios: [],
|
||||||
sourceType: 'plex',
|
sourceType: 'plex',
|
||||||
title: plexTrack.parentTitle ?? '',
|
title: plexTrack.parentTitle ?? '',
|
||||||
|
sortTitle: plexTrack.parentTitle
|
||||||
|
? titleToSortTitle(plexTrack.parentTitle)
|
||||||
|
: '',
|
||||||
summary: null,
|
summary: null,
|
||||||
tagline: null,
|
tagline: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -1758,7 +1768,6 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
artist: plexTrack.grandparentRatingKey
|
artist: plexTrack.grandparentRatingKey
|
||||||
? ({
|
? ({
|
||||||
externalId: plexTrack.grandparentRatingKey,
|
externalId: plexTrack.grandparentRatingKey,
|
||||||
externalKey: plexTrack.grandparentRatingKey,
|
|
||||||
externalLibraryId: mediaLibrary.externalKey,
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
identifiers: compact([
|
identifiers: compact([
|
||||||
plexTrack.grandparentRatingKey
|
plexTrack.grandparentRatingKey
|
||||||
@@ -1780,6 +1789,9 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|||||||
plot: null,
|
plot: null,
|
||||||
sourceType: 'plex',
|
sourceType: 'plex',
|
||||||
title: plexTrack.grandparentTitle ?? '',
|
title: plexTrack.grandparentTitle ?? '',
|
||||||
|
sortTitle: plexTrack.grandparentTitle
|
||||||
|
? titleToSortTitle(plexTrack.grandparentTitle)
|
||||||
|
: '',
|
||||||
summary: null,
|
summary: null,
|
||||||
tagline: null,
|
tagline: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ function calculateScaledSize(
|
|||||||
videoStream: VideoStreamDetails,
|
videoStream: VideoStreamDetails,
|
||||||
) {
|
) {
|
||||||
const { widthPx: targetW, heightPx: targetH } = config.resolution;
|
const { widthPx: targetW, heightPx: targetH } = config.resolution;
|
||||||
const [width, height] = videoStream.sampleAspectRatio
|
const [width, height] = (videoStream.sampleAspectRatio ?? '1:1')
|
||||||
.split(':')
|
.split(':')
|
||||||
.map((i) => parseInt(i));
|
.map((i) => parseInt(i));
|
||||||
const sarSize: Resolution = { widthPx: width, heightPx: height };
|
const sarSize: Resolution = { widthPx: width, heightPx: height };
|
||||||
|
|||||||
@@ -314,9 +314,9 @@ export class FfmpegStreamFactory extends IFFMPEG {
|
|||||||
if (streamDetails.videoDetails) {
|
if (streamDetails.videoDetails) {
|
||||||
const [videoStreamDetails] = streamDetails.videoDetails;
|
const [videoStreamDetails] = streamDetails.videoDetails;
|
||||||
|
|
||||||
const streamIndex = isNonEmptyString(videoStreamDetails.streamIndex)
|
const streamIndex = isUndefined(videoStreamDetails.streamIndex)
|
||||||
? parseInt(videoStreamDetails.streamIndex)
|
? 0
|
||||||
: 0;
|
: videoStreamDetails.streamIndex;
|
||||||
|
|
||||||
let pixelFormat: Maybe<PixelFormat>;
|
let pixelFormat: Maybe<PixelFormat>;
|
||||||
if (videoStreamDetails.pixelFormat) {
|
if (videoStreamDetails.pixelFormat) {
|
||||||
|
|||||||
@@ -140,10 +140,10 @@ export class SubtitleStreamPicker {
|
|||||||
const cacheFolder = this.getCacheFolder();
|
const cacheFolder = this.getCacheFolder();
|
||||||
const filePath = getSubtitleCacheFilePath(
|
const filePath = getSubtitleCacheFilePath(
|
||||||
{
|
{
|
||||||
id: lineupItem.programId,
|
id: lineupItem.program.uuid,
|
||||||
externalKey: lineupItem.externalKey,
|
externalKey: lineupItem.program.externalKey,
|
||||||
externalSourceId: lineupItem.externalSourceId,
|
externalSourceId: lineupItem.program.mediaSourceId,
|
||||||
externalSourceType: lineupItem.externalSource,
|
externalSourceType: lineupItem.program.sourceType,
|
||||||
},
|
},
|
||||||
stream,
|
stream,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,9 +115,10 @@ export class NvidiaGpuDetectionHelper {
|
|||||||
'null',
|
'null',
|
||||||
'-',
|
'-',
|
||||||
],
|
],
|
||||||
true,
|
{
|
||||||
{},
|
swallowError: true,
|
||||||
true,
|
isPath: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const lines = reject(
|
const lines = reject(
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ export class QsvHardwareCapabilitiesFactory
|
|||||||
const output = await new ChildProcessHelper().getStdout(
|
const output = await new ChildProcessHelper().getStdout(
|
||||||
this.ffmpegSettings.ffmpegExecutablePath,
|
this.ffmpegSettings.ffmpegExecutablePath,
|
||||||
['-hide_banner', '-help', 'decoder=h264_qsv'],
|
['-hide_banner', '-help', 'decoder=h264_qsv'],
|
||||||
false,
|
|
||||||
{},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const nonEmptyLines = reject(map(drop(split(output, '\n'), 1), trim), (s) =>
|
const nonEmptyLines = reject(map(drop(split(output, '\n'), 1), trim), (s) =>
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ export class VainfoProcessHelper {
|
|||||||
new ChildProcessHelper().getStdout(
|
new ChildProcessHelper().getStdout(
|
||||||
'vainfo',
|
'vainfo',
|
||||||
['--display', display, '--device', vaapiDevice, '-a'],
|
['--display', display, '--device', vaapiDevice, '-a'],
|
||||||
swallowError,
|
{
|
||||||
isNonEmptyString(vaapiDriver)
|
swallowError,
|
||||||
? { LIBVA_DRIVER_NAME: vaapiDriver }
|
env: isNonEmptyString(vaapiDriver)
|
||||||
: undefined,
|
? { LIBVA_DRIVER_NAME: vaapiDriver }
|
||||||
swallowError,
|
: undefined,
|
||||||
|
isPath: false,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { FfprobeMediaInfoSchema } from '@/types/ffmpeg.js';
|
|||||||
import { KEYS } from '@/types/inject.js';
|
import { KEYS } from '@/types/inject.js';
|
||||||
import { Result } from '@/types/result.js';
|
import { Result } from '@/types/result.js';
|
||||||
import { Nullable } from '@/types/util.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 { cacheGetOrSet } from '@/util/cache.js';
|
||||||
import dayjs from '@/util/dayjs.js';
|
import dayjs from '@/util/dayjs.js';
|
||||||
import { Logger } from '@/util/logging/LoggerFactory.js';
|
import { Logger } from '@/util/logging/LoggerFactory.js';
|
||||||
@@ -303,18 +306,21 @@ export class FfmpegInfo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async probeFile(path: string) {
|
async probeFile(path: string, timeout?: number) {
|
||||||
const output = await this.getFfprobeStdout([
|
const output = await this.getFfprobeStdout(
|
||||||
'-hide_banner',
|
[
|
||||||
'-print_format',
|
'-hide_banner',
|
||||||
'json',
|
'-print_format',
|
||||||
'-show_format',
|
'json',
|
||||||
'-show_chapters',
|
'-show_format',
|
||||||
'-show_streams',
|
'-show_chapters',
|
||||||
'-analyzeduration',
|
'-show_streams',
|
||||||
'30',
|
'-analyzeduration',
|
||||||
`${path}`,
|
'30',
|
||||||
]);
|
`${path}`,
|
||||||
|
],
|
||||||
|
{ timeout, swallowError: false },
|
||||||
|
);
|
||||||
|
|
||||||
const result = await FfprobeMediaInfoSchema.safeParseAsync(
|
const result = await FfprobeMediaInfoSchema.safeParseAsync(
|
||||||
JSON.parse(output),
|
JSON.parse(output),
|
||||||
@@ -335,32 +341,24 @@ export class FfmpegInfo {
|
|||||||
|
|
||||||
private getFfmpegStdout(
|
private getFfmpegStdout(
|
||||||
args: string[],
|
args: string[],
|
||||||
swallowError: boolean = false,
|
opts: GetStdoutOptions = { swallowError: false },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return this.getStdout(this.ffmpegPath, args, swallowError);
|
return this.getStdout(this.ffmpegPath, args, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFfprobeStdout(
|
private getFfprobeStdout(
|
||||||
args: string[],
|
args: string[],
|
||||||
swallowError: boolean = false,
|
opts: GetStdoutOptions = { swallowError: false },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return this.getStdout(this.ffprobePath, args, swallowError);
|
return this.getStdout(this.ffprobePath, args, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStdout(
|
private getStdout(
|
||||||
executable: string,
|
executable: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
swallowError: boolean = false,
|
opts?: GetStdoutOptions,
|
||||||
env?: NodeJS.ProcessEnv,
|
|
||||||
isPath: boolean = true,
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return new ChildProcessHelper().getStdout(
|
return new ChildProcessHelper().getStdout(executable, args, opts);
|
||||||
executable,
|
|
||||||
args,
|
|
||||||
swallowError,
|
|
||||||
env,
|
|
||||||
isPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private cacheKey(key: keyof typeof CacheKeys): string {
|
private cacheKey(key: keyof typeof CacheKeys): string {
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import type { StreamLineupItem } from '../db/derived_types/StreamLineup.ts';
|
import type { StreamLineupItem } from '../db/derived_types/StreamLineup.ts';
|
||||||
|
|
||||||
export interface IStreamLineupCache {
|
export interface IStreamLineupCache {
|
||||||
getCurrentLineupItem(
|
|
||||||
channelId: string,
|
|
||||||
timeNow: number,
|
|
||||||
): StreamLineupItem | undefined;
|
|
||||||
|
|
||||||
getProgramLastPlayTime(channelId: string, programId: string): number;
|
getProgramLastPlayTime(channelId: string, programId: string): number;
|
||||||
|
|
||||||
getFillerLastPlayTime(channelId: string, fillerId: string): number;
|
getFillerLastPlayTime(channelId: string, fillerId: string): number;
|
||||||
@@ -17,6 +12,4 @@ export interface IStreamLineupCache {
|
|||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
clear(): 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 Migration1756381281_AddLibraries from './db/Migration1756381281_AddLibraries.ts';
|
||||||
import Migration1757704591_AddProgramMediaSourceIndex from './db/Migration1757704591_AddProgramMediaSourceIndex.ts';
|
import Migration1757704591_AddProgramMediaSourceIndex from './db/Migration1757704591_AddProgramMediaSourceIndex.ts';
|
||||||
import Migration1758203109_AddProgramMedia from './db/Migration1758203109_AddProgramMedia.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 = [
|
export const LegacyMigrationNameToNewMigrationName = [
|
||||||
['Migration20240124115044', '_Legacy_Migration00'],
|
['Migration20240124115044', '_Legacy_Migration00'],
|
||||||
@@ -119,6 +127,21 @@ export class DirectMigrationProvider implements MigrationProvider {
|
|||||||
migration1756381281: Migration1756381281_AddLibraries,
|
migration1756381281: Migration1756381281_AddLibraries,
|
||||||
migration1757704591: Migration1757704591_AddProgramMediaSourceIndex,
|
migration1757704591: Migration1757704591_AddProgramMediaSourceIndex,
|
||||||
migration1758203109: Migration1758203109_AddProgramMedia,
|
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,
|
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,
|
WithCreatedAt,
|
||||||
WithUpdatedAt,
|
WithUpdatedAt,
|
||||||
WithUuid,
|
WithUuid,
|
||||||
} from '../../db/schema/base.ts';
|
} from '../../db/schema/base.js';
|
||||||
|
|
||||||
interface CurrentProgramExternalIdTable
|
interface CurrentProgramExternalIdTable
|
||||||
extends WithUuid,
|
extends WithUuid,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
WithCreatedAt,
|
WithCreatedAt,
|
||||||
WithUpdatedAt,
|
WithUpdatedAt,
|
||||||
WithUuid,
|
WithUuid,
|
||||||
} from '../../db/schema/base.ts';
|
} from '../../db/schema/base.js';
|
||||||
|
|
||||||
interface CurrentProgramExternalIdTable
|
interface CurrentProgramExternalIdTable
|
||||||
extends WithUuid,
|
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 { ProgramGroupingType } from '@/db/schema/ProgramGrouping.js';
|
||||||
import type { Kysely } from 'kysely';
|
import type { Kysely } from 'kysely';
|
||||||
import { CompiledQuery, sql } from 'kysely';
|
import { CompiledQuery, sql } from 'kysely';
|
||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
WithCreatedAt,
|
WithCreatedAt,
|
||||||
WithUpdatedAt,
|
WithUpdatedAt,
|
||||||
WithUuid,
|
WithUuid,
|
||||||
} from '../../db/schema/base.ts';
|
} from '../../db/schema/base.js';
|
||||||
|
|
||||||
interface ProgramGroupingInMigration
|
interface ProgramGroupingInMigration
|
||||||
extends WithUuid,
|
extends WithUuid,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ChannelFillerShowTable } from '@/db/schema/Channel.js';
|
|
||||||
import type { Kysely, Migration } from 'kysely';
|
import type { Kysely, Migration } from 'kysely';
|
||||||
import { CompiledQuery } from 'kysely';
|
import { CompiledQuery } from 'kysely';
|
||||||
|
import type { ChannelFillerShowTable } from '../../db/schema/ChannelFillerShow.ts';
|
||||||
|
|
||||||
type DBTemp = {
|
type DBTemp = {
|
||||||
channelFillerShowTmp: ChannelFillerShowTable;
|
channelFillerShowTmp: ChannelFillerShowTable;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CustomShowContent } from '@/db/schema/CustomShow.js';
|
|
||||||
import type { Kysely, Migration } from 'kysely';
|
import type { Kysely, Migration } from 'kysely';
|
||||||
import { CompiledQuery } from 'kysely';
|
import { CompiledQuery } from 'kysely';
|
||||||
|
import type { CustomShowContent } from '../../db/schema/CustomShowContent.ts';
|
||||||
|
|
||||||
type DBTemp = {
|
type DBTemp = {
|
||||||
customShowContentTmp: CustomShowContent;
|
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