Compare commits
443 Commits
v10.11.0-rc5
...
upstream
| Author | SHA1 | Date | |
|---|---|---|---|
| d4f9c12c22 | |||
| d46539b718 | |||
| 13f49305da | |||
| 7d4cef51f5 | |||
| a9aa2d53ed | |||
| 08b87504b4 | |||
| df27faaa41 | |||
| fe9ea18918 | |||
| e71efc3f97 | |||
| 9817185fa3 | |||
| c2a11f3e68 | |||
| d60ae43b59 | |||
| a187ab18b8 | |||
| cf5268c1e4 | |||
| f405e17c96 | |||
| 67300ec17f | |||
| 2b4a0a2314 | |||
| d84a37e1d2 | |||
| 465b6c5d9e | |||
| b975195252 | |||
| 1c28df61df | |||
| c69b8ccc53 | |||
| 138081bd8b | |||
| ea732b62d8 | |||
| e7c1451b6c | |||
| a49a766424 | |||
| ddf3b9f609 | |||
| 9c6fab8f12 | |||
| 992e591938 | |||
| 365036f9f2 | |||
| 93af601a5f | |||
| 894d837578 | |||
| e79caccab1 | |||
| 25b137e0ab | |||
| 75ef410437 | |||
| 849f0a06c8 | |||
| 2e5fb57052 | |||
| e72bde9c02 | |||
| 35a1ec8a5d | |||
| a0c38131c8 | |||
| 3bc45ff92d | |||
| 2d4d6d8c38 | |||
| 9a99572354 | |||
| b2aa80ce5c | |||
| ff365dae34 | |||
| 52aebfb7d3 | |||
| 66ea1b50e6 | |||
| 3f656ade7a | |||
| 8bf0d372c6 | |||
| 202d7b5829 | |||
| 352e4f3aba | |||
| c5f6d00c94 | |||
| e8d1d94436 | |||
| 50dc37065b | |||
| 7e88b18192 | |||
| 89e914c7f1 | |||
| 1932ac4765 | |||
| ec33c74ec4 | |||
| 2184ed1b16 | |||
| d3907afde7 | |||
| e12d933531 | |||
| c0ba29d917 | |||
| d1fd81c382 | |||
| e038045494 | |||
| e1691e649e | |||
| 8d28497d29 | |||
| fddd4e7e6b | |||
| 0581cd6610 | |||
| 0f1732e5f5 | |||
| 41c2d51d8c | |||
| 29b2361857 | |||
| ce867f9834 | |||
| 4034bf9d7e | |||
| 3d2658fa43 | |||
| 61b19688ff | |||
| e8d72bf6a3 | |||
| 348b14f7b7 | |||
| fda49a5a49 | |||
| 55c00d76bb | |||
| 519d2113eb | |||
| f34f6b6941 | |||
| 6864e108b8 | |||
| 09ba04662a | |||
| 9cd2418095 | |||
| b6a96513de | |||
| ca57166e95 | |||
| 33496c1693 | |||
| b65daeca0b | |||
| 286cc6d720 | |||
| aa4f09c799 | |||
| afd3c0d9f3 | |||
| 5597d8e1a7 | |||
| 0166362258 | |||
| 58c330b63d | |||
| be71295693 | |||
| 8cd3090cee | |||
| 7bf08daeec | |||
| 290463fe7b | |||
| 1b2d9c100a | |||
| caa05c1bf2 | |||
| a37ead86df | |||
| e65aff8bc6 | |||
| 9734494eb6 | |||
| d41e302418 | |||
| 80ba517294 | |||
| 95d08b264f | |||
| 893a849f28 | |||
| 673f617994 | |||
| 644327eb76 | |||
| 10662e75e4 | |||
| a2b1936e73 | |||
| 2df546af6d | |||
| 338b480217 | |||
| 2943bb6fdd | |||
| 94edcbd2d1 | |||
| a8d1cdefac | |||
| a518160a6f | |||
| b56de6493f | |||
| 093cfc3f3b | |||
| 49775b1f6a | |||
| 22d593b8e9 | |||
| 2cb7fb52d2 | |||
| 8433b6d8a4 | |||
| 32d2414de0 | |||
| 317a3a47c3 | |||
| 845b8cdc8f | |||
| c86f6439c5 | |||
| 559e0088e5 | |||
| adaca95590 | |||
| 09a1c31fa3 | |||
| e4b82025b8 | |||
| 78e3702cb0 | |||
| 01b20d3b75 | |||
| 156761405e | |||
| 1805f2259f | |||
| 4c587776d6 | |||
| 8379b4634a | |||
| 9470439cfa | |||
| 18096e48e0 | |||
| f2d0ac7b28 | |||
| 2ccf08f547 | |||
| 1e27f460fe | |||
| 4cdd8c8233 | |||
| 6e60634c9f | |||
| 12c5d6b636 | |||
| b617c62f8e | |||
| 035b5895b0 | |||
| 22da5187c8 | |||
| 5804d6840c | |||
| b50ce1ad6b | |||
| 481ee03f35 | |||
| d91adb5d54 | |||
| ef7f138a4e | |||
| 2e8d9a311b | |||
| 4c5a3fbff3 | |||
| 636908fc4d | |||
| 997362fc97 | |||
| c5147341e3 | |||
| ca33bcebf0 | |||
| d32f487e8e | |||
| fb65f8f853 | |||
| 2a0b90e385 | |||
| dde70fd8a2 | |||
| 98d1d0cb35 | |||
| ba76a8f3ad | |||
| 8cd5652157 | |||
| 8aff4227d9 | |||
| 026f7472cb | |||
| daca285568 | |||
| fbb9a0b2c7 | |||
| 29b3aa8543 | |||
| 94f3725208 | |||
| 0ee81e87be | |||
| c491a918c2 | |||
| 1e7e46cb82 | |||
| 5ae444d96d | |||
| ee7ad83427 | |||
| 921d7d3364 | |||
| f8e012582a | |||
| def5956cd1 | |||
| abfbaca336 | |||
| 6566188e45 | |||
| 078f9584ed | |||
| ee34c75386 | |||
| e8150428b6 | |||
| 4b38e35bbb | |||
| 435bb14bb2 | |||
| 2e5ced5098 | |||
| f4a846aa4d | |||
| 7c1063177f | |||
| 5878b1ffc5 | |||
| 3c3c2aee0d | |||
| 511223aac4 | |||
| 3b2d64995a | |||
| 13c4517a66 | |||
| 177b6464ca | |||
| 5a9a8363f4 | |||
| 49efd68fc7 | |||
| 90a8a26c6e | |||
| 002c83e6f5 | |||
| 7222910b05 | |||
| 097cb87f6f | |||
| 91c3b1617e | |||
| 8f71922734 | |||
| d140630208 | |||
| 63a3e55297 | |||
| c2e5081d64 | |||
| 4187c6f620 | |||
| e7dbb3afec | |||
| f994dd6211 | |||
| da254ee968 | |||
| 4ad3141875 | |||
| b5f0199a25 | |||
| 6bf88c049e | |||
| 40a33da2a5 | |||
| 3596fc0693 | |||
| 93824dad97 | |||
| e5656af1f2 | |||
| c127c10458 | |||
| 7d1824ea27 | |||
| 2966d27c97 | |||
| 618ec4543e | |||
| 0e4031ae52 | |||
| 442af96ed9 | |||
| a305204cfa | |||
| 75f472e6a7 | |||
| cc32e8f7cb | |||
| 14b3085ff1 | |||
| 5691eee4f1 | |||
| 1520a697ad | |||
| 81b8b0ca4a | |||
| ac3fa3c376 | |||
| 7a1c1cd342 | |||
| 70c32a26fa | |||
| 2b94bb54aa | |||
| 0a6e8146be | |||
| 305b0fdca3 | |||
| d738386fe2 | |||
| ca830d5be7 | |||
| a5bc4524d8 | |||
| 175ee12bbc | |||
| a725220c21 | |||
| a245605152 | |||
| f4a53209f4 | |||
| 877251bcae | |||
| ace30afcf8 | |||
| fc056b6273 | |||
| ac5efb4775 | |||
| fefd676adc | |||
| 59c17a663c | |||
| 641551e164 | |||
| bd543d7ac3 | |||
| 545e412259 | |||
| 7dff92bb82 | |||
| b36aab9399 | |||
| 2c7d2d4719 | |||
| 5c519270b8 | |||
| 55047b1183 | |||
| 794e1361d7 | |||
| 27c9c9c0ed | |||
| 68636b2390 | |||
| 2e6430c4f4 | |||
| c88d792963 | |||
| 73dbc9e89f | |||
| cf3edd9875 | |||
| ef0131ad69 | |||
| 056c318f04 | |||
| 49c3443b0c | |||
| e415718fe7 | |||
| 8abcfb2a80 | |||
| 9aadf97958 | |||
| 9e57121171 | |||
| b471811920 | |||
| 3cb99add76 | |||
| 001f1c4377 | |||
| 9ef3706b44 | |||
| 864d6d0b8f | |||
| a565e4896e | |||
| ceef9143ad | |||
| a7a92509c7 | |||
| e876e784da | |||
| 9b7d5edc86 | |||
| f01cddf273 | |||
| 0d4bd0495b | |||
| 6f9c4dea6e | |||
| 8c51920911 | |||
| 8f2fd65810 | |||
| 953659980f | |||
| 8ab1fecb70 | |||
| f5d42ee180 | |||
| e28d547006 | |||
| b3b9f74014 | |||
| 07d31c6ba5 | |||
| a9198e865e | |||
| 79ff0b0b00 | |||
| 2b45a984dd | |||
| 739642b330 | |||
| 6097045d71 | |||
| 51e20a14c2 | |||
| eb0d05cf1e | |||
| d3d5915f31 | |||
| 288640a5d0 | |||
| ff0a1b999f | |||
| da0fe7455e | |||
| bf69f9d8a8 | |||
| badf22fcc2 | |||
| b59e9f90f0 | |||
| 056b92dbd5 | |||
| ba80f5e416 | |||
| 97ec4c1da2 | |||
| 894ba1a410 | |||
| 0a0aaefad5 | |||
| c8b97bf533 | |||
| cfa4e357ea | |||
| 0f42aa892e | |||
| cce6bf27e0 | |||
| d6cebf1e67 | |||
| c053a6cd78 | |||
| d8c62420bf | |||
| d483c3efe6 | |||
| 275c1a3cc1 | |||
| 4942b2c15f | |||
| 3fc71293b4 | |||
| 8ea9bece03 | |||
| baa7f5f0b0 | |||
| b9c96f3d2c | |||
| 08f9b932ac | |||
| e6cd73df03 | |||
| 71ebb1f456 | |||
| 9c298c52f5 | |||
| 3e8db40901 | |||
| f9ead9615c | |||
| 93af2d6f67 | |||
| 027c91949d | |||
| 526ec83305 | |||
| dfcacce1b0 | |||
| 2a54669a8a | |||
| 54d48fa446 | |||
| 1736a566cc | |||
| 04ab362e59 | |||
| e282b05b8f | |||
| 2aa39226c6 | |||
| 60fbd39bb9 | |||
| 740b9924a0 | |||
| 5a6d9180fe | |||
| 897975fc57 | |||
| 7dab62616f | |||
| f1bd9a40d5 | |||
| 469e6e1bc8 | |||
| 38f5f8008a | |||
| 7bb68d8610 | |||
| 27047c35a4 | |||
| 42003ca9d2 | |||
| 98f5e21bb8 | |||
| 162985bb23 | |||
| 0d2c551cce | |||
| 717e7cbd77 | |||
| 58f9bdcf5c | |||
| 2a499aaa95 | |||
| 4246825239 | |||
| 68810c690b | |||
| b73ea1b99d | |||
| 59f77c24c9 | |||
| 0949212993 | |||
| 248aac9a3a | |||
| a1b85a63e7 | |||
| 091cb1c34a | |||
| eaf33f01e1 | |||
| db2dbaa62b | |||
| 1a7df6daf7 | |||
| a0b3e2b071 | |||
| 2618a5fba2 | |||
| 2ee887a502 | |||
| a17e157d44 | |||
| 6b6745b7fe | |||
| 594f9e4f6b | |||
| 4cda5f5ff2 | |||
| 24410d8a2e | |||
| 4d36bd635d | |||
| ef65534071 | |||
| 7c6cedd90a | |||
| 96590eea85 | |||
| 6796b3435d | |||
| 8776a447d1 | |||
| c02a24e32a | |||
| deee04ae38 | |||
| 580db0c1d2 | |||
| 8fcc2496d9 | |||
| f0e60a7ff3 | |||
| a99e67544a | |||
| bca6400bc3 | |||
| 986a509955 | |||
| da19f02f7b | |||
| 3fad5eb069 | |||
| 9923a51aed | |||
| 585e9a2fe2 | |||
| 8e81737dba | |||
| e4e578b37a | |||
| 387bc0c8eb | |||
| cbb569a277 | |||
| 1fa63b797b | |||
| aa3a7c88a4 | |||
| 0a2cf69a55 | |||
| 0845b0c258 | |||
| e043f93a72 | |||
| 6ac2d707cb | |||
| 20f7ddbf8f | |||
| 4849486fa0 | |||
| 4ccd3da77a | |||
| bc28dc11c0 | |||
| d9eaeed61d | |||
| c7320dc189 | |||
| 71048917dd | |||
| 11eab1b663 | |||
| a17a0495d8 | |||
| b3e57a5f7d | |||
| 65827cce6f | |||
| b5df0d2a34 | |||
| 339a31f0a5 | |||
| a0d4ae1974 | |||
| d65b18a7f3 | |||
| cc93b44947 | |||
| e753adac2c | |||
| 0b465842c8 | |||
| da3f3b09d9 | |||
| 7a9beb3745 | |||
| c7ee07b14a | |||
| d8dfbc26f6 | |||
| 88e0d35ed7 | |||
| 1eadb07a12 | |||
| 26d9633fed | |||
| 19aadd934b | |||
| ce28374d40 | |||
| 7aa1c46447 | |||
| ffb7753f8d | |||
| 14884f2628 | |||
| 41188ff054 | |||
| cb6e38d830 | |||
| 4ba34709d6 | |||
| 28b8d3ee29 | |||
| 9eaca73888 | |||
| 29e17b6bc0 | |||
| 84cde7383f |
@@ -3,7 +3,7 @@
|
|||||||
"isRoot": true,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"dotnet-ef": {
|
"dotnet-ef": {
|
||||||
"version": "9.0.8",
|
"version": "9.0.11",
|
||||||
"commands": [
|
"commands": [
|
||||||
"dotnet-ef"
|
"dotnet-ef"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -294,6 +294,9 @@ dotnet_diagnostic.CA1854.severity = error
|
|||||||
# error on CA1860: Avoid using 'Enumerable.Any()' extension method
|
# error on CA1860: Avoid using 'Enumerable.Any()' extension method
|
||||||
dotnet_diagnostic.CA1860.severity = error
|
dotnet_diagnostic.CA1860.severity = error
|
||||||
|
|
||||||
|
# error on CA1861: Avoid constant arrays as arguments
|
||||||
|
dotnet_diagnostic.CA1861.severity = error
|
||||||
|
|
||||||
# error on CA1862: Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
# error on CA1862: Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
dotnet_diagnostic.CA1862.severity = error
|
dotnet_diagnostic.CA1862.severity = error
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
runs-on: ubuntu-latest
|
# Disabled in fork — upstream CodeQL requires specific GitHub org permissions and .NET 10 support
|
||||||
|
if: false
|
||||||
|
runs-on: [self-hosted, k3s, linux, amd64]
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -20,18 +22,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '9.0.x'
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
|
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended
|
queries: +security-extended
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
|
uses: github/codeql-action/autobuild@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
|
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: ABI Compatibility
|
name: ABI Compatibility
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
@@ -11,13 +11,13 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '9.0.x'
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
dotnet build Jellyfin.Server -o ./out
|
dotnet build Jellyfin.Server -o ./out
|
||||||
|
|
||||||
- name: Upload Head
|
- name: Upload Head
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: abi-head
|
name: abi-head
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
@@ -40,14 +40,14 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '9.0.x'
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
dotnet build Jellyfin.Server -o ./out
|
dotnet build Jellyfin.Server -o ./out
|
||||||
|
|
||||||
- name: Upload Head
|
- name: Upload Head
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: abi-base
|
name: abi-base
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||||
|
|
||||||
name: ABI - Difference
|
name: ABI - Difference
|
||||||
if: ${{ github.event_name == 'pull_request_target' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- abi-head
|
- abi-head
|
||||||
@@ -85,13 +85,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download abi-head
|
- name: Download abi-head
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: abi-head
|
name: abi-head
|
||||||
path: abi-head
|
path: abi-head
|
||||||
|
|
||||||
- name: Download abi-base
|
- name: Download abi-base
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: abi-base
|
name: abi-base
|
||||||
path: abi-base
|
path: abi-base
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
} >> $GITHUB_OUTPUT
|
} >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Find difference comment
|
- name: Find difference comment
|
||||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
|
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||||
id: find-comment
|
id: find-comment
|
||||||
with:
|
with:
|
||||||
issue-number: ${{ github.event.pull_request.number }}
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
@@ -123,7 +123,7 @@ jobs:
|
|||||||
body-includes: abi-diff-workflow-comment
|
body-includes: abi-diff-workflow-comment
|
||||||
|
|
||||||
- name: Reply or edit difference comment (changed)
|
- name: Reply or edit difference comment (changed)
|
||||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||||
if: ${{ steps.diff.outputs.body != '' }}
|
if: ${{ steps.diff.outputs.body != '' }}
|
||||||
with:
|
with:
|
||||||
issue-number: ${{ github.event.pull_request.number }}
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
@@ -142,7 +142,7 @@ jobs:
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
- name: Reply or edit difference comment (unchanged)
|
- name: Reply or edit difference comment (unchanged)
|
||||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||||
if: ${{ steps.diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
|
if: ${{ steps.diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
|
||||||
with:
|
with:
|
||||||
issue-number: ${{ github.event.pull_request.number }}
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
- master
|
- master
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request_target:
|
pull_request:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
@@ -16,18 +16,18 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '9.0.x'
|
||||||
- name: Generate openapi.json
|
- name: Generate openapi.json
|
||||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||||
- name: Upload openapi.json
|
- name: Upload openapi.json
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-head
|
name: openapi-head
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
@@ -55,13 +55,13 @@ jobs:
|
|||||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
||||||
git checkout --progress --force $ANCESTOR_REF
|
git checkout --progress --force $ANCESTOR_REF
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '9.0.x'
|
||||||
- name: Generate openapi.json
|
- name: Generate openapi.json
|
||||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||||
- name: Upload openapi.json
|
- name: Upload openapi.json
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-base
|
name: openapi-base
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
@@ -73,19 +73,19 @@ jobs:
|
|||||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||||
|
|
||||||
name: OpenAPI - Difference
|
name: OpenAPI - Difference
|
||||||
if: ${{ github.event_name == 'pull_request_target' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- openapi-head
|
- openapi-head
|
||||||
- openapi-base
|
- openapi-base
|
||||||
steps:
|
steps:
|
||||||
- name: Download openapi-head
|
- name: Download openapi-head
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-head
|
name: openapi-head
|
||||||
path: openapi-head
|
path: openapi-head
|
||||||
- name: Download openapi-base
|
- name: Download openapi-base
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-base
|
name: openapi-base
|
||||||
path: openapi-base
|
path: openapi-base
|
||||||
@@ -120,14 +120,14 @@ jobs:
|
|||||||
echo "" >> openapi-changes-reply.md
|
echo "" >> openapi-changes-reply.md
|
||||||
echo "</details>" >> openapi-changes-reply.md
|
echo "</details>" >> openapi-changes-reply.md
|
||||||
- name: Find difference comment
|
- name: Find difference comment
|
||||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
|
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||||
id: find-comment
|
id: find-comment
|
||||||
with:
|
with:
|
||||||
issue-number: ${{ github.event.pull_request.number }}
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
direction: last
|
direction: last
|
||||||
body-includes: openapi-diff-workflow-comment
|
body-includes: openapi-diff-workflow-comment
|
||||||
- name: Reply or edit difference comment (changed)
|
- name: Reply or edit difference comment (changed)
|
||||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||||
if: ${{ steps.read-diff.outputs.ApiChanged == '1' }}
|
if: ${{ steps.read-diff.outputs.ApiChanged == '1' }}
|
||||||
with:
|
with:
|
||||||
issue-number: ${{ github.event.pull_request.number }}
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
@@ -135,7 +135,7 @@ jobs:
|
|||||||
edit-mode: replace
|
edit-mode: replace
|
||||||
body-path: openapi-changes-reply.md
|
body-path: openapi-changes-reply.md
|
||||||
- name: Edit difference comment (unchanged)
|
- name: Edit difference comment (unchanged)
|
||||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||||
if: ${{ steps.read-diff.outputs.ApiChanged == '0' && steps.find-comment.outputs.comment-id != '' }}
|
if: ${{ steps.read-diff.outputs.ApiChanged == '0' && steps.find-comment.outputs.comment-id != '' }}
|
||||||
with:
|
with:
|
||||||
issue-number: ${{ github.event.pull_request.number }}
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
@@ -148,7 +148,7 @@ jobs:
|
|||||||
|
|
||||||
publish-unstable:
|
publish-unstable:
|
||||||
name: OpenAPI - Publish Unstable Spec
|
name: OpenAPI - Publish Unstable Spec
|
||||||
if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- openapi-head
|
- openapi-head
|
||||||
@@ -158,7 +158,7 @@ jobs:
|
|||||||
run: |-
|
run: |-
|
||||||
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
|
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
|
||||||
- name: Download openapi-head
|
- name: Download openapi-head
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-head
|
name: openapi-head
|
||||||
path: openapi-head
|
path: openapi-head
|
||||||
@@ -172,7 +172,7 @@ jobs:
|
|||||||
strip_components: 1
|
strip_components: 1
|
||||||
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||||
- name: Move openapi.json (unstable) into place
|
- name: Move openapi.json (unstable) into place
|
||||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||||
with:
|
with:
|
||||||
host: "${{ secrets.REPO_HOST }}"
|
host: "${{ secrets.REPO_HOST }}"
|
||||||
username: "${{ secrets.REPO_USER }}"
|
username: "${{ secrets.REPO_USER }}"
|
||||||
@@ -220,7 +220,7 @@ jobs:
|
|||||||
run: |-
|
run: |-
|
||||||
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||||
- name: Download openapi-head
|
- name: Download openapi-head
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-head
|
name: openapi-head
|
||||||
path: openapi-head
|
path: openapi-head
|
||||||
@@ -234,7 +234,7 @@ jobs:
|
|||||||
strip_components: 1
|
strip_components: 1
|
||||||
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||||
- name: Move openapi.json (stable) into place
|
- name: Move openapi.json (stable) into place
|
||||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||||
with:
|
with:
|
||||||
host: "${{ secrets.REPO_HOST }}"
|
host: "${{ secrets.REPO_HOST }}"
|
||||||
username: "${{ secrets.REPO_USER }}"
|
username: "${{ secrets.REPO_USER }}"
|
||||||
|
|||||||
@@ -13,29 +13,32 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
run-tests:
|
run-tests:
|
||||||
strategy:
|
runs-on: [self-hosted, k3s, linux, amd64]
|
||||||
matrix:
|
|
||||||
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
|
|
||||||
fail-fast: false
|
|
||||||
|
|
||||||
runs-on: "${{ matrix.os }}"
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
# Install .NET — use GITHUB_ENV (not GITHUB_PATH) to set PATH because
|
||||||
with:
|
# ARC self-hosted runner pods don't pick up GITHUB_PATH between steps.
|
||||||
dotnet-version: ${{ env.SDK_VERSION }}
|
- name: Install .NET SDK
|
||||||
|
run: |
|
||||||
|
DOTNET_INSTALL_DIR="$HOME/.dotnet"
|
||||||
|
mkdir -p "$DOTNET_INSTALL_DIR"
|
||||||
|
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
|
||||||
|
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
|
||||||
|
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Run DotNet CLI Tests
|
- name: Run DotNet CLI Tests
|
||||||
run: >
|
run: |
|
||||||
dotnet test Jellyfin.sln
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
--configuration Release
|
dotnet test Jellyfin.sln \
|
||||||
--collect:"XPlat Code Coverage"
|
--configuration Release \
|
||||||
--settings tests/coverletArgs.runsettings
|
--collect:"XPlat Code Coverage" \
|
||||||
--verbosity minimal
|
--settings tests/coverletArgs.runsettings \
|
||||||
|
--verbosity minimal \
|
||||||
|
--filter "Category!=RequiresDocker&FullyQualifiedName!~Integration"
|
||||||
|
|
||||||
- name: Merge code coverage results
|
- name: Merge code coverage results
|
||||||
uses: danielpalme/ReportGenerator-GitHub-Action@c4c5175a441c6603ec614f5084386dabe0e2295b # v5.4.12
|
uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
|
||||||
with:
|
with:
|
||||||
reports: "**/coverage.cobertura.xml"
|
reports: "**/coverage.cobertura.xml"
|
||||||
targetdir: "merged/"
|
targetdir: "merged/"
|
||||||
@@ -43,3 +46,57 @@ jobs:
|
|||||||
|
|
||||||
# TODO - which action / tool to use to publish code coverage results?
|
# TODO - which action / tool to use to publish code coverage results?
|
||||||
# - name: Publish code coverage results
|
# - name: Publish code coverage results
|
||||||
|
|
||||||
|
# Phase 5 transcode coverage gate — runs in parallel with run-tests.
|
||||||
|
# Explicitly targets the three test assemblies most affected by Phase 5 HLS
|
||||||
|
# session-sharing and PostgreSQL media-encoding changes so failures surface
|
||||||
|
# with a dedicated check status independent of the full test matrix.
|
||||||
|
run-phase5-tests:
|
||||||
|
runs-on: [self-hosted, k3s, linux, amd64]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Install .NET SDK
|
||||||
|
run: |
|
||||||
|
DOTNET_INSTALL_DIR="$HOME/.dotnet"
|
||||||
|
mkdir -p "$DOTNET_INSTALL_DIR"
|
||||||
|
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
|
||||||
|
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
|
||||||
|
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Run Phase 5 Transcode Tests (API)
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
dotnet test tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--collect:"XPlat Code Coverage" \
|
||||||
|
--settings tests/coverletArgs.runsettings \
|
||||||
|
--verbosity minimal \
|
||||||
|
--filter "Category!=RequiresDocker"
|
||||||
|
|
||||||
|
- name: Run Phase 5 Transcode Tests (HLS)
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
dotnet test tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--collect:"XPlat Code Coverage" \
|
||||||
|
--settings tests/coverletArgs.runsettings \
|
||||||
|
--verbosity minimal \
|
||||||
|
--filter "Category!=RequiresDocker"
|
||||||
|
|
||||||
|
- name: Run Phase 5 Transcode Tests (Server.Implementations)
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
dotnet test tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--collect:"XPlat Code Coverage" \
|
||||||
|
--settings tests/coverletArgs.runsettings \
|
||||||
|
--verbosity minimal \
|
||||||
|
--filter "Category!=RequiresDocker"
|
||||||
|
|
||||||
|
- name: Merge Phase 5 code coverage results
|
||||||
|
uses: danielpalme/ReportGenerator-GitHub-Action@2a7030e9775aab6c78e80cb66843051acdacee3e # v5.5.2
|
||||||
|
with:
|
||||||
|
reports: "**/coverage.cobertura.xml"
|
||||||
|
targetdir: "merged-phase5/"
|
||||||
|
reporttypes: "Cobertura"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
types:
|
types:
|
||||||
- created
|
- created
|
||||||
- edited
|
- edited
|
||||||
pull_request_target:
|
pull_request:
|
||||||
types:
|
types:
|
||||||
- labeled
|
- labeled
|
||||||
- synchronize
|
- synchronize
|
||||||
@@ -17,14 +17,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Notify as seen
|
- name: Notify as seen
|
||||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
comment-id: ${{ github.event.comment.id }}
|
comment-id: ${{ github.event.comment.id }}
|
||||||
reactions: '+1'
|
reactions: '+1'
|
||||||
|
|
||||||
- name: Checkout the latest code
|
- name: Checkout the latest code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -40,13 +40,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: pull in script
|
- name: pull in script
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
repository: jellyfin/jellyfin-triage-script
|
repository: jellyfin/jellyfin-triage-script
|
||||||
- name: install python
|
- name: install python
|
||||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.13'
|
python-version: '3.14'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
- name: install python packages
|
- name: install python packages
|
||||||
run: pip install -r rename/requirements.txt
|
run: pip install -r rename/requirements.txt
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
name: HA Build & Push to ECR
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
- "feat/ha-*"
|
||||||
|
- "feat/phase*"
|
||||||
|
- "copilot/*"
|
||||||
|
# pull_request intentionally removed: this workflow runs on self-hosted k3s
|
||||||
|
# runners. Allowing pull_request events from a public repo would let any
|
||||||
|
# internet user execute arbitrary code inside the cluster network.
|
||||||
|
# CI build feedback on PRs is provided by ci-tests.yml (GitHub-hosted runners).
|
||||||
|
|
||||||
|
# Cancel in-progress runs when a new push arrives on the same branch.
|
||||||
|
concurrency:
|
||||||
|
group: ha-build-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: [self-hosted, k3s, linux, amd64]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||||
|
with:
|
||||||
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
aws-region: ${{ secrets.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Login to Amazon ECR
|
||||||
|
id: ecr-login
|
||||||
|
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1
|
||||||
|
|
||||||
|
- name: Set image metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
REPO="${{ steps.ecr-login.outputs.registry }}/${{ secrets.ECR_REPOSITORY }}"
|
||||||
|
SHORT_SHA="${GITHUB_SHA::7}"
|
||||||
|
echo "image_repo=${REPO}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Install .NET SDK
|
||||||
|
# Build on the runner host filesystem (native I/O) to avoid DinD
|
||||||
|
# overlay-on-overlay throttling which makes dotnet publish ~20x slower.
|
||||||
|
run: |
|
||||||
|
DOTNET_INSTALL_DIR="$HOME/.dotnet"
|
||||||
|
mkdir -p "$DOTNET_INSTALL_DIR"
|
||||||
|
if ! "$DOTNET_INSTALL_DIR/dotnet" --version 2>/dev/null | grep -q "^10\\."; then
|
||||||
|
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
|
||||||
|
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Restore NuGet packages
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
dotnet restore Jellyfin.Server/Jellyfin.Server.csproj --runtime linux-x64
|
||||||
|
|
||||||
|
- name: Publish Jellyfin server
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--runtime linux-x64 \
|
||||||
|
--self-contained false \
|
||||||
|
--no-restore \
|
||||||
|
-p:TreatWarningsAsErrors=false \
|
||||||
|
--output ./publish-output
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile.runtime
|
||||||
|
platforms: linux/amd64
|
||||||
|
# Only push the image on direct branch pushes, not on pull_request events.
|
||||||
|
push: ${{ github.event_name == 'push' }}
|
||||||
|
provenance: false
|
||||||
|
tags: |
|
||||||
|
${{ steps.meta.outputs.image_repo }}:${{ steps.meta.outputs.short_sha }}
|
||||||
|
${{ steps.meta.outputs.image_repo }}:latest
|
||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
ascending: true
|
ascending: true
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: pull in script
|
- name: pull in script
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
repository: jellyfin/jellyfin-triage-script
|
repository: jellyfin/jellyfin-triage-script
|
||||||
- name: install python
|
- name: install python
|
||||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.13'
|
python-version: '3.14'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
- name: install python packages
|
- name: install python packages
|
||||||
run: pip install -r main-repo-triage/requirements.txt
|
run: pip install -r main-repo-triage/requirements.txt
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request_target:
|
pull_request:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request_target:
|
pull_request:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Apply label
|
- name: Apply label
|
||||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
|
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}}
|
||||||
with:
|
with:
|
||||||
dirtyLabel: 'merge conflict'
|
dirtyLabel: 'merge conflict'
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
ascending: true
|
ascending: true
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
yq-version: v4.9.8
|
yq-version: v4.9.8
|
||||||
|
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.TAG_BRANCH }}
|
ref: ${{ env.TAG_BRANCH }}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.TAG_BRANCH }}
|
ref: ${{ env.TAG_BRANCH }}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
- [DaveChild](https://github.com/DaveChild)
|
- [DaveChild](https://github.com/DaveChild)
|
||||||
- [DavidFair](https://github.com/DavidFair)
|
- [DavidFair](https://github.com/DavidFair)
|
||||||
- [Delgan](https://github.com/Delgan)
|
- [Delgan](https://github.com/Delgan)
|
||||||
|
- [Derpipose](https://github.com/Derpipose)
|
||||||
- [dcrdev](https://github.com/dcrdev)
|
- [dcrdev](https://github.com/dcrdev)
|
||||||
- [dhartung](https://github.com/dhartung)
|
- [dhartung](https://github.com/dhartung)
|
||||||
- [dinki](https://github.com/dinki)
|
- [dinki](https://github.com/dinki)
|
||||||
@@ -116,6 +117,7 @@
|
|||||||
- [sachk](https://github.com/sachk)
|
- [sachk](https://github.com/sachk)
|
||||||
- [sammyrc34](https://github.com/sammyrc34)
|
- [sammyrc34](https://github.com/sammyrc34)
|
||||||
- [samuel9554](https://github.com/samuel9554)
|
- [samuel9554](https://github.com/samuel9554)
|
||||||
|
- [SapientGuardian](https://github.com/SapientGuardian)
|
||||||
- [scheidleon](https://github.com/scheidleon)
|
- [scheidleon](https://github.com/scheidleon)
|
||||||
- [sebPomme](https://github.com/sebPomme)
|
- [sebPomme](https://github.com/sebPomme)
|
||||||
- [SegiH](https://github.com/SegiH)
|
- [SegiH](https://github.com/SegiH)
|
||||||
@@ -140,6 +142,7 @@
|
|||||||
- [ThibaultNocchi](https://github.com/ThibaultNocchi)
|
- [ThibaultNocchi](https://github.com/ThibaultNocchi)
|
||||||
- [thornbill](https://github.com/thornbill)
|
- [thornbill](https://github.com/thornbill)
|
||||||
- [ThreeFive-O](https://github.com/ThreeFive-O)
|
- [ThreeFive-O](https://github.com/ThreeFive-O)
|
||||||
|
- [tjwalkr3](https://github.com/tjwalkr3)
|
||||||
- [TrisMcC](https://github.com/TrisMcC)
|
- [TrisMcC](https://github.com/TrisMcC)
|
||||||
- [trumblejoe](https://github.com/trumblejoe)
|
- [trumblejoe](https://github.com/trumblejoe)
|
||||||
- [TtheCreator](https://github.com/TtheCreator)
|
- [TtheCreator](https://github.com/TtheCreator)
|
||||||
@@ -202,6 +205,9 @@
|
|||||||
- [Shoham Peller](https://github.com/spellr)
|
- [Shoham Peller](https://github.com/spellr)
|
||||||
- [theshoeshiner](https://github.com/theshoeshiner)
|
- [theshoeshiner](https://github.com/theshoeshiner)
|
||||||
- [TokerX](https://github.com/TokerX)
|
- [TokerX](https://github.com/TokerX)
|
||||||
|
- [GeneMarks](https://github.com/GeneMarks)
|
||||||
|
- [martenumberto](https://github.com/martenumberto)
|
||||||
|
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
|
||||||
|
|
||||||
# Emby Contributors
|
# Emby Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -19,4 +19,9 @@
|
|||||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)/stylecop.json" />
|
<AdditionalFiles Include="$(MSBuildThisFileDirectory)/stylecop.json" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Custom Analyzers -->
|
||||||
|
<ItemGroup Condition=" '$(MSBuildProjectName)' != 'Jellyfin.CodeAnalysis' AND '$(Configuration)' == 'Debug' ">
|
||||||
|
<ProjectReference Include="$(MSBuildThisFileDirectory)src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj" OutputItemType="Analyzer" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+44
-36
@@ -4,7 +4,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
||||||
<ItemGroup Label="Package Dependencies">
|
<ItemGroup Label="Package Dependencies">
|
||||||
<PackageVersion Include="AsyncKeyedLock" Version="7.1.6" />
|
<PackageVersion Include="AsyncKeyedLock" Version="7.1.8" />
|
||||||
|
<PackageVersion Include="AWSSDK.S3" Version="3.7.410.2" />
|
||||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||||
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
||||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
<PackageVersion Include="Diacritics" Version="4.0.17" />
|
<PackageVersion Include="Diacritics" Version="4.0.17" />
|
||||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.0" />
|
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
|
||||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
|
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
|
||||||
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
||||||
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
||||||
@@ -26,68 +27,75 @@
|
|||||||
<PackageVersion Include="libse" Version="4.0.12" />
|
<PackageVersion Include="libse" Version="4.0.12" />
|
||||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
|
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||||
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
||||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||||
<PackageVersion Include="NEbml" Version="1.0.0.3" />
|
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
<PackageVersion Include="Npgsql" Version="9.0.4" />
|
||||||
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||||
<PackageVersion Include="Polly" Version="8.6.2" />
|
<PackageVersion Include="Polly" Version="8.6.5" />
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||||
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
|
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
|
||||||
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
|
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
|
||||||
<PackageVersion Include="SkiaSharp" Version="3.116.1" />
|
<PackageVersion Include="SkiaSharp" Version="3.116.1" />
|
||||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.116.1" />
|
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.116.1" />
|
||||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
||||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||||
<PackageVersion Include="Svg.Skia" Version="3.0.5" />
|
<PackageVersion Include="Svg.Skia" Version="3.2.1" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
|
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
||||||
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
||||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.8" />
|
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" />
|
||||||
<PackageVersion Include="System.Text.Json" Version="9.0.8" />
|
<PackageVersion Include="System.Text.Json" Version="9.0.11" />
|
||||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.8" />
|
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
|
||||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||||
<PackageVersion Include="z440.atl.core" Version="7.3.0" />
|
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.4.0" />
|
||||||
<PackageVersion Include="TMDbLib" Version="2.2.0" />
|
<PackageVersion Include="z440.atl.core" Version="7.9.0" />
|
||||||
|
<PackageVersion Include="TMDbLib" Version="2.3.0" />
|
||||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||||
|
<PackageVersion Include="StackExchange.Redis" Version="2.8.16" />
|
||||||
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
||||||
|
|||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||||
|
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Restore dependencies first (layer-cache friendly)
|
||||||
|
COPY ["Jellyfin.sln", "global.json", "nuget.config", "Directory.Build.props", "Directory.Packages.props", "./"]
|
||||||
|
COPY ["SharedVersion.cs", "BannedSymbols.txt", "stylecop.json", "./"]
|
||||||
|
|
||||||
|
# Copy all project files so dotnet restore can resolve the full dependency graph
|
||||||
|
COPY Emby.Naming/ Emby.Naming/
|
||||||
|
COPY Emby.Photos/ Emby.Photos/
|
||||||
|
COPY Emby.Server.Implementations/ Emby.Server.Implementations/
|
||||||
|
COPY Jellyfin.Api/ Jellyfin.Api/
|
||||||
|
COPY Jellyfin.Data/ Jellyfin.Data/
|
||||||
|
COPY Jellyfin.Server/ Jellyfin.Server/
|
||||||
|
COPY Jellyfin.Server.Implementations/ Jellyfin.Server.Implementations/
|
||||||
|
COPY MediaBrowser.Common/ MediaBrowser.Common/
|
||||||
|
COPY MediaBrowser.Controller/ MediaBrowser.Controller/
|
||||||
|
COPY MediaBrowser.LocalMetadata/ MediaBrowser.LocalMetadata/
|
||||||
|
COPY MediaBrowser.MediaEncoding/ MediaBrowser.MediaEncoding/
|
||||||
|
COPY MediaBrowser.Model/ MediaBrowser.Model/
|
||||||
|
COPY MediaBrowser.Providers/ MediaBrowser.Providers/
|
||||||
|
COPY MediaBrowser.XbmcMetadata/ MediaBrowser.XbmcMetadata/
|
||||||
|
COPY src/ src/
|
||||||
|
|
||||||
|
RUN dotnet restore Jellyfin.Server/Jellyfin.Server.csproj \
|
||||||
|
--runtime linux-x64
|
||||||
|
|
||||||
|
# Publish the server (and all transitive dependencies, including the
|
||||||
|
# PostgreSQL provider assembly added by this fork).
|
||||||
|
# Note: TreatWarningsAsErrors is disabled for the Docker build — StyleCop
|
||||||
|
# analyzer violations in upstream src/ projects would otherwise block the
|
||||||
|
# image build. StyleCop is enforced in the CI pipeline, not the Dockerfile.
|
||||||
|
RUN dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--runtime linux-x64 \
|
||||||
|
--self-contained false \
|
||||||
|
--no-restore \
|
||||||
|
-p:TreatWarningsAsErrors=false \
|
||||||
|
--output /app
|
||||||
|
|
||||||
|
# ── Web client stage ──────────────────────────────────────────────────────────
|
||||||
|
# Install jellyfin-web via the official Jellyfin apt repo.
|
||||||
|
# Package suffix in the bookworm repo is +deb12 (e.g. 10.11.6+deb12).
|
||||||
|
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
|
||||||
|
# 10.11.6 is the latest stable web client; API-compatible with the 10.12.0 server.
|
||||||
|
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
|
||||||
|
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
|
||||||
|
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
|
||||||
|
> /etc/apt/sources.list.d/jellyfin.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends "jellyfin-web=10.11.6+deb12" \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& echo "Web client files:" && ls /usr/share/jellyfin/web/ | head -10
|
||||||
|
|
||||||
|
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||||
|
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
|
||||||
|
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
|
||||||
|
# libicu, libssl, and liblttng-ust are already present in the dotnet/aspnet base image.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
fontconfig \
|
||||||
|
libfontconfig1 \
|
||||||
|
libfreetype6 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /jellyfin
|
||||||
|
|
||||||
|
COPY --from=build /app .
|
||||||
|
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
|
||||||
|
|
||||||
|
# Jellyfin default ports
|
||||||
|
EXPOSE 8096
|
||||||
|
EXPOSE 8920
|
||||||
|
|
||||||
|
# Data / config volumes
|
||||||
|
VOLUME ["/config", "/cache", "/media"]
|
||||||
|
|
||||||
|
ENV JELLYFIN_DATA_DIR=/config \
|
||||||
|
JELLYFIN_CACHE_DIR=/cache \
|
||||||
|
JELLYFIN_LOG_DIR=/config/log \
|
||||||
|
JELLYFIN_CONFIG_DIR=/config
|
||||||
|
|
||||||
|
ENTRYPOINT ["./jellyfin", \
|
||||||
|
"--datadir", "/config", \
|
||||||
|
"--cachedir", "/cache", \
|
||||||
|
"--webdir", "/jellyfin/jellyfin-web"]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Runtime-only image — the .NET publish step runs on the CI host (runner),
|
||||||
|
# not inside this Dockerfile.
|
||||||
|
|
||||||
|
# ── Web client stage ──────────────────────────────────────────────────────────
|
||||||
|
# Install jellyfin-web via the official Jellyfin apt repo.
|
||||||
|
# Package suffix in the bookworm repo is +deb12 (e.g. 10.11.6+deb12).
|
||||||
|
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
|
||||||
|
# 10.11.6 is the latest stable web client; API-compatible with the 10.12.0 server.
|
||||||
|
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
|
||||||
|
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
|
||||||
|
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
|
||||||
|
> /etc/apt/sources.list.d/jellyfin.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends "jellyfin-web=10.11.6+deb12" \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||||
|
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
|
||||||
|
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
fontconfig \
|
||||||
|
libfontconfig1 \
|
||||||
|
libfreetype6 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /jellyfin
|
||||||
|
|
||||||
|
# Copy the pre-built publish output produced by `dotnet publish` on the CI host.
|
||||||
|
COPY publish-output/ .
|
||||||
|
# Copy the jellyfin-web client assets from the webclient stage.
|
||||||
|
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
|
||||||
|
|
||||||
|
# Jellyfin default ports
|
||||||
|
EXPOSE 8096
|
||||||
|
EXPOSE 8920
|
||||||
|
|
||||||
|
# Data / config volumes
|
||||||
|
VOLUME ["/config", "/cache", "/media"]
|
||||||
|
|
||||||
|
ENV JELLYFIN_DATA_DIR=/config \
|
||||||
|
JELLYFIN_CACHE_DIR=/cache \
|
||||||
|
JELLYFIN_LOG_DIR=/config/log
|
||||||
|
|
||||||
|
ENTRYPOINT ["./jellyfin", \
|
||||||
|
"--datadir", "/config", \
|
||||||
|
"--cachedir", "/cache", \
|
||||||
|
"--webdir", "/jellyfin/jellyfin-web"]
|
||||||
@@ -21,8 +21,8 @@ namespace Emby.Naming.Common
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public NamingOptions()
|
public NamingOptions()
|
||||||
{
|
{
|
||||||
VideoFileExtensions = new[]
|
VideoFileExtensions =
|
||||||
{
|
[
|
||||||
".001",
|
".001",
|
||||||
".3g2",
|
".3g2",
|
||||||
".3gp",
|
".3gp",
|
||||||
@@ -77,10 +77,10 @@ namespace Emby.Naming.Common
|
|||||||
".wmv",
|
".wmv",
|
||||||
".wtv",
|
".wtv",
|
||||||
".xvid"
|
".xvid"
|
||||||
};
|
];
|
||||||
|
|
||||||
VideoFlagDelimiters = new[]
|
VideoFlagDelimiters =
|
||||||
{
|
[
|
||||||
'(',
|
'(',
|
||||||
')',
|
')',
|
||||||
'-',
|
'-',
|
||||||
@@ -88,15 +88,15 @@ namespace Emby.Naming.Common
|
|||||||
'_',
|
'_',
|
||||||
'[',
|
'[',
|
||||||
']'
|
']'
|
||||||
};
|
];
|
||||||
|
|
||||||
StubFileExtensions = new[]
|
StubFileExtensions =
|
||||||
{
|
[
|
||||||
".disc"
|
".disc"
|
||||||
};
|
];
|
||||||
|
|
||||||
StubTypes = new[]
|
StubTypes =
|
||||||
{
|
[
|
||||||
new StubTypeRule(
|
new StubTypeRule(
|
||||||
stubType: "dvd",
|
stubType: "dvd",
|
||||||
token: "dvd"),
|
token: "dvd"),
|
||||||
@@ -136,32 +136,32 @@ namespace Emby.Naming.Common
|
|||||||
new StubTypeRule(
|
new StubTypeRule(
|
||||||
stubType: "tv",
|
stubType: "tv",
|
||||||
token: "DSR")
|
token: "DSR")
|
||||||
};
|
];
|
||||||
|
|
||||||
VideoFileStackingRules = new[]
|
VideoFileStackingRules =
|
||||||
{
|
[
|
||||||
new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[0-9]+)[\)\]]?(?:\.[^.]+)?$", true),
|
new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[0-9]+)[\)\]]?(?:\.[^.]+)?$", true),
|
||||||
new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[a-d])[\)\]]?(?:\.[^.]+)?$", false)
|
new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[a-d])[\)\]]?(?:\.[^.]+)?$", false)
|
||||||
};
|
];
|
||||||
|
|
||||||
CleanDateTimes = new[]
|
CleanDateTimes =
|
||||||
{
|
[
|
||||||
@"(.+[^_\,\.\(\)\[\]\-])[_\.\(\)\[\]\-](19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*",
|
@"(.+[^_\,\.\(\)\[\]\-])[_\.\(\)\[\]\-](19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*",
|
||||||
@"(.+[^_\,\.\(\)\[\]\-])[ _\.\(\)\[\]\-]+(19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*"
|
@"(.+[^_\,\.\(\)\[\]\-])[ _\.\(\)\[\]\-]+(19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*"
|
||||||
};
|
];
|
||||||
|
|
||||||
CleanStrings = new[]
|
CleanStrings =
|
||||||
{
|
[
|
||||||
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)",
|
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)",
|
||||||
@"^(?<cleaned>.+?)(\[.*\])",
|
@"^(?<cleaned>.+?)(\[.*\])",
|
||||||
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
|
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
|
||||||
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
|
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
|
||||||
@"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$",
|
@"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$",
|
||||||
@"^\s*(?<cleaned>.+?)(([-._ ](trailer|sample))|-(scene|clip|behindthescenes|deleted|deletedscene|featurette|short|interview|other|extra))$"
|
@"^\s*(?<cleaned>.+?)(([-._ ](trailer|sample))|-(scene|clip|behindthescenes|deleted|deletedscene|featurette|short|interview|other|extra))$"
|
||||||
};
|
];
|
||||||
|
|
||||||
SubtitleFileExtensions = new[]
|
SubtitleFileExtensions =
|
||||||
{
|
[
|
||||||
".ass",
|
".ass",
|
||||||
".mks",
|
".mks",
|
||||||
".sami",
|
".sami",
|
||||||
@@ -171,17 +171,17 @@ namespace Emby.Naming.Common
|
|||||||
".sub",
|
".sub",
|
||||||
".sup",
|
".sup",
|
||||||
".vtt",
|
".vtt",
|
||||||
};
|
];
|
||||||
|
|
||||||
LyricFileExtensions = new[]
|
LyricFileExtensions =
|
||||||
{
|
[
|
||||||
".lrc",
|
".lrc",
|
||||||
".elrc",
|
".elrc",
|
||||||
".txt"
|
".txt"
|
||||||
};
|
];
|
||||||
|
|
||||||
AlbumStackingPrefixes = new[]
|
AlbumStackingPrefixes =
|
||||||
{
|
[
|
||||||
"cd",
|
"cd",
|
||||||
"digital media",
|
"digital media",
|
||||||
"disc",
|
"disc",
|
||||||
@@ -190,10 +190,10 @@ namespace Emby.Naming.Common
|
|||||||
"volume",
|
"volume",
|
||||||
"part",
|
"part",
|
||||||
"act"
|
"act"
|
||||||
};
|
];
|
||||||
|
|
||||||
ArtistSubfolders = new[]
|
ArtistSubfolders =
|
||||||
{
|
[
|
||||||
"albums",
|
"albums",
|
||||||
"broadcasts",
|
"broadcasts",
|
||||||
"bootlegs",
|
"bootlegs",
|
||||||
@@ -208,10 +208,10 @@ namespace Emby.Naming.Common
|
|||||||
"soundtracks",
|
"soundtracks",
|
||||||
"spokenwords",
|
"spokenwords",
|
||||||
"streets"
|
"streets"
|
||||||
};
|
];
|
||||||
|
|
||||||
AudioFileExtensions = new[]
|
AudioFileExtensions =
|
||||||
{
|
[
|
||||||
".669",
|
".669",
|
||||||
".3gp",
|
".3gp",
|
||||||
".aa",
|
".aa",
|
||||||
@@ -241,6 +241,7 @@ namespace Emby.Naming.Common
|
|||||||
".dts",
|
".dts",
|
||||||
".dvf",
|
".dvf",
|
||||||
".eac3",
|
".eac3",
|
||||||
|
".ec3",
|
||||||
".far",
|
".far",
|
||||||
".flac",
|
".flac",
|
||||||
".gdm",
|
".gdm",
|
||||||
@@ -291,33 +292,33 @@ namespace Emby.Naming.Common
|
|||||||
".xm",
|
".xm",
|
||||||
".xsp",
|
".xsp",
|
||||||
".ymf"
|
".ymf"
|
||||||
};
|
];
|
||||||
|
|
||||||
MediaFlagDelimiters = new[]
|
MediaFlagDelimiters =
|
||||||
{
|
[
|
||||||
'.'
|
'.'
|
||||||
};
|
];
|
||||||
|
|
||||||
MediaForcedFlags = new[]
|
MediaForcedFlags =
|
||||||
{
|
[
|
||||||
"foreign",
|
"foreign",
|
||||||
"forced"
|
"forced"
|
||||||
};
|
];
|
||||||
|
|
||||||
MediaDefaultFlags = new[]
|
MediaDefaultFlags =
|
||||||
{
|
[
|
||||||
"default"
|
"default"
|
||||||
};
|
];
|
||||||
|
|
||||||
MediaHearingImpairedFlags = new[]
|
MediaHearingImpairedFlags =
|
||||||
{
|
[
|
||||||
"cc",
|
"cc",
|
||||||
"hi",
|
"hi",
|
||||||
"sdh"
|
"sdh"
|
||||||
};
|
];
|
||||||
|
|
||||||
EpisodeExpressions = new[]
|
EpisodeExpressions =
|
||||||
{
|
[
|
||||||
// *** Begin Kodi Standard Naming
|
// *** Begin Kodi Standard Naming
|
||||||
// <!-- foo.s01.e01, foo.s01_e01, S01E02 foo, S01 - E02 -->
|
// <!-- foo.s01.e01, foo.s01_e01, S01E02 foo, S01 - E02 -->
|
||||||
new EpisodeExpression(@".*(\\|\/)(?<seriesname>((?[][ ._-]*[Ee]([0-9]+))[^\\\/])*)?[Ss](?<seasonnumber>[0-9]+)[][ ._-]*[Ee](?<epnumber>[0-9]+)([^\\/]*)$")
|
new EpisodeExpression(@".*(\\|\/)(?<seriesname>((?[][ ._-]*[Ee]([0-9]+))[^\\\/])*)?[Ss](?<seasonnumber>[0-9]+)[][ ._-]*[Ee](?<epnumber>[0-9]+)([^\\/]*)$")
|
||||||
@@ -330,23 +331,23 @@ namespace Emby.Naming.Common
|
|||||||
new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"),
|
new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"),
|
||||||
new EpisodeExpression("(?<year>[0-9]{4})[._ -](?<month>[0-9]{2})[._ -](?<day>[0-9]{2})", true)
|
new EpisodeExpression("(?<year>[0-9]{4})[._ -](?<month>[0-9]{2})[._ -](?<day>[0-9]{2})", true)
|
||||||
{
|
{
|
||||||
DateTimeFormats = new[]
|
DateTimeFormats =
|
||||||
{
|
[
|
||||||
"yyyy.MM.dd",
|
"yyyy.MM.dd",
|
||||||
"yyyy-MM-dd",
|
"yyyy-MM-dd",
|
||||||
"yyyy_MM_dd",
|
"yyyy_MM_dd",
|
||||||
"yyyy MM dd"
|
"yyyy MM dd"
|
||||||
}
|
]
|
||||||
},
|
},
|
||||||
new EpisodeExpression("(?<day>[0-9]{2})[._ -](?<month>[0-9]{2})[._ -](?<year>[0-9]{4})", true)
|
new EpisodeExpression("(?<day>[0-9]{2})[._ -](?<month>[0-9]{2})[._ -](?<year>[0-9]{4})", true)
|
||||||
{
|
{
|
||||||
DateTimeFormats = new[]
|
DateTimeFormats =
|
||||||
{
|
[
|
||||||
"dd.MM.yyyy",
|
"dd.MM.yyyy",
|
||||||
"dd-MM-yyyy",
|
"dd-MM-yyyy",
|
||||||
"dd_MM_yyyy",
|
"dd_MM_yyyy",
|
||||||
"dd MM yyyy"
|
"dd MM yyyy"
|
||||||
}
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
// This isn't a Kodi naming rule, but the expression below causes false episode numbers for
|
// This isn't a Kodi naming rule, but the expression below causes false episode numbers for
|
||||||
@@ -478,10 +479,10 @@ namespace Emby.Naming.Common
|
|||||||
{
|
{
|
||||||
IsNamed = true
|
IsNamed = true
|
||||||
},
|
},
|
||||||
};
|
];
|
||||||
|
|
||||||
VideoExtraRules = new[]
|
VideoExtraRules =
|
||||||
{
|
[
|
||||||
new ExtraRule(
|
new ExtraRule(
|
||||||
ExtraType.Trailer,
|
ExtraType.Trailer,
|
||||||
ExtraRuleType.DirectoryName,
|
ExtraRuleType.DirectoryName,
|
||||||
@@ -691,14 +692,14 @@ namespace Emby.Naming.Common
|
|||||||
ExtraRuleType.Suffix,
|
ExtraRuleType.Suffix,
|
||||||
"-other",
|
"-other",
|
||||||
MediaType.Video)
|
MediaType.Video)
|
||||||
};
|
];
|
||||||
|
|
||||||
AllExtrasTypesFolderNames = VideoExtraRules
|
AllExtrasTypesFolderNames = VideoExtraRules
|
||||||
.Where(i => i.RuleType == ExtraRuleType.DirectoryName)
|
.Where(i => i.RuleType == ExtraRuleType.DirectoryName)
|
||||||
.ToDictionary(i => i.Token, i => i.ExtraType, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(i => i.Token, i => i.ExtraType, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
Format3DRules = new[]
|
Format3DRules =
|
||||||
{
|
[
|
||||||
// Kodi rules:
|
// Kodi rules:
|
||||||
new Format3DRule(
|
new Format3DRule(
|
||||||
precedingToken: "3d",
|
precedingToken: "3d",
|
||||||
@@ -725,10 +726,10 @@ namespace Emby.Naming.Common
|
|||||||
new Format3DRule("tab"),
|
new Format3DRule("tab"),
|
||||||
new Format3DRule("sbs3d"),
|
new Format3DRule("sbs3d"),
|
||||||
new Format3DRule("mvc")
|
new Format3DRule("mvc")
|
||||||
};
|
];
|
||||||
|
|
||||||
AudioBookPartsExpressions = new[]
|
AudioBookPartsExpressions =
|
||||||
{
|
[
|
||||||
// Detect specified chapters, like CH 01
|
// Detect specified chapters, like CH 01
|
||||||
@"ch(?:apter)?[\s_-]?(?<chapter>[0-9]+)",
|
@"ch(?:apter)?[\s_-]?(?<chapter>[0-9]+)",
|
||||||
// Detect specified parts, like Part 02
|
// Detect specified parts, like Part 02
|
||||||
@@ -741,14 +742,14 @@ namespace Emby.Naming.Common
|
|||||||
"(?<chapter>[0-9]+)_(?<part>[0-9]+)",
|
"(?<chapter>[0-9]+)_(?<part>[0-9]+)",
|
||||||
// Some audiobooks are ripped from cd's, and will be named by disk number.
|
// Some audiobooks are ripped from cd's, and will be named by disk number.
|
||||||
@"dis(?:c|k)[\s_-]?(?<chapter>[0-9]+)"
|
@"dis(?:c|k)[\s_-]?(?<chapter>[0-9]+)"
|
||||||
};
|
];
|
||||||
|
|
||||||
AudioBookNamesExpressions = new[]
|
AudioBookNamesExpressions =
|
||||||
{
|
[
|
||||||
// Detect year usually in brackets after name Batman (2020)
|
// Detect year usually in brackets after name Batman (2020)
|
||||||
@"^(?<name>.+?)\s*\(\s*(?<year>[0-9]{4})\s*\)\s*$",
|
@"^(?<name>.+?)\s*\(\s*(?<year>[0-9]{4})\s*\)\s*$",
|
||||||
@"^\s*(?<name>[^ ].*?)\s*$"
|
@"^\s*(?<name>[^ ].*?)\s*$"
|
||||||
};
|
];
|
||||||
|
|
||||||
MultipleEpisodeExpressions = new[]
|
MultipleEpisodeExpressions = new[]
|
||||||
{
|
{
|
||||||
@@ -888,12 +889,12 @@ namespace Emby.Naming.Common
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets list of clean datetime regular expressions.
|
/// Gets list of clean datetime regular expressions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Regex[] CleanDateTimeRegexes { get; private set; } = Array.Empty<Regex>();
|
public Regex[] CleanDateTimeRegexes { get; private set; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets list of clean string regular expressions.
|
/// Gets list of clean string regular expressions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Regex[] CleanStringRegexes { get; private set; } = Array.Empty<Regex>();
|
public Regex[] CleanStringRegexes { get; private set; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compiles raw regex strings into regexes.
|
/// Compiles raw regex strings into regexes.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Naming</PackageId>
|
<PackageId>Jellyfin.Naming</PackageId>
|
||||||
<VersionPrefix>10.11.0</VersionPrefix>
|
<VersionPrefix>10.11.7</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ namespace Emby.Naming.TV
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static partial class SeasonPathParser
|
public static partial class SeasonPathParser
|
||||||
{
|
{
|
||||||
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$")]
|
private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex ProcessPre();
|
private static partial Regex ProcessPre();
|
||||||
|
|
||||||
[GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$")]
|
[GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex ProcessPost();
|
private static partial Regex ProcessPost();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
|
||||||
|
private static partial Regex SeasonPrefix();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to parse season number from path.
|
/// Attempts to parse season number from path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -56,44 +61,34 @@ namespace Emby.Naming.TV
|
|||||||
bool supportSpecialAliases,
|
bool supportSpecialAliases,
|
||||||
bool supportNumericSeasonFolders)
|
bool supportNumericSeasonFolders)
|
||||||
{
|
{
|
||||||
string filename = Path.GetFileName(path);
|
var fileName = Path.GetFileName(path);
|
||||||
filename = Regex.Replace(filename, "[ ._-]", string.Empty);
|
|
||||||
|
var seasonPrefixMatch = SeasonPrefix().Match(fileName);
|
||||||
|
if (seasonPrefixMatch.Success &&
|
||||||
|
int.TryParse(seasonPrefixMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
||||||
|
{
|
||||||
|
return (val, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
string filename = CleanNameRegex.Replace(fileName, string.Empty);
|
||||||
|
|
||||||
if (parentFolderName is not null)
|
if (parentFolderName is not null)
|
||||||
{
|
{
|
||||||
parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty);
|
var cleanParent = CleanNameRegex.Replace(parentFolderName, string.Empty);
|
||||||
filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase);
|
filename = filename.Replace(cleanParent, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportSpecialAliases)
|
if (supportSpecialAliases &&
|
||||||
|
(filename.Equals("specials", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
filename.Equals("extras", StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
if (string.Equals(filename, "specials", StringComparison.OrdinalIgnoreCase))
|
return (0, true);
|
||||||
{
|
|
||||||
return (0, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(filename, "extras", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return (0, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportNumericSeasonFolders)
|
if (supportNumericSeasonFolders &&
|
||||||
|
int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val))
|
||||||
{
|
{
|
||||||
if (int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
return (val, true);
|
||||||
{
|
|
||||||
return (val, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filename.StartsWith('s'))
|
|
||||||
{
|
|
||||||
var testFilename = filename.AsSpan()[1..];
|
|
||||||
|
|
||||||
if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
|
||||||
{
|
|
||||||
return (val, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var preMatch = ProcessPre().Match(filename);
|
var preMatch = ProcessPre().Match(filename);
|
||||||
@@ -113,8 +108,10 @@ namespace Emby.Naming.TV
|
|||||||
var numberString = match.Groups["seasonnumber"];
|
var numberString = match.Groups["seasonnumber"];
|
||||||
if (numberString.Success)
|
if (numberString.Success)
|
||||||
{
|
{
|
||||||
var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture);
|
if (int.TryParse(numberString.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber))
|
||||||
return (seasonNumber, true);
|
{
|
||||||
|
return (seasonNumber, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (null, false);
|
return (null, false);
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ namespace Emby.Naming.Video
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class StackMetadata
|
private sealed class StackMetadata
|
||||||
{
|
{
|
||||||
public StackMetadata(bool isDirectory, bool isNumerical, string partType)
|
public StackMetadata(bool isDirectory, bool isNumerical, string partType)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -107,10 +107,20 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
|
|
||||||
private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
|
private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
|
||||||
{
|
{
|
||||||
var otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => Path.GetFileName(e) != markerName);
|
string? otherMarkers = null;
|
||||||
if (otherMarkers != null)
|
try
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Exepected to find only {markerName} but found marker for {otherMarkers}.");
|
otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => !Path.GetFileName(e.AsSpan()).Equals(markerName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Error while checking for marker files, assume none exist and keep going
|
||||||
|
// TODO: add some logging
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otherMarkers is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Expected to find only {markerName} but found marker for {otherMarkers}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var markerPath = Path.Combine(path, markerName);
|
var markerPath = Path.Combine(path, markerName);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -224,7 +223,7 @@ public class ChapterManager : IChapterManager
|
|||||||
|
|
||||||
if (saveChapters && changesMade)
|
if (saveChapters && changesMade)
|
||||||
{
|
{
|
||||||
_chapterRepository.SaveChapters(video.Id, chapters);
|
SaveChapters(video, chapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
DeleteDeadImages(currentImages, chapters);
|
DeleteDeadImages(currentImages, chapters);
|
||||||
@@ -235,7 +234,9 @@ public class ChapterManager : IChapterManager
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
|
public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
|
||||||
{
|
{
|
||||||
_chapterRepository.SaveChapters(video.Id, chapters);
|
// Remove any chapters that are outside of the runtime of the video
|
||||||
|
var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList();
|
||||||
|
_chapterRepository.SaveChapters(video.Id, validChapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -251,23 +252,9 @@ public class ChapterManager : IChapterManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void DeleteChapterImages(Video video)
|
public async Task DeleteChapterDataAsync(Guid itemId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var path = _pathManager.GetChapterImageFolderPath(video);
|
await _chapterRepository.DeleteChaptersAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
try
|
|
||||||
{
|
|
||||||
if (Directory.Exists(path))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Removing chapter images for {Name} [{Id}]", video.Name, video.Id);
|
|
||||||
Directory.Delete(path, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Failed to remove chapter image folder for {Item}: {Exception}", video.Id, ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
_chapterRepository.DeleteChapters(video.Id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<string> GetSavedChapterImages(Video video, IDirectoryService directoryService)
|
private IReadOnlyList<string> GetSavedChapterImages(Video video, IDirectoryService directoryService)
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ namespace Emby.Server.Implementations.Collections
|
|||||||
|
|
||||||
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
|
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_libraryManager.RootFolder.Children = null;
|
||||||
|
|
||||||
return FindFolders(path).First();
|
return FindFolders(path).First();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
|
|||||||
|
|
||||||
_logger.LogDebug("Cleaning {Number} items with dead parents", numItems);
|
_logger.LogDebug("Cleaning {Number} items with dead parents", numItems);
|
||||||
|
|
||||||
|
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
||||||
|
|
||||||
foreach (var itemId in itemIds)
|
foreach (var itemId in itemIds)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
@@ -95,9 +97,10 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
|
|||||||
numComplete++;
|
numComplete++;
|
||||||
double percent = numComplete;
|
double percent = numComplete;
|
||||||
percent /= numItems;
|
percent /= numItems;
|
||||||
progress.Report(percent * 100);
|
subProgress.Report(percent * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
|
||||||
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await using (context.ConfigureAwait(false))
|
await using (context.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
@@ -105,7 +108,9 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
|
|||||||
await using (transaction.ConfigureAwait(false))
|
await using (transaction.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
subProgress.Report(50);
|
||||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
subProgress.Report(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1051,31 +1051,16 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
||||||
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
||||||
|
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||||
|
|
||||||
dto.ArtistItems = hasArtist.Artists
|
dto.ArtistItems = hasArtist.Artists
|
||||||
// .Except(foundArtists, new DistinctNameComparer())
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
.Select(i =>
|
.Distinct()
|
||||||
{
|
.Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
|
||||||
// This should not be necessary but we're seeing some cases of it
|
? new NameGuidPair { Name = name, Id = artists[0].Id }
|
||||||
if (string.IsNullOrEmpty(i))
|
: null)
|
||||||
{
|
.Where(item => item is not null)
|
||||||
return null;
|
.ToArray();
|
||||||
}
|
|
||||||
|
|
||||||
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
|
|
||||||
{
|
|
||||||
EnableImages = false
|
|
||||||
});
|
|
||||||
if (artist is not null)
|
|
||||||
{
|
|
||||||
return new NameGuidPair
|
|
||||||
{
|
|
||||||
Name = artist.Name,
|
|
||||||
Id = artist.Id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}).Where(i => i is not null).ToArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item is IHasAlbumArtist hasAlbumArtist)
|
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||||
@@ -1100,31 +1085,16 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
// })
|
// })
|
||||||
// .ToList();
|
// .ToList();
|
||||||
|
|
||||||
|
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||||
|
|
||||||
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
||||||
// .Except(foundArtists, new DistinctNameComparer())
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
.Select(i =>
|
.Distinct()
|
||||||
{
|
.Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
|
||||||
// This should not be necessary but we're seeing some cases of it
|
? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
|
||||||
if (string.IsNullOrEmpty(i))
|
: null)
|
||||||
{
|
.Where(item => item is not null)
|
||||||
return null;
|
.ToArray();
|
||||||
}
|
|
||||||
|
|
||||||
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
|
|
||||||
{
|
|
||||||
EnableImages = false
|
|
||||||
});
|
|
||||||
if (artist is not null)
|
|
||||||
{
|
|
||||||
return new NameGuidPair
|
|
||||||
{
|
|
||||||
Name = artist.Name,
|
|
||||||
Id = artist.Id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}).Where(i => i is not null).ToArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add video info
|
// Add video info
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Ignore" />
|
<PackageReference Include="Ignore" />
|
||||||
|
<PackageReference Include="StackExchange.Redis" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -352,6 +352,12 @@ namespace Emby.Server.Implementations.IO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fileInfo = _fileSystem.GetFileSystemInfo(path);
|
||||||
|
if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
||||||
foreach (var i in _tempIgnoredPaths.Keys)
|
foreach (var i in _tempIgnoredPaths.Keys)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Linq;
|
|||||||
using System.Security;
|
using System.Security;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -152,6 +153,10 @@ namespace Emby.Server.Implementations.IO
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MoveDirectory(string source, string destination)
|
public void MoveDirectory(string source, string destination)
|
||||||
{
|
{
|
||||||
|
// Make sure parent directory of target exists
|
||||||
|
var parent = Directory.GetParent(destination);
|
||||||
|
parent?.Create();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.Move(source, destination);
|
Directory.Move(source, destination);
|
||||||
@@ -248,47 +253,40 @@ namespace Emby.Server.Implementations.IO
|
|||||||
{
|
{
|
||||||
result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
|
result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
|
||||||
|
|
||||||
// if (!result.IsDirectory)
|
|
||||||
// {
|
|
||||||
// result.IsHidden = (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden;
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (info is FileInfo fileInfo)
|
if (info is FileInfo fileInfo)
|
||||||
{
|
{
|
||||||
result.Length = fileInfo.Length;
|
result.CreationTimeUtc = GetCreationTimeUtc(info);
|
||||||
|
result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
|
||||||
// Issue #2354 get the size of files behind symbolic links. Also Enum.HasFlag is bad as it boxes!
|
if (fileInfo.LinkTarget is not null)
|
||||||
if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
var targetFileInfo = FileSystemHelper.ResolveLinkTarget(fileInfo, returnFinalTarget: true);
|
||||||
|
if (targetFileInfo is not null)
|
||||||
{
|
{
|
||||||
result.Length = RandomAccess.GetLength(fileHandle);
|
result.Exists = targetFileInfo.Exists;
|
||||||
|
if (result.Exists)
|
||||||
|
{
|
||||||
|
result.Length = targetFileInfo.Length;
|
||||||
|
result.CreationTimeUtc = GetCreationTimeUtc(targetFileInfo);
|
||||||
|
result.LastWriteTimeUtc = GetLastWriteTimeUtc(targetFileInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Exists = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (FileNotFoundException ex)
|
|
||||||
{
|
|
||||||
// Dangling symlinks cannot be detected before opening the file unfortunately...
|
|
||||||
_logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName);
|
|
||||||
result.Exists = false;
|
|
||||||
}
|
}
|
||||||
catch (UnauthorizedAccessException ex)
|
catch (UnauthorizedAccessException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName);
|
_logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName);
|
||||||
}
|
}
|
||||||
catch (IOException ex)
|
}
|
||||||
{
|
else
|
||||||
// IOException generally means the file is not accessible due to filesystem issues
|
{
|
||||||
// Catch this exception and mark the file as not exist to ignore it
|
result.Length = fileInfo.Length;
|
||||||
_logger.LogError(ex, "Reading the file at {Path} failed due to an IO Exception. Marking the file as not existing", fileInfo.FullName);
|
|
||||||
result.Exists = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.CreationTimeUtc = GetCreationTimeUtc(info);
|
|
||||||
result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -499,8 +497,17 @@ namespace Emby.Server.Implementations.IO
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public virtual bool AreEqual(string path1, string path2)
|
public virtual bool AreEqual(string path1, string path2)
|
||||||
{
|
{
|
||||||
return Path.TrimEndingDirectorySeparator(path1).Equals(
|
if (string.IsNullOrWhiteSpace(path1) || string.IsNullOrWhiteSpace(path2))
|
||||||
Path.TrimEndingDirectorySeparator(path2),
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized1 = Path.TrimEndingDirectorySeparator(path1);
|
||||||
|
var normalized2 = Path.TrimEndingDirectorySeparator(path2);
|
||||||
|
|
||||||
|
return string.Equals(
|
||||||
|
normalized1,
|
||||||
|
normalized2,
|
||||||
_isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
_isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,22 +267,24 @@ namespace Emby.Server.Implementations.Images
|
|||||||
{
|
{
|
||||||
var image = item.GetImageInfo(type, 0);
|
var image = item.GetImageInfo(type, 0);
|
||||||
|
|
||||||
if (image is not null)
|
if (image is null)
|
||||||
{
|
{
|
||||||
if (!image.IsLocalFile)
|
return GetItemsWithImages(item).Count is not 0;
|
||||||
{
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
|
if (!image.IsLocalFile)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!HasChangedByDate(item, image))
|
if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!HasChangedByDate(item, image))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -98,5 +98,11 @@ namespace Emby.Server.Implementations.Images
|
|||||||
|
|
||||||
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image)
|
||||||
|
{
|
||||||
|
var age = DateTime.UtcNow - image.DateModified;
|
||||||
|
return age.TotalDays > 7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IgnorePatterns.ShouldIgnore(fileInfo.FullName))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't ignore top level folders
|
// Don't ignore top level folders
|
||||||
if (fileInfo.IsDirectory
|
if (fileInfo.IsDirectory
|
||||||
&& (parent is AggregateFolder || (parent?.IsTopParent ?? false)))
|
&& (parent is AggregateFolder || (parent?.IsTopParent ?? false)))
|
||||||
@@ -44,11 +49,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IgnorePatterns.ShouldIgnore(fileInfo.FullName))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parent is null)
|
if (parent is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Resolvers;
|
using MediaBrowser.Controller.Resolvers;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
|
|
||||||
@@ -11,28 +13,24 @@ namespace Emby.Server.Implementations.Library;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||||
{
|
{
|
||||||
|
private static readonly bool IsWindows = OperatingSystem.IsWindows();
|
||||||
|
|
||||||
private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
|
private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
|
||||||
{
|
{
|
||||||
var ignoreFile = new FileInfo(Path.Join(directory.FullName, ".ignore"));
|
for (var current = directory; current is not null; current = current.Parent)
|
||||||
if (ignoreFile.Exists)
|
|
||||||
{
|
{
|
||||||
return ignoreFile;
|
var ignorePath = Path.Join(current.FullName, ".ignore");
|
||||||
|
if (File.Exists(ignorePath))
|
||||||
|
{
|
||||||
|
return new FileInfo(ignorePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var parentDir = directory.Parent;
|
return null;
|
||||||
if (parentDir is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindIgnoreFile(parentDir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent)
|
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent);
|
||||||
{
|
|
||||||
return IsIgnored(fileInfo, parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks whether or not the file is ignored.
|
/// Checks whether or not the file is ignored.
|
||||||
@@ -42,53 +40,101 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
|||||||
/// <returns>True if the file should be ignored.</returns>
|
/// <returns>True if the file should be ignored.</returns>
|
||||||
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
|
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
|
||||||
{
|
{
|
||||||
if (fileInfo.IsDirectory)
|
var searchDirectory = fileInfo.IsDirectory
|
||||||
{
|
? new DirectoryInfo(fileInfo.FullName)
|
||||||
var dirIgnoreFile = FindIgnoreFile(new DirectoryInfo(fileInfo.FullName));
|
: new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty);
|
||||||
if (dirIgnoreFile is null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignore the directory only if the .ignore file is empty
|
if (string.IsNullOrEmpty(searchDirectory.FullName))
|
||||||
// evaluate individual files otherwise
|
|
||||||
return string.IsNullOrWhiteSpace(GetFileContent(dirIgnoreFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
var parentDirPath = Path.GetDirectoryName(fileInfo.FullName);
|
|
||||||
if (string.IsNullOrEmpty(parentDirPath))
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var folder = new DirectoryInfo(parentDirPath);
|
var ignoreFile = FindIgnoreFile(searchDirectory);
|
||||||
var ignoreFile = FindIgnoreFile(folder);
|
|
||||||
if (ignoreFile is null)
|
if (ignoreFile is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
string ignoreFileString = GetFileContent(ignoreFile);
|
// Fast path in case the ignore files isn't a symlink and is empty
|
||||||
|
if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0)
|
||||||
if (string.IsNullOrWhiteSpace(ignoreFileString))
|
|
||||||
{
|
{
|
||||||
// Ignore directory if we just have the file
|
// Ignore directory if we just have the file
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If file has content, base ignoring off the content .gitignore-style rules
|
var content = GetFileContent(ignoreFile);
|
||||||
var ignoreRules = ignoreFileString.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
return string.IsNullOrWhiteSpace(content)
|
||||||
var ignore = new Ignore.Ignore();
|
|| CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory);
|
||||||
ignore.Add(ignoreRules);
|
|
||||||
|
|
||||||
return ignore.IsIgnored(fileInfo.FullName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetFileContent(FileInfo dirIgnoreFile)
|
private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory)
|
||||||
{
|
{
|
||||||
using (var reader = dirIgnoreFile.OpenText())
|
// If file has content, base ignoring off the content .gitignore-style rules
|
||||||
|
var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
return CheckIgnoreRules(path, rules, isDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a path should be ignored based on an array of ignore rules.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to check.</param>
|
||||||
|
/// <param name="rules">The array of ignore rules.</param>
|
||||||
|
/// <param name="isDirectory">Whether the path is a directory.</param>
|
||||||
|
/// <returns>True if the path should be ignored.</returns>
|
||||||
|
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory)
|
||||||
|
=> CheckIgnoreRules(path, rules, isDirectory, IsWindows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a path should be ignored based on an array of ignore rules.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to check.</param>
|
||||||
|
/// <param name="rules">The array of ignore rules.</param>
|
||||||
|
/// <param name="isDirectory">Whether the path is a directory.</param>
|
||||||
|
/// <param name="normalizePath">Whether to normalize backslashes to forward slashes (for Windows paths).</param>
|
||||||
|
/// <returns>True if the path should be ignored.</returns>
|
||||||
|
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory, bool normalizePath)
|
||||||
|
{
|
||||||
|
var ignore = new Ignore.Ignore();
|
||||||
|
|
||||||
|
// Add each rule individually to catch and skip invalid patterns
|
||||||
|
var validRulesAdded = 0;
|
||||||
|
foreach (var rule in rules)
|
||||||
{
|
{
|
||||||
return reader.ReadToEnd();
|
try
|
||||||
|
{
|
||||||
|
ignore.Add(rule);
|
||||||
|
validRulesAdded++;
|
||||||
|
}
|
||||||
|
catch (RegexParseException)
|
||||||
|
{
|
||||||
|
// Ignore invalid patterns
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no valid rules were added, fall back to ignoring everything (like an empty .ignore file)
|
||||||
|
if (validRulesAdded == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
|
||||||
|
// See https://github.com/jellyfin/jellyfin/issues/15484
|
||||||
|
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
|
||||||
|
|
||||||
|
// Add trailing slash for directories to match "folder/"
|
||||||
|
if (isDirectory)
|
||||||
|
{
|
||||||
|
pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ignore.IsIgnored(pathToCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetFileContent(FileInfo ignoreFile)
|
||||||
|
{
|
||||||
|
ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
|
||||||
|
return ignoreFile.Exists
|
||||||
|
? File.ReadAllText(ignoreFile.FullName)
|
||||||
|
: string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Chapters;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.MediaSegments;
|
using MediaBrowser.Controller.MediaSegments;
|
||||||
@@ -20,6 +21,7 @@ public class ExternalDataManager : IExternalDataManager
|
|||||||
private readonly IMediaSegmentManager _mediaSegmentManager;
|
private readonly IMediaSegmentManager _mediaSegmentManager;
|
||||||
private readonly IPathManager _pathManager;
|
private readonly IPathManager _pathManager;
|
||||||
private readonly ITrickplayManager _trickplayManager;
|
private readonly ITrickplayManager _trickplayManager;
|
||||||
|
private readonly IChapterManager _chapterManager;
|
||||||
private readonly ILogger<ExternalDataManager> _logger;
|
private readonly ILogger<ExternalDataManager> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -29,18 +31,21 @@ public class ExternalDataManager : IExternalDataManager
|
|||||||
/// <param name="mediaSegmentManager">The media segment manager.</param>
|
/// <param name="mediaSegmentManager">The media segment manager.</param>
|
||||||
/// <param name="pathManager">The path manager.</param>
|
/// <param name="pathManager">The path manager.</param>
|
||||||
/// <param name="trickplayManager">The trickplay manager.</param>
|
/// <param name="trickplayManager">The trickplay manager.</param>
|
||||||
|
/// <param name="chapterManager">The chapter manager.</param>
|
||||||
/// <param name="logger">The logger.</param>
|
/// <param name="logger">The logger.</param>
|
||||||
public ExternalDataManager(
|
public ExternalDataManager(
|
||||||
IKeyframeManager keyframeManager,
|
IKeyframeManager keyframeManager,
|
||||||
IMediaSegmentManager mediaSegmentManager,
|
IMediaSegmentManager mediaSegmentManager,
|
||||||
IPathManager pathManager,
|
IPathManager pathManager,
|
||||||
ITrickplayManager trickplayManager,
|
ITrickplayManager trickplayManager,
|
||||||
|
IChapterManager chapterManager,
|
||||||
ILogger<ExternalDataManager> logger)
|
ILogger<ExternalDataManager> logger)
|
||||||
{
|
{
|
||||||
_keyframeManager = keyframeManager;
|
_keyframeManager = keyframeManager;
|
||||||
_mediaSegmentManager = mediaSegmentManager;
|
_mediaSegmentManager = mediaSegmentManager;
|
||||||
_pathManager = pathManager;
|
_pathManager = pathManager;
|
||||||
_trickplayManager = trickplayManager;
|
_trickplayManager = trickplayManager;
|
||||||
|
_chapterManager = chapterManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,5 +72,6 @@ public class ExternalDataManager : IExternalDataManager
|
|||||||
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
|
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
|
await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
"**/.wd_tv",
|
"**/.wd_tv",
|
||||||
"**/lost+found/**",
|
"**/lost+found/**",
|
||||||
"**/lost+found",
|
"**/lost+found",
|
||||||
|
"**/subs/**",
|
||||||
|
"**/subs",
|
||||||
|
"**/.snapshots/**",
|
||||||
|
"**/.snapshots",
|
||||||
|
"**/.snapshot/**",
|
||||||
|
"**/.snapshot",
|
||||||
|
|
||||||
// Trickplay files
|
// Trickplay files
|
||||||
"**/*.trickplay",
|
"**/*.trickplay",
|
||||||
|
|||||||
@@ -327,6 +327,45 @@ namespace Emby.Server.Implementations.Library
|
|||||||
DeleteItem(item, options, parent, notifyParentItem);
|
DeleteItem(item, options, parent, notifyParentItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items)
|
||||||
|
{
|
||||||
|
var pathMaps = items.Select(e => (Item: e, InternalPath: GetInternalMetadataPaths(e), DeletePaths: e.GetDeletePaths())).ToArray();
|
||||||
|
|
||||||
|
foreach (var (item, internalPaths, pathsToDelete) in pathMaps)
|
||||||
|
{
|
||||||
|
foreach (var metadataPath in internalPaths)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(metadataPath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Deleting metadata path, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
|
||||||
|
item.GetType().Name,
|
||||||
|
item.Name ?? "Unknown name",
|
||||||
|
metadataPath,
|
||||||
|
item.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(metadataPath, true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error deleting {MetadataPath}", metadataPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var fileSystemInfo in pathsToDelete)
|
||||||
|
{
|
||||||
|
DeleteItemPath(item, false, fileSystemInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_itemRepository.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
|
||||||
|
}
|
||||||
|
|
||||||
public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem)
|
public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(item);
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
@@ -403,59 +442,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
foreach (var fileSystemInfo in item.GetDeletePaths())
|
foreach (var fileSystemInfo in item.GetDeletePaths())
|
||||||
{
|
{
|
||||||
if (Directory.Exists(fileSystemInfo.FullName) || File.Exists(fileSystemInfo.FullName))
|
DeleteItemPath(item, isRequiredForDelete, fileSystemInfo);
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Deleting item path, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
|
|
||||||
item.GetType().Name,
|
|
||||||
item.Name ?? "Unknown name",
|
|
||||||
fileSystemInfo.FullName,
|
|
||||||
item.Id);
|
|
||||||
|
|
||||||
if (fileSystemInfo.IsDirectory)
|
|
||||||
{
|
|
||||||
Directory.Delete(fileSystemInfo.FullName, true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
File.Delete(fileSystemInfo.FullName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (DirectoryNotFoundException)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Directory not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
|
|
||||||
item.GetType().Name,
|
|
||||||
item.Name ?? "Unknown name",
|
|
||||||
fileSystemInfo.FullName,
|
|
||||||
item.Id);
|
|
||||||
}
|
|
||||||
catch (FileNotFoundException)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"File not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
|
|
||||||
item.GetType().Name,
|
|
||||||
item.Name ?? "Unknown name",
|
|
||||||
fileSystemInfo.FullName,
|
|
||||||
item.Id);
|
|
||||||
}
|
|
||||||
catch (IOException)
|
|
||||||
{
|
|
||||||
if (isRequiredForDelete)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
if (isRequiredForDelete)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isRequiredForDelete = false;
|
isRequiredForDelete = false;
|
||||||
}
|
}
|
||||||
@@ -463,17 +450,79 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
item.SetParent(null);
|
item.SetParent(null);
|
||||||
|
|
||||||
_itemRepository.DeleteItem(item.Id);
|
_itemRepository.DeleteItem([item.Id, .. children.Select(f => f.Id)]);
|
||||||
_cache.TryRemove(item.Id, out _);
|
_cache.TryRemove(item.Id, out _);
|
||||||
foreach (var child in children)
|
foreach (var child in children)
|
||||||
{
|
{
|
||||||
_itemRepository.DeleteItem(child.Id);
|
|
||||||
_cache.TryRemove(child.Id, out _);
|
_cache.TryRemove(child.Id, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parent is Folder folder)
|
||||||
|
{
|
||||||
|
folder.Children = null;
|
||||||
|
folder.UserData = null;
|
||||||
|
}
|
||||||
|
|
||||||
ReportItemRemoved(item, parent);
|
ReportItemRemoved(item, parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DeleteItemPath(BaseItem item, bool isRequiredForDelete, FileSystemMetadata fileSystemInfo)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(fileSystemInfo.FullName) || File.Exists(fileSystemInfo.FullName))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Deleting item path, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
|
||||||
|
item.GetType().Name,
|
||||||
|
item.Name ?? "Unknown name",
|
||||||
|
fileSystemInfo.FullName,
|
||||||
|
item.Id);
|
||||||
|
|
||||||
|
if (fileSystemInfo.IsDirectory)
|
||||||
|
{
|
||||||
|
Directory.Delete(fileSystemInfo.FullName, true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
File.Delete(fileSystemInfo.FullName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Directory not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
|
||||||
|
item.GetType().Name,
|
||||||
|
item.Name ?? "Unknown name",
|
||||||
|
fileSystemInfo.FullName,
|
||||||
|
item.Id);
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"File not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
|
||||||
|
item.GetType().Name,
|
||||||
|
item.Name ?? "Unknown name",
|
||||||
|
fileSystemInfo.FullName,
|
||||||
|
item.Id);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
if (isRequiredForDelete)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
if (isRequiredForDelete)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsInternalItem(BaseItem item)
|
private bool IsInternalItem(BaseItem item)
|
||||||
{
|
{
|
||||||
if (!item.IsFileProtocol)
|
if (!item.IsFileProtocol)
|
||||||
@@ -485,7 +534,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
Genre => _configurationManager.ApplicationPaths.GenrePath,
|
Genre => _configurationManager.ApplicationPaths.GenrePath,
|
||||||
MusicArtist => _configurationManager.ApplicationPaths.ArtistsPath,
|
MusicArtist => _configurationManager.ApplicationPaths.ArtistsPath,
|
||||||
MusicGenre => _configurationManager.ApplicationPaths.GenrePath,
|
MusicGenre => _configurationManager.ApplicationPaths.MusicGenrePath,
|
||||||
Person => _configurationManager.ApplicationPaths.PeoplePath,
|
Person => _configurationManager.ApplicationPaths.PeoplePath,
|
||||||
Studio => _configurationManager.ApplicationPaths.StudioPath,
|
Studio => _configurationManager.ApplicationPaths.StudioPath,
|
||||||
Year => _configurationManager.ApplicationPaths.YearPath,
|
Year => _configurationManager.ApplicationPaths.YearPath,
|
||||||
@@ -826,6 +875,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
if (!folder.ParentId.Equals(rootFolder.Id))
|
if (!folder.ParentId.Equals(rootFolder.Id))
|
||||||
{
|
{
|
||||||
|
rootFolder.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
folder.ParentId = rootFolder.Id;
|
folder.ParentId = rootFolder.Id;
|
||||||
folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, CancellationToken.None).GetAwaiter().GetResult();
|
folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
@@ -989,6 +1039,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return GetArtist(name, new DtoOptions(true));
|
return GetArtist(name, new DtoOptions(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, MusicArtist[]> GetArtists(IReadOnlyList<string> names)
|
||||||
|
{
|
||||||
|
return _itemRepository.FindArtists(names);
|
||||||
|
}
|
||||||
|
|
||||||
public MusicArtist GetArtist(string name, DtoOptions options)
|
public MusicArtist GetArtist(string name, DtoOptions options)
|
||||||
{
|
{
|
||||||
return CreateItemByName<MusicArtist>(MusicArtist.GetPath, name, options);
|
return CreateItemByName<MusicArtist>(MusicArtist.GetPath, name, options);
|
||||||
@@ -1003,6 +1058,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
||||||
Name = name,
|
Name = name,
|
||||||
|
UseRawName = true,
|
||||||
DtoOptions = options
|
DtoOptions = options
|
||||||
}).Cast<MusicArtist>()
|
}).Cast<MusicArtist>()
|
||||||
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
|
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
|
||||||
@@ -1090,6 +1146,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false)
|
public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false)
|
||||||
{
|
{
|
||||||
|
RootFolder.Children = null;
|
||||||
await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Start by just validating the children of the root, but go no further
|
// Start by just validating the children of the root, but go no further
|
||||||
@@ -1100,9 +1157,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
allowRemoveRoot: removeRoot,
|
allowRemoveRoot: removeRoot,
|
||||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
var rootFolder = GetUserRootFolder();
|
||||||
|
rootFolder.Children = null;
|
||||||
|
|
||||||
await GetUserRootFolder().ValidateChildren(
|
await rootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await rootFolder.ValidateChildren(
|
||||||
new Progress<double>(),
|
new Progress<double>(),
|
||||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem)),
|
new MetadataRefreshOptions(new DirectoryService(_fileSystem)),
|
||||||
recursive: false,
|
recursive: false,
|
||||||
@@ -1110,18 +1170,24 @@ namespace Emby.Server.Implementations.Library
|
|||||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Quickly scan CollectionFolders for changes
|
// Quickly scan CollectionFolders for changes
|
||||||
foreach (var child in GetUserRootFolder().Children.OfType<Folder>())
|
var toDelete = new List<Guid>();
|
||||||
|
foreach (var child in rootFolder.Children!.OfType<Folder>())
|
||||||
{
|
{
|
||||||
// If the user has somehow deleted the collection directory, remove the metadata from the database.
|
// If the user has somehow deleted the collection directory, remove the metadata from the database.
|
||||||
if (child is CollectionFolder collectionFolder && !Directory.Exists(collectionFolder.Path))
|
if (child is CollectionFolder collectionFolder && !Directory.Exists(collectionFolder.Path))
|
||||||
{
|
{
|
||||||
_itemRepository.DeleteItem(collectionFolder.Id);
|
toDelete.Add(collectionFolder.Id);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await child.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
await child.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toDelete.Count > 0)
|
||||||
|
{
|
||||||
|
_itemRepository.DeleteItem(toDelete.ToArray());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PerformLibraryValidation(IProgress<double> progress, CancellationToken cancellationToken)
|
private async Task PerformLibraryValidation(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
@@ -1934,6 +2000,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
RegisterItem(item);
|
RegisterItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parent is Folder folder)
|
||||||
|
{
|
||||||
|
folder.Children = null;
|
||||||
|
folder.UserData = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (ItemAdded is not null)
|
if (ItemAdded is not null)
|
||||||
{
|
{
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
@@ -2027,6 +2099,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(image.Path))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Image not found at {ImagePath}", image.Path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
ImageDimensions size;
|
ImageDimensions size;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -2064,6 +2142,8 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item.ValidateImages();
|
||||||
|
|
||||||
_itemRepository.SaveImages(item);
|
_itemRepository.SaveImages(item);
|
||||||
|
|
||||||
RegisterItem(item);
|
RegisterItem(item);
|
||||||
@@ -2083,6 +2163,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
_itemRepository.SaveItems(items, cancellationToken);
|
_itemRepository.SaveItems(items, cancellationToken);
|
||||||
|
|
||||||
|
if (parent is Folder folder)
|
||||||
|
{
|
||||||
|
folder.Children = null;
|
||||||
|
folder.UserData = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (ItemUpdated is not null)
|
if (ItemUpdated is not null)
|
||||||
{
|
{
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
@@ -2116,6 +2202,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||||
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
|
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
|
||||||
{
|
{
|
||||||
if (item.IsFileProtocol)
|
if (item.IsFileProtocol)
|
||||||
@@ -2986,10 +3078,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
await ValidateTopLibraryFolders(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
if (refreshLibrary)
|
if (refreshLibrary)
|
||||||
{
|
{
|
||||||
await ValidateTopLibraryFolders(CancellationToken.None).ConfigureAwait(false);
|
|
||||||
|
|
||||||
StartScanInBackground();
|
StartScanInBackground();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -3109,19 +3201,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||||
|
|
||||||
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
CreateShortcut(virtualFolderPath, pathInfo);
|
||||||
|
|
||||||
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
|
||||||
|
|
||||||
while (File.Exists(lnk))
|
|
||||||
{
|
|
||||||
shortcutFilename += "1";
|
|
||||||
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
|
||||||
}
|
|
||||||
|
|
||||||
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
|
||||||
|
|
||||||
RemoveContentTypeOverrides(path);
|
|
||||||
|
|
||||||
if (saveLibraryOptions)
|
if (saveLibraryOptions)
|
||||||
{
|
{
|
||||||
@@ -3286,5 +3366,24 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
return item is UserRootFolder || item.IsVisibleStandalone(user);
|
return item is UserRootFolder || item.IsVisibleStandalone(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo)
|
||||||
|
{
|
||||||
|
var path = pathInfo.Path;
|
||||||
|
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||||
|
|
||||||
|
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
||||||
|
|
||||||
|
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||||
|
|
||||||
|
while (File.Exists(lnk))
|
||||||
|
{
|
||||||
|
shortcutFilename += "1";
|
||||||
|
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||||
|
}
|
||||||
|
|
||||||
|
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
||||||
|
RemoveContentTypeOverrides(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <inheritdoc />>
|
/// <inheritdoc />>
|
||||||
public MediaProtocol GetPathProtocol(string path)
|
public MediaProtocol GetPathProtocol(string path)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return MediaProtocol.File;
|
||||||
|
}
|
||||||
|
|
||||||
if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase))
|
if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return MediaProtocol.Rtsp;
|
return MediaProtocol.Rtsp;
|
||||||
@@ -657,7 +662,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "_jsonSerializer.DeserializeFromFile threw an exception.");
|
_logger.LogDebug(ex, "Error parsing cached media info.");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions)
|
public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions)
|
||||||
{
|
{
|
||||||
return GetInstantMixFromGenres(item.Genres, user, dtoOptions);
|
var instantMixItems = GetInstantMixFromGenres(item.Genres, user, dtoOptions);
|
||||||
|
|
||||||
|
return [item, .. instantMixItems.Where(i => !i.Id.Equals(item.Id))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -45,11 +47,14 @@ namespace Emby.Server.Implementations.Library
|
|||||||
public IReadOnlyList<BaseItem> GetInstantMixFromFolder(Folder item, User? user, DtoOptions dtoOptions)
|
public IReadOnlyList<BaseItem> GetInstantMixFromFolder(Folder item, User? user, DtoOptions dtoOptions)
|
||||||
{
|
{
|
||||||
var genres = item
|
var genres = item
|
||||||
.GetRecursiveChildren(user, new InternalItemsQuery(user)
|
.GetRecursiveChildren(
|
||||||
{
|
user,
|
||||||
IncludeItemTypes = [BaseItemKind.Audio],
|
new InternalItemsQuery(user)
|
||||||
DtoOptions = dtoOptions
|
{
|
||||||
})
|
IncludeItemTypes = [BaseItemKind.Audio],
|
||||||
|
DtoOptions = dtoOptions
|
||||||
|
},
|
||||||
|
out _)
|
||||||
.Cast<Audio>()
|
.Cast<Audio>()
|
||||||
.SelectMany(i => i.Genres)
|
.SelectMany(i => i.Genres)
|
||||||
.Concat(item.Genres)
|
.Concat(item.Genres)
|
||||||
|
|||||||
@@ -369,13 +369,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
|||||||
// We need to only look at the name of this actual item (not parents)
|
// We need to only look at the name of this actual item (not parents)
|
||||||
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
|
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
|
||||||
|
|
||||||
if (!justName.IsEmpty)
|
var tmdbid = justName.GetAttributeValue("tmdbid");
|
||||||
|
|
||||||
|
// If not in a mixed folder and ID not found in folder path, check filename
|
||||||
|
if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
|
||||||
{
|
{
|
||||||
// Check for TMDb id
|
tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
|
||||||
var tmdbid = justName.GetAttributeValue("tmdbid");
|
|
||||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(item.Path))
|
if (!string.IsNullOrEmpty(item.Path))
|
||||||
{
|
{
|
||||||
// Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name)
|
// Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name)
|
||||||
@@ -405,6 +408,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
|||||||
|
|
||||||
if (child.IsDirectory)
|
if (child.IsDirectory)
|
||||||
{
|
{
|
||||||
|
if (NamingOptions.AllExtrasTypesFolderNames.ContainsKey(filename))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (IsDvdDirectory(child.FullName, filename, directoryService))
|
if (IsDvdDirectory(child.FullName, filename, directoryService))
|
||||||
{
|
{
|
||||||
var movie = new T
|
var movie = new T
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var userId = user.InternalId;
|
var userId = user.InternalId;
|
||||||
var cacheKey = GetCacheKey(userId, item.Id);
|
var cacheKey = GetCacheKey(userId, item.Id);
|
||||||
_cache.AddOrUpdate(cacheKey, userData);
|
_cache.AddOrUpdate(cacheKey, userData);
|
||||||
|
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); // rehydrate the cached userdata
|
||||||
|
|
||||||
UserDataSaved?.Invoke(this, new UserDataSaveEventArgs
|
UserDataSaved?.Invoke(this, new UserDataSaveEventArgs
|
||||||
{
|
{
|
||||||
@@ -159,7 +160,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserItemData Map(UserData dto)
|
private static UserItemData Map(UserData dto)
|
||||||
{
|
{
|
||||||
return new UserItemData()
|
return new UserItemData()
|
||||||
{
|
{
|
||||||
@@ -237,7 +238,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public UserItemData? GetUserData(User user, BaseItem item)
|
public UserItemData? GetUserData(User user, BaseItem item)
|
||||||
{
|
{
|
||||||
return GetUserData(user, item.Id, item.GetUserDataKeys());
|
return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
|
||||||
|
{
|
||||||
|
Key = item.GetUserDataKeys()[0],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -304,7 +308,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
// ignore progress during the beginning
|
// ignore progress during the beginning
|
||||||
positionTicks = 0;
|
positionTicks = 0;
|
||||||
}
|
}
|
||||||
else if (pctIn > _config.Configuration.MaxResumePct || positionTicks >= runtimeTicks)
|
else if (pctIn > _config.Configuration.MaxResumePct || positionTicks >= (runtimeTicks - TimeSpan.TicksPerSecond))
|
||||||
{
|
{
|
||||||
// mark as completed close to the end
|
// mark as completed close to the end
|
||||||
positionTicks = 0;
|
positionTicks = 0;
|
||||||
|
|||||||
@@ -374,13 +374,22 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
if (request.GroupItems)
|
if (request.GroupItems)
|
||||||
{
|
{
|
||||||
if (parents.OfType<ICollectionFolder>().All(i => i.CollectionType == CollectionType.tvshows))
|
var collectionType = parents
|
||||||
|
.Select(parent => parent switch
|
||||||
|
{
|
||||||
|
ICollectionFolder collectionFolder => collectionFolder.CollectionType,
|
||||||
|
UserView userView => userView.CollectionType,
|
||||||
|
_ => null
|
||||||
|
})
|
||||||
|
.FirstOrDefault(type => type is not null);
|
||||||
|
|
||||||
|
if (collectionType == CollectionType.tvshows)
|
||||||
{
|
{
|
||||||
query.Limit = limit;
|
query.Limit = limit;
|
||||||
return _libraryManager.GetLatestItemList(query, parents, CollectionType.tvshows);
|
return _libraryManager.GetLatestItemList(query, parents, CollectionType.tvshows);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parents.OfType<ICollectionFolder>().All(i => i.CollectionType == CollectionType.music))
|
if (collectionType == CollectionType.music)
|
||||||
{
|
{
|
||||||
query.Limit = limit;
|
query.Limit = limit;
|
||||||
return _libraryManager.GetLatestItemList(query, parents, CollectionType.music);
|
return _libraryManager.GetLatestItemList(query, parents, CollectionType.music);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
@@ -55,6 +55,8 @@ public class PeopleValidator
|
|||||||
|
|
||||||
var numPeople = people.Count;
|
var numPeople = people.Count;
|
||||||
|
|
||||||
|
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
||||||
|
|
||||||
_logger.LogDebug("Will refresh {Amount} people", numPeople);
|
_logger.LogDebug("Will refresh {Amount} people", numPeople);
|
||||||
|
|
||||||
foreach (var person in people)
|
foreach (var person in people)
|
||||||
@@ -92,7 +94,7 @@ public class PeopleValidator
|
|||||||
double percent = numComplete;
|
double percent = numComplete;
|
||||||
percent /= numPeople;
|
percent /= numPeople;
|
||||||
|
|
||||||
progress.Report(100 * percent);
|
subProgress.Report(100 * percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
@@ -102,17 +104,13 @@ public class PeopleValidator
|
|||||||
IsLocked = false
|
IsLocked = false
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach (var item in deadEntities)
|
subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
|
||||||
{
|
|
||||||
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
|
||||||
|
|
||||||
_libraryManager.DeleteItem(
|
var i = 0;
|
||||||
item,
|
foreach (var item in deadEntities.Chunk(500))
|
||||||
new DeleteOptions
|
{
|
||||||
{
|
_libraryManager.DeleteItemsUnsafeFast(item);
|
||||||
DeleteFileLocation = false
|
subProgress.Report(100f / deadEntities.Count * (i++ * 100));
|
||||||
},
|
|
||||||
false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"Sync": "Сінхранізаваць",
|
"Sync": "Сінхранізаваць",
|
||||||
"Playlists": "Спісы прайгравання",
|
"Playlists": "Плэй-лісты",
|
||||||
"Latest": "Апошні",
|
"Latest": "Апошняе",
|
||||||
"LabelIpAddressValue": "IP-адрас: {0}",
|
"LabelIpAddressValue": "IP-адрас: {0}",
|
||||||
"ItemAddedWithName": "{0} быў дададзены ў бібліятэку",
|
"ItemAddedWithName": "{0} даданы ў бібліятэку",
|
||||||
"MessageApplicationUpdated": "Сервер Jellyfin абноўлены",
|
"MessageApplicationUpdated": "Сервер Jellyfin абноўлены",
|
||||||
"NotificationOptionApplicationUpdateInstalled": "Абнаўленне прыкладання ўсталявана",
|
"NotificationOptionApplicationUpdateInstalled": "Абнаўленне праграмы ўсталявана",
|
||||||
"PluginInstalledWithName": "{0} быў усталяваны",
|
"PluginInstalledWithName": "{0} быў усталяваны",
|
||||||
"UserCreatedWithName": "Карыстальнік {0} быў створаны",
|
"UserCreatedWithName": "Карыстальнік {0} быў створаны",
|
||||||
"Albums": "Альбомы",
|
"Albums": "Альбомы",
|
||||||
"Application": "Прыкладанне",
|
"Application": "Праграма",
|
||||||
"AuthenticationSucceededWithUserName": "{0} паспяхова аўтэнтыфікаваны",
|
"AuthenticationSucceededWithUserName": "{0} паспяхова аўтарызаваны",
|
||||||
"Channels": "Каналы",
|
"Channels": "Каналы",
|
||||||
"ChapterNameValue": "Раздзел {0}",
|
"ChapterNameValue": "Раздзел {0}",
|
||||||
"Collections": "Калекцыі",
|
"Collections": "Калекцыі",
|
||||||
@@ -29,18 +29,18 @@
|
|||||||
"HeaderAlbumArtists": "Выканаўцы альбома",
|
"HeaderAlbumArtists": "Выканаўцы альбома",
|
||||||
"LabelRunningTimeValue": "Працягласць: {0}",
|
"LabelRunningTimeValue": "Працягласць: {0}",
|
||||||
"HomeVideos": "Хатнія відэа",
|
"HomeVideos": "Хатнія відэа",
|
||||||
"ItemRemovedWithName": "{0} быў выдалены з бібліятэкі",
|
"ItemRemovedWithName": "{0} выдалены з бібліятэкі",
|
||||||
"MessageApplicationUpdatedTo": "Сервер Jellyfin абноўлены да {0}",
|
"MessageApplicationUpdatedTo": "Сервер Jellyfin абноўлены да версіі {0}",
|
||||||
"Movies": "Фільмы",
|
"Movies": "Фільмы",
|
||||||
"Music": "Музыка",
|
"Music": "Музыка",
|
||||||
"MusicVideos": "Музычныя кліпы",
|
"MusicVideos": "Музычныя кліпы",
|
||||||
"NameInstallFailed": "Устаноўка {0} не атрымалася",
|
"NameInstallFailed": "Усталяванне {0} не атрымалася",
|
||||||
"NameSeasonNumber": "Сезон {0}",
|
"NameSeasonNumber": "Сезон {0}",
|
||||||
"NotificationOptionApplicationUpdateAvailable": "Даступна абнаўленне прыкладання",
|
"NotificationOptionApplicationUpdateAvailable": "Даступна абнаўленне праграмы",
|
||||||
"NotificationOptionPluginInstalled": "Плагін усталяваны",
|
"NotificationOptionPluginInstalled": "Плагін усталяваны",
|
||||||
"NotificationOptionPluginUpdateInstalled": "Абнаўленне плагіна усталявана",
|
"NotificationOptionPluginUpdateInstalled": "Абнаўленне плагіна ўсталявана",
|
||||||
"NotificationOptionServerRestartRequired": "Патрабуецца перазапуск сервера",
|
"NotificationOptionServerRestartRequired": "Патрабуецца перазапуск сервера",
|
||||||
"Photos": "Фатаграфіі",
|
"Photos": "Фотаздымкі",
|
||||||
"Plugin": "Плагін",
|
"Plugin": "Плагін",
|
||||||
"PluginUninstalledWithName": "{0} быў выдалены",
|
"PluginUninstalledWithName": "{0} быў выдалены",
|
||||||
"PluginUpdatedWithName": "{0} быў абноўлены",
|
"PluginUpdatedWithName": "{0} быў абноўлены",
|
||||||
@@ -54,16 +54,16 @@
|
|||||||
"Artists": "Выканаўцы",
|
"Artists": "Выканаўцы",
|
||||||
"UserOfflineFromDevice": "{0} адлучыўся ад {1}",
|
"UserOfflineFromDevice": "{0} адлучыўся ад {1}",
|
||||||
"UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}",
|
"UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}",
|
||||||
"TaskCleanActivityLogDescription": "Выдаляе старэйшыя за зададзены ўзрост запісы ў журнале актыўнасці.",
|
"TaskCleanActivityLogDescription": "Выдаляе запісы старэйшыя за зададзены ўзрост ў журнале актыўнасці.",
|
||||||
"TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.",
|
"TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.",
|
||||||
"TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.",
|
"TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.",
|
||||||
"TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія настроены на аўтаматычнае абнаўленне.",
|
"TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія сканфігураваныя на аўтаматычнае абнаўленне.",
|
||||||
"TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.",
|
"TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.",
|
||||||
"TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субтытры на аснове канфігурацыі метададзеных.",
|
"TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метададзеных.",
|
||||||
"TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых змяненняў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць прадукцыйнасць.",
|
"TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых зменаў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць выдайнасць.",
|
||||||
"TaskKeyframeExtractor": "Экстрактар ключавых кадраў",
|
"TaskKeyframeExtractor": "Экстрактар ключавых кадраў",
|
||||||
"TasksApplicationCategory": "Прыкладанне",
|
"TasksApplicationCategory": "Праграма",
|
||||||
"AppDeviceValues": "Прыкладанне: {0}, Прылада: {1}",
|
"AppDeviceValues": "Праграма: {0}, Прылада: {1}",
|
||||||
"Books": "Кнігі",
|
"Books": "Кнігі",
|
||||||
"CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}",
|
"CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}",
|
||||||
"DeviceOfflineWithName": "{0} адлучыўся",
|
"DeviceOfflineWithName": "{0} адлучыўся",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"HeaderFavoriteArtists": "Абраныя выканаўцы",
|
"HeaderFavoriteArtists": "Абраныя выканаўцы",
|
||||||
"HearingImpaired": "Са слабым слыхам",
|
"HearingImpaired": "Са слабым слыхам",
|
||||||
"Inherit": "Атрымаць у спадчыну",
|
"Inherit": "Атрымаць у спадчыну",
|
||||||
"MessageNamedServerConfigurationUpdatedWithValue": "Канфігурацыя сервера {0} абноўлена",
|
"MessageNamedServerConfigurationUpdatedWithValue": "Канфігурацыя сервера (секцыя {0}) абноўлена",
|
||||||
"MessageServerConfigurationUpdated": "Канфігурацыя сервера абноўлена",
|
"MessageServerConfigurationUpdated": "Канфігурацыя сервера абноўлена",
|
||||||
"MixedContent": "Змешаны змест",
|
"MixedContent": "Змешаны змест",
|
||||||
"NameSeasonUnknown": "Невядомы сезон",
|
"NameSeasonUnknown": "Невядомы сезон",
|
||||||
@@ -92,48 +92,48 @@
|
|||||||
"NotificationOptionVideoPlaybackStopped": "Прайграванне відэа спынена",
|
"NotificationOptionVideoPlaybackStopped": "Прайграванне відэа спынена",
|
||||||
"ScheduledTaskFailedWithName": "{0} не атрымалася",
|
"ScheduledTaskFailedWithName": "{0} не атрымалася",
|
||||||
"ScheduledTaskStartedWithName": "{0} пачалося",
|
"ScheduledTaskStartedWithName": "{0} пачалося",
|
||||||
"ServerNameNeedsToBeRestarted": "{0} трэба перазапусціць",
|
"ServerNameNeedsToBeRestarted": "{0} патрабуе перазапуску",
|
||||||
"Shows": "Шоу",
|
"Shows": "Шоу",
|
||||||
"StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.",
|
"StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.",
|
||||||
"SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}",
|
"SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}",
|
||||||
"TvShows": "ТБ-шоу",
|
"TvShows": "Тэлепраграма",
|
||||||
"Undefined": "Нявызначана",
|
"Undefined": "Нявызначана",
|
||||||
"UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны",
|
"UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны",
|
||||||
"UserOnlineFromDevice": "{0} падключаны з {1}",
|
"UserOnlineFromDevice": "{0} падключаны з {1}",
|
||||||
"UserPasswordChangedWithName": "Пароль быў зменены для карыстальніка {0}",
|
"UserPasswordChangedWithName": "Пароль быў зменены для карыстальніка {0}",
|
||||||
"UserStartedPlayingItemWithValues": "{0} грае {1} на {2}",
|
"UserStartedPlayingItemWithValues": "{0} прайграваецца {1} на {2}",
|
||||||
"UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}",
|
"UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}",
|
||||||
"ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку",
|
"ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку",
|
||||||
"ValueSpecialEpisodeName": "Спецэпізод - {0}",
|
"ValueSpecialEpisodeName": "Спецэпізод - {0}",
|
||||||
"VersionNumber": "Версія {0}",
|
"VersionNumber": "Версія {0}",
|
||||||
"TasksMaintenanceCategory": "Абслугоўванне",
|
"TasksMaintenanceCategory": "Абслугоўванне",
|
||||||
"TasksLibraryCategory": "Медыятэка",
|
"TasksLibraryCategory": "Бібліятэка",
|
||||||
"TasksChannelsCategory": "Інтэрнэт-каналы",
|
"TasksChannelsCategory": "Інтэрнэт-каналы",
|
||||||
"TaskCleanActivityLog": "Ачысціць журнал актыўнасці",
|
"TaskCleanActivityLog": "Ачысціць журнал актыўнасці",
|
||||||
"TaskCleanCache": "Ачысціць кэш",
|
"TaskCleanCache": "Ачысціць кэш",
|
||||||
"TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.",
|
"TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.",
|
||||||
"TaskRefreshChapterImages": "Выняць выявы раздзелаў",
|
"TaskRefreshChapterImages": "Вынуць выявы раздзелаў",
|
||||||
"TaskRefreshLibrary": "Сканіраваць медыятэку",
|
"TaskRefreshLibrary": "Сканаваць бібліятэку",
|
||||||
"TaskRefreshLibraryDescription": "Сканіруе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.",
|
"TaskRefreshLibraryDescription": "Скануе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.",
|
||||||
"TaskCleanLogs": "Ачысціць часопіс",
|
"TaskCleanLogs": "Ачысціць журнал",
|
||||||
"TaskRefreshPeople": "Абнавіць людзей",
|
"TaskRefreshPeople": "Абнавіць выканаўцаў",
|
||||||
"TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.",
|
"TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.",
|
||||||
"TaskUpdatePlugins": "Абнавіць плагіны",
|
"TaskUpdatePlugins": "Абнавіць плагіны",
|
||||||
"TaskCleanTranscode": "Ачысціць каталог перакадзіравання",
|
"TaskCleanTranscode": "Ачысціць каталог перакадзіравання",
|
||||||
"TaskCleanTranscodeDescription": "Выдаляе перакадзіраваныя файлы, старэйшыя за адзін дзень.",
|
"TaskCleanTranscodeDescription": "Выдаляе перакадзіраваныя файлы, старэйшыя за адзін дзень.",
|
||||||
"TaskRefreshChannels": "Абнавіць каналы",
|
"TaskRefreshChannels": "Абнавіць каналы",
|
||||||
"TaskDownloadMissingSubtitles": "Спампаваць адсутныя субтытры",
|
"TaskDownloadMissingSubtitles": "Спампаваць адсутныя субцітры",
|
||||||
"TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных спісаў прайгравання HLS. Гэта задача можа працаваць у працягу доўгага часу.",
|
"TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных плэй-лістоў HLS. Гэта задача можа працягнуцца шмат часу.",
|
||||||
"TaskRefreshTrickplayImages": "Стварыце выявы Trickplay",
|
"TaskRefreshTrickplayImages": "Стварыць выявы Trickplay",
|
||||||
"TaskRefreshTrickplayImagesDescription": "Стварае прагляд відэаролікаў для Trickplay у падключаных бібліятэках.",
|
"TaskRefreshTrickplayImagesDescription": "Стварае перадпрагляды відэаролікаў для Trickplay у падключаных бібліятэках.",
|
||||||
"TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і спісы прайгравання",
|
"TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і плэй-лісты",
|
||||||
"TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і спісаў прайгравання, якія больш не існуюць.",
|
"TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і плэй-лістоў, якія больш не існуюць.",
|
||||||
"TaskAudioNormalizationDescription": "Сканіруе файлы на прадмет нармалізацыі гуку.",
|
"TaskAudioNormalizationDescription": "Скануе файлы на прадмет нармалізацыі гуку.",
|
||||||
"TaskAudioNormalization": "Нармалізацыя гуку",
|
"TaskAudioNormalization": "Нармалізацыя гуку",
|
||||||
"TaskExtractMediaSegmentsDescription": "Выдае або атрымлівае медыясегменты з убудоў з падтрымкай MediaSegment.",
|
"TaskExtractMediaSegmentsDescription": "Выдае або атрымлівае медыясегменты з убудоў з падтрымкай MediaSegment.",
|
||||||
"TaskMoveTrickplayImagesDescription": "Перамяшчае існуючыя файлы trickplay у адпаведнасці з наладамі бібліятэкі.",
|
"TaskMoveTrickplayImagesDescription": "Перамяшчае існуючыя файлы trickplay у адпаведнасці з наладамі бібліятэкі.",
|
||||||
"TaskDownloadMissingLyrics": "Спампаваць зніклыя тэксты песень",
|
"TaskDownloadMissingLyrics": "Спампаваць адсутныя тэксты песняў",
|
||||||
"TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песень",
|
"TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песняў",
|
||||||
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
|
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
|
||||||
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
|
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
|
||||||
"CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка",
|
"CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"HeaderFavoriteAlbums": "প্রিয় এলবামগুলো",
|
"HeaderFavoriteAlbums": "প্রিয় এলবামগুলো",
|
||||||
"HeaderContinueWatching": "দেখতে থাকুন",
|
"HeaderContinueWatching": "দেখতে থাকুন",
|
||||||
"HeaderAlbumArtists": "অ্যালবাম শিল্পীবৃন্দ",
|
"HeaderAlbumArtists": "অ্যালবাম শিল্পীবৃন্দ",
|
||||||
"Genres": "জনরা",
|
"Genres": "ধরণ",
|
||||||
"Folders": "ফোল্ডারসমূহ",
|
"Folders": "ফোল্ডারসমূহ",
|
||||||
"Favorites": "পছন্দসমূহ",
|
"Favorites": "পছন্দসমূহ",
|
||||||
"FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে",
|
"FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে",
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
"Sync": "সমন্বয় করুন",
|
"Sync": "সমন্বয় করুন",
|
||||||
"SubtitleDownloadFailureFromForItem": "{0} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ হয়েছে",
|
"SubtitleDownloadFailureFromForItem": "{0} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ হয়েছে",
|
||||||
"StartupEmbyServerIsLoading": "জেলিফিন সার্ভার লোড হচ্ছে। দয়া করে একটু পরে আবার চেষ্টা করুন।",
|
"StartupEmbyServerIsLoading": "জেলিফিন সার্ভার লোড হচ্ছে। দয়া করে একটু পরে আবার চেষ্টা করুন।",
|
||||||
"Songs": "সঙ্গীতসমূহ",
|
"Songs": "সঙ্গীত সমূহ",
|
||||||
"Shows": "টিভি পর্ব",
|
"Shows": "শো সমূহ",
|
||||||
"ServerNameNeedsToBeRestarted": "{0} রিস্টার্ট করা প্রয়োজন",
|
"ServerNameNeedsToBeRestarted": "{0} রিস্টার্ট করা প্রয়োজন",
|
||||||
"ScheduledTaskStartedWithName": "{0} শুরু হয়েছে",
|
"ScheduledTaskStartedWithName": "{0} শুরু হয়েছে",
|
||||||
"ScheduledTaskFailedWithName": "{0} ব্যর্থ",
|
"ScheduledTaskFailedWithName": "{0} ব্যর্থ",
|
||||||
@@ -51,9 +51,9 @@
|
|||||||
"Plugin": "প্লাগিন",
|
"Plugin": "প্লাগিন",
|
||||||
"Playlists": "প্লে লিস্ট সমূহ",
|
"Playlists": "প্লে লিস্ট সমূহ",
|
||||||
"Photos": "ছবিসমূহ",
|
"Photos": "ছবিসমূহ",
|
||||||
"NotificationOptionVideoPlaybackStopped": "ভিডিও বন্ধ হয়েছে",
|
"NotificationOptionVideoPlaybackStopped": "ভিডিও প্লেব্যাক বন্ধ হয়েছে",
|
||||||
"NotificationOptionVideoPlayback": "ভিডিও শুরু হয়েছে",
|
"NotificationOptionVideoPlayback": "ভিডিও প্লেব্যাক শুরু হয়েছে",
|
||||||
"NotificationOptionUserLockedOut": "ব্যবহারকারী ঢুকতে পারছে না",
|
"NotificationOptionUserLockedOut": "ব্যবহারকারী লক আউট হয়েছে",
|
||||||
"NotificationOptionTaskFailed": "পরিকল্পিত কাজটি ব্যর্থ",
|
"NotificationOptionTaskFailed": "পরিকল্পিত কাজটি ব্যর্থ",
|
||||||
"NotificationOptionServerRestartRequired": "সার্ভার রিস্টার্ট করা লাগবে",
|
"NotificationOptionServerRestartRequired": "সার্ভার রিস্টার্ট করা লাগবে",
|
||||||
"NotificationOptionPluginUpdateInstalled": "প্লাগিন আপডেট ইন্সটল হয়েছে",
|
"NotificationOptionPluginUpdateInstalled": "প্লাগিন আপডেট ইন্সটল হয়েছে",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"LabelIpAddressValue": "আইপি এড্রেস: {0}",
|
"LabelIpAddressValue": "আইপি এড্রেস: {0}",
|
||||||
"ItemRemovedWithName": "{0} লাইব্রেরি থেকে বাদ দেয়া হয়েছে",
|
"ItemRemovedWithName": "{0} লাইব্রেরি থেকে বাদ দেয়া হয়েছে",
|
||||||
"ItemAddedWithName": "{0} লাইব্রেরিতে যোগ করা হয়েছে",
|
"ItemAddedWithName": "{0} লাইব্রেরিতে যোগ করা হয়েছে",
|
||||||
"Inherit": "মূল থেকে গ্রহণ করুন",
|
"Inherit": "উত্তরাধিকারসূত্র থেকে গ্রহণ করুন",
|
||||||
"HomeVideos": "হোম ভিডিও",
|
"HomeVideos": "হোম ভিডিও",
|
||||||
"HeaderNextUp": "এরপরে আসছে",
|
"HeaderNextUp": "এরপরে আসছে",
|
||||||
"HeaderLiveTV": "লাইভ টিভি",
|
"HeaderLiveTV": "লাইভ টিভি",
|
||||||
@@ -126,16 +126,16 @@
|
|||||||
"TaskKeyframeExtractorDescription": "ভিডিয়ো থেকে কি-ফ্রেম নিষ্কাশনের মাধ্যমে অধিকতর সঠিক HLS প্লে লিস্ট তৈরী করে। এই প্রক্রিয়া দীর্ঘ সময় ধরে চলতে পারে।",
|
"TaskKeyframeExtractorDescription": "ভিডিয়ো থেকে কি-ফ্রেম নিষ্কাশনের মাধ্যমে অধিকতর সঠিক HLS প্লে লিস্ট তৈরী করে। এই প্রক্রিয়া দীর্ঘ সময় ধরে চলতে পারে।",
|
||||||
"TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি",
|
"TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি",
|
||||||
"TaskRefreshTrickplayImagesDescription": "সক্ষম লাইব্রেরিতে ভিডিওর জন্য ট্রিকপ্লে প্রিভিউ তৈরি করে।",
|
"TaskRefreshTrickplayImagesDescription": "সক্ষম লাইব্রেরিতে ভিডিওর জন্য ট্রিকপ্লে প্রিভিউ তৈরি করে।",
|
||||||
"TaskDownloadMissingLyricsDescription": "গানের লিরিক্স ডাউনলোড করে",
|
"TaskDownloadMissingLyricsDescription": "গানের জন্য লিরিকস ডাউনলোড করুন",
|
||||||
"TaskCleanCollectionsAndPlaylists": "কালেকশন এবং প্লেলিস্ট পরিষ্কার করুন",
|
"TaskCleanCollectionsAndPlaylists": "কালেকশন এবং প্লেলিস্ট পরিষ্কার করুন",
|
||||||
"TaskCleanCollectionsAndPlaylistsDescription": "কালেকশন এবং প্লেলিস্ট থেকে আইটেমগুলি সরিয়ে দেয় যা আর বিদ্যমান নেই।",
|
"TaskCleanCollectionsAndPlaylistsDescription": "কালেকশন এবং প্লেলিস্ট থেকে আইটেমগুলি সরিয়ে দেয় যা আর বিদ্যমান নেই।",
|
||||||
"TaskExtractMediaSegments": "মিডিয়া সেগমেন্ট স্ক্যান",
|
"TaskExtractMediaSegments": "মিডিয়া সেগমেন্ট স্ক্যান",
|
||||||
"TaskExtractMediaSegmentsDescription": "মিডিয়া সেগমেন্ট সক্রিয় প্লাগইনগুলি থেকে মিডিয়া সেগমেন্টগুলি বের করে বা প্রাপ্ত করে।",
|
"TaskExtractMediaSegmentsDescription": "মিডিয়া সেগমেন্ট সক্ষম প্লাগইনগুলি থেকে মিডিয়া সেগমেন্ট বের করে বা অর্জন করে।",
|
||||||
"TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন",
|
"TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন",
|
||||||
"TaskMoveTrickplayImagesDescription": "লাইব্রেরির সেটিং অনুযায়ী বিদ্যমান ট্রিকপ্লে ফাইলগুলো সরিয়ে নেবে।",
|
"TaskMoveTrickplayImagesDescription": "লাইব্রেরির সেটিং অনুযায়ী বিদ্যমান ট্রিকপ্লে ফাইলগুলো সরিয়ে নেবে।",
|
||||||
"TaskAudioNormalizationDescription": "অডিও নর্মালাইজেশন তথ্যের জন্য ফাইল স্ক্যান করবে।",
|
"TaskAudioNormalizationDescription": "অডিও নর্মালাইজেশন তথ্যের জন্য ফাইল স্ক্যান করবে।",
|
||||||
"CleanupUserDataTaskDescription": "৯০ দিন বা তার বেশি সময় ধরে অনুপস্থিত মিডিয়া থেকে সকল ব্যবহারকারীর ডেটা (ওয়াচ স্টেট, ফেভারিট স্ট্যাটাস ইত্যাদি) মুছে ফেলবে।",
|
"CleanupUserDataTaskDescription": "৯০ দিন বা তার বেশি সময় ধরে অনুপস্থিত মিডিয়া থেকে সকল ব্যবহারকারীর ডেটা (ওয়াচ স্টেট, ফেভারিট স্ট্যাটাস ইত্যাদি) মুছে ফেলবে।",
|
||||||
"TaskMoveTrickplayImages": "ট্রিকপ্লে ইমেজের অবস্থান পরিবর্তন",
|
"TaskMoveTrickplayImages": "ট্রিকপ্লে ইমেজের অবস্থান পরিবর্তন",
|
||||||
"TaskAudioNormalization": "অডিও নর্মলাইজেশন",
|
"TaskAudioNormalization": "অডিও নর্মলাইজেশন",
|
||||||
"CleanupUserDataTask": "ব্যবহারকারীর ডেটা পরিষ্কারের কাজ"
|
"CleanupUserDataTask": "ইউজার ডেটা ক্লিনআপ কাজ"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,5 +138,5 @@
|
|||||||
"TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren",
|
"TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren",
|
||||||
"TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben.",
|
"TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben.",
|
||||||
"CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten",
|
"CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten",
|
||||||
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Anschaustatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind."
|
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,5 +136,7 @@
|
|||||||
"TaskExtractMediaSegments": "Escanear Segmentos de Media",
|
"TaskExtractMediaSegments": "Escanear Segmentos de Media",
|
||||||
"TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medio de plugins habilitados para MediaSegment.",
|
"TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medio de plugins habilitados para MediaSegment.",
|
||||||
"TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.",
|
"TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.",
|
||||||
"TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay"
|
"TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay",
|
||||||
|
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
|
||||||
|
"CleanupUserDataTask": "Tarea de limpieza de datos de usuarios"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,5 +137,6 @@
|
|||||||
"TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.",
|
"TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.",
|
||||||
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
||||||
"TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.",
|
"TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.",
|
||||||
"CleanupUserDataTask": "Tarea de limpieza de los datos del usuario"
|
"CleanupUserDataTask": "Tarea de limpieza de los datos del usuario",
|
||||||
|
"CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,5 +125,11 @@
|
|||||||
"Undefined": "Sin definir",
|
"Undefined": "Sin definir",
|
||||||
"TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.",
|
"TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.",
|
||||||
"TaskCleanCacheDescription": "Elimina archivos caché que ya no son necesarios para el sistema.",
|
"TaskCleanCacheDescription": "Elimina archivos caché que ya no son necesarios para el sistema.",
|
||||||
"TaskCleanLogsDescription": "Elimina archivos de registro con más de {0} días de antigüedad."
|
"TaskCleanLogsDescription": "Elimina archivos de registro con más de {0} días de antigüedad.",
|
||||||
|
"NotificationOptionApplicationUpdateAvailable": "actualización disponible",
|
||||||
|
"TaskDownloadMissingLyrics": "Descargue letras desaparecidas",
|
||||||
|
"TaskDownloadMissingLyricsDescription": "Decarga letras para canciones",
|
||||||
|
"TaskMoveTrickplayImages": "Mover localización de foto vista previa",
|
||||||
|
"NotificationOptionApplicationUpdateInstalled": "Aplicación actualización disponible",
|
||||||
|
"CleanupUserDataTask": "Tarea de limpieza de los datos del usuario"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"TaskCleanActivityLogDescription": "Kustutab määratud ajast vanemad tegevuslogi kirjed.",
|
"TaskCleanActivityLogDescription": "Kustutab määratud ajast vanemad tegevuslogi kirjed.",
|
||||||
"UserDownloadingItemWithValues": "{0} laeb alla {1}",
|
"UserDownloadingItemWithValues": "{0} laadib alla {1}",
|
||||||
"HeaderRecordingGroups": "Salvestusrühmad",
|
"HeaderRecordingGroups": "Salvestusrühmad",
|
||||||
"TaskOptimizeDatabaseDescription": "Tihendab ja puhastab andmebaasi. Selle toimingu tegemine pärast meediakogu andmebaasiga seotud muudatuste skannimist võib jõudlust parandada.",
|
"TaskOptimizeDatabaseDescription": "Tihendab ja puhastab andmebaasi. Selle toimingu tegemine pärast meediakogu andmebaasiga seotud muudatuste skannimist võib jõudlust parandada.",
|
||||||
"TaskOptimizeDatabase": "Optimeeri andmebaasi",
|
"TaskOptimizeDatabase": "Optimeeri andmebaasi",
|
||||||
"TaskDownloadMissingSubtitlesDescription": "Otsib veebist puuduvaid subtiitreid vastavalt määratud metaandmete seadetele.",
|
"TaskDownloadMissingSubtitlesDescription": "Otsib veebist puuduvaid subtiitreid vastavalt määratud metaandmete seadetele.",
|
||||||
"TaskDownloadMissingSubtitles": "Laadi alla puuduvad subtiitrid",
|
"TaskDownloadMissingSubtitles": "Hangi puuduvad subtiitrid",
|
||||||
"TaskRefreshChannelsDescription": "Värskendab veebikanalite teavet.",
|
"TaskRefreshChannelsDescription": "Värskendab veebikanalite teavet.",
|
||||||
"TaskRefreshChannels": "Värskenda kanaleid",
|
"TaskRefreshChannels": "Värskenda kanaleid",
|
||||||
"TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkodeerimisfailid.",
|
"TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkoodimisfailid.",
|
||||||
"TaskCleanTranscode": "Puhasta transkoodimise kataloog",
|
"TaskCleanTranscode": "Puhasta transkoodimise kataloog",
|
||||||
"TaskUpdatePluginsDescription": "Laadib alla ja paigaldab nende pluginate uuendused, mis on seadistatud automaatselt uuenduma.",
|
"TaskUpdatePluginsDescription": "Laadib alla ja paigaldab nende pluginate uuendused, mis on seadistatud automaatselt uuenduma.",
|
||||||
"TaskUpdatePlugins": "Uuenda pluginaid",
|
"TaskUpdatePlugins": "Uuenda pluginaid",
|
||||||
@@ -41,10 +41,10 @@
|
|||||||
"StartupEmbyServerIsLoading": "Jellyfin server laadib. Proovi varsti uuesti.",
|
"StartupEmbyServerIsLoading": "Jellyfin server laadib. Proovi varsti uuesti.",
|
||||||
"User": "Kasutaja",
|
"User": "Kasutaja",
|
||||||
"Undefined": "Määratlemata",
|
"Undefined": "Määratlemata",
|
||||||
"TvShows": "Seriaalid",
|
"TvShows": "Sarjad",
|
||||||
"System": "Süsteem",
|
"System": "Süsteem",
|
||||||
"Sync": "Sünkrooni",
|
"Sync": "Sünkrooni",
|
||||||
"Songs": "Laulud",
|
"Songs": "Lood",
|
||||||
"Shows": "Sarjad",
|
"Shows": "Sarjad",
|
||||||
"ServerNameNeedsToBeRestarted": "{0} tuleb taaskäivitada",
|
"ServerNameNeedsToBeRestarted": "{0} tuleb taaskäivitada",
|
||||||
"ScheduledTaskFailedWithName": "{0} nurjus",
|
"ScheduledTaskFailedWithName": "{0} nurjus",
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
"HeaderNextUp": "Järgmisena",
|
"HeaderNextUp": "Järgmisena",
|
||||||
"HeaderLiveTV": "Otse TV",
|
"HeaderLiveTV": "Otse TV",
|
||||||
"HeaderFavoriteSongs": "Lemmiklood",
|
"HeaderFavoriteSongs": "Lemmiklood",
|
||||||
"HeaderFavoriteShows": "Lemmikseriaalid",
|
"HeaderFavoriteShows": "Lemmiksarjad",
|
||||||
"HeaderFavoriteEpisodes": "Lemmikepisoodid",
|
"HeaderFavoriteEpisodes": "Lemmikepisoodid",
|
||||||
"HeaderFavoriteArtists": "Lemmikesitajad",
|
"HeaderFavoriteArtists": "Lemmikesitajad",
|
||||||
"HeaderFavoriteAlbums": "Lemmikalbumid",
|
"HeaderFavoriteAlbums": "Lemmikalbumid",
|
||||||
@@ -122,18 +122,20 @@
|
|||||||
"UserOnlineFromDevice": "{0} on ühendatud seadmest {1}",
|
"UserOnlineFromDevice": "{0} on ühendatud seadmest {1}",
|
||||||
"External": "Väline",
|
"External": "Väline",
|
||||||
"HearingImpaired": "Kuulmispuudega",
|
"HearingImpaired": "Kuulmispuudega",
|
||||||
"TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadreid, et luua täpsemaid HLS-i esitusloendeid. See ülesanne võib kesta pikka aega.",
|
"TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadrid, et luua täpsemaid HLS-i esitusloendeid. See võib kesta pikka aega.",
|
||||||
"TaskKeyframeExtractor": "Võtmekaadri ekstraktor",
|
"TaskKeyframeExtractor": "Eralda võtmekaadrid",
|
||||||
"TaskRefreshTrickplayImages": "Loo eelvaate pildid",
|
"TaskRefreshTrickplayImages": "Loo trickplay pildid",
|
||||||
"TaskRefreshTrickplayImagesDescription": "Loob eelvaated videotele, kus lubatud.",
|
"TaskRefreshTrickplayImagesDescription": "Loob trickplay eelvaated videotele lubatud meediakogudes.",
|
||||||
"TaskAudioNormalization": "Heli Normaliseerimine",
|
"TaskAudioNormalization": "Normaliseeri helitugevus",
|
||||||
"TaskAudioNormalizationDescription": "Skaneerib faile heli normaliseerimise andmete jaoks.",
|
"TaskAudioNormalizationDescription": "Otsib failidest helitugevuse normaliseerimise teavet.",
|
||||||
"TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest asjad, mida enam ei eksisteeri.",
|
"TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest üksused, mida enam ei eksisteeri.",
|
||||||
"TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid",
|
"TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid",
|
||||||
"TaskDownloadMissingLyrics": "Lae alla puuduolev lüürika",
|
"TaskDownloadMissingLyrics": "Hangi puuduvad laulusõnad",
|
||||||
"TaskDownloadMissingLyricsDescription": "Lae lauludele alla lüürika",
|
"TaskDownloadMissingLyricsDescription": "Laulusõnade allalaadimine",
|
||||||
"TaskMoveTrickplayImagesDescription": "Liigutab trickplay pildid meediakogu sätete kohaselt.",
|
"TaskMoveTrickplayImagesDescription": "Liigutab trickplay pildid meediakogu sätete kohaselt.",
|
||||||
"TaskExtractMediaSegments": "Meediasegmentide skaneerimine",
|
"TaskExtractMediaSegments": "Skaneeri meediasegmente",
|
||||||
"TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.",
|
"TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.",
|
||||||
"TaskMoveTrickplayImages": "Migreeri trickplay piltide asukoht"
|
"TaskMoveTrickplayImages": "Muuda trickplay piltide asukoht",
|
||||||
|
"CleanupUserDataTask": "Puhasta kasutajaandmed",
|
||||||
|
"CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mis pole enam vähemalt 90 päeva saadaval olnud."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
"TvShows": "Sarjat",
|
"TvShows": "Sarjat",
|
||||||
"Sync": "Synkronointi",
|
"Sync": "Synkronointi",
|
||||||
"SubtitleDownloadFailureFromForItem": "Tekstityksen lataus lähteestä \"{0}\" kohteelle \"{1}\" epäonnistui",
|
"SubtitleDownloadFailureFromForItem": "Tekstityksen lataus lähteestä \"{0}\" kohteelle \"{1}\" epäonnistui",
|
||||||
"StartupEmbyServerIsLoading": "Jellyfin-palvelin latautuu. Yritä hetken kuluttua uudelleen.",
|
"StartupEmbyServerIsLoading": "Jellyfin-palvelin on latautumassa. Yritä hetken kuluttua uudelleen.",
|
||||||
"Songs": "Kappaleet",
|
"Songs": "Kappaleet",
|
||||||
"Shows": "Sarjat",
|
"Shows": "Sarjat",
|
||||||
"ServerNameNeedsToBeRestarted": "\"{0}\" on käynnistettävä uudelleen",
|
"ServerNameNeedsToBeRestarted": "\"{0}\" on käynnistettävä uudelleen",
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
"NotificationOptionVideoPlayback": "Videon toisto aloitettu",
|
"NotificationOptionVideoPlayback": "Videon toisto aloitettu",
|
||||||
"NotificationOptionUserLockedOut": "Käyttäjä on lukittu",
|
"NotificationOptionUserLockedOut": "Käyttäjä on lukittu",
|
||||||
"NotificationOptionTaskFailed": "Ajoitettu tehtävä epäonnistui",
|
"NotificationOptionTaskFailed": "Ajoitettu tehtävä epäonnistui",
|
||||||
"NotificationOptionServerRestartRequired": "Tarvitaan palvelimen uudelleenkäynnistys",
|
"NotificationOptionServerRestartRequired": "Palvelimen uudelleenkäynnistys vaaditaan",
|
||||||
"NotificationOptionPluginUpdateInstalled": "Lisäosa päivitettiin",
|
"NotificationOptionPluginUpdateInstalled": "Lisäosa päivitettiin",
|
||||||
"NotificationOptionPluginUninstalled": "Lisäosa poistettiin",
|
"NotificationOptionPluginUninstalled": "Lisäosa poistettiin",
|
||||||
"NotificationOptionPluginInstalled": "Lisäosa asennettiin",
|
"NotificationOptionPluginInstalled": "Lisäosa asennettiin",
|
||||||
|
|||||||
@@ -1,74 +1,74 @@
|
|||||||
{
|
{
|
||||||
"Albums": "Álbumes",
|
"Albums": "Álbums",
|
||||||
"Collections": "Coleccións",
|
"Collections": "Coleccións",
|
||||||
"ChapterNameValue": "Capítulo {0}",
|
"ChapterNameValue": "Capítulo {0}",
|
||||||
"Channels": "Canles",
|
"Channels": "Canles",
|
||||||
"CameraImageUploadedFrom": "Cargouse unha nova imaxe da cámara desde {0}",
|
"CameraImageUploadedFrom": "Cargouse unha nova imaxe de cámara dende {0}",
|
||||||
"Books": "Libros",
|
"Books": "Libros",
|
||||||
"AuthenticationSucceededWithUserName": "{0} autenticouse correctamente",
|
"AuthenticationSucceededWithUserName": "{0} autenticouse correctamente",
|
||||||
"Artists": "Artistas",
|
"Artists": "Artistas",
|
||||||
"Application": "Aplicativo",
|
"Application": "Aplicación",
|
||||||
"NotificationOptionServerRestartRequired": "Necesario un reinicio do servidor",
|
"NotificationOptionServerRestartRequired": "Necesario o reinicio do servidor",
|
||||||
"NotificationOptionPluginUpdateInstalled": "Actualización do Plugin instalada",
|
"NotificationOptionPluginUpdateInstalled": "Actualización do plugin instalada",
|
||||||
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
|
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
|
||||||
"NotificationOptionPluginInstalled": "Plugin instalado",
|
"NotificationOptionPluginInstalled": "Plugin instalado",
|
||||||
"NotificationOptionPluginError": "Fallo do Plugin",
|
"NotificationOptionPluginError": "Fallo do plugin",
|
||||||
"NotificationOptionNewLibraryContent": "Novo contido engadido",
|
"NotificationOptionNewLibraryContent": "Novo contido engadido",
|
||||||
"NotificationOptionInstallationFailed": "Fallo na instalación",
|
"NotificationOptionInstallationFailed": "Fallo na instalación",
|
||||||
"NotificationOptionCameraImageUploaded": "Imaxe da cámara subida",
|
"NotificationOptionCameraImageUploaded": "Imaxe da cámara cargada",
|
||||||
"NotificationOptionAudioPlaybackStopped": "Reproducción de audio parada",
|
"NotificationOptionAudioPlaybackStopped": "Reproducción de audio detida",
|
||||||
"NotificationOptionAudioPlayback": "Reproducción de audio comezada",
|
"NotificationOptionAudioPlayback": "Reproducción de audio comezada",
|
||||||
"NotificationOptionApplicationUpdateInstalled": "Actualización da aplicación instalada",
|
"NotificationOptionApplicationUpdateInstalled": "Actualización da aplicación instalada",
|
||||||
"NotificationOptionApplicationUpdateAvailable": "Actualización da aplicación dispoñible",
|
"NotificationOptionApplicationUpdateAvailable": "Actualización da aplicación dispoñible",
|
||||||
"NewVersionIsAvailable": "Unha nova versión do Servidor Jellyfin está dispoñible para descarga.",
|
"NewVersionIsAvailable": "Nova versión do Servidor Jellyfin dispoñible para descargar.",
|
||||||
"NameSeasonUnknown": "Tempada descoñecida",
|
"NameSeasonUnknown": "Tempada descoñecida",
|
||||||
"NameSeasonNumber": "Tempada {0}",
|
"NameSeasonNumber": "Tempada {0}",
|
||||||
"NameInstallFailed": "{0} instalación fallida",
|
"NameInstallFailed": "{0} instalación fallida",
|
||||||
"MusicVideos": "Vídeos Musicais",
|
"MusicVideos": "Vídeos musicais",
|
||||||
"Music": "Música",
|
"Music": "Música",
|
||||||
"Movies": "Películas",
|
"Movies": "Películas",
|
||||||
"MixedContent": "Contido Mixto",
|
"MixedContent": "Contido mixto",
|
||||||
"MessageServerConfigurationUpdated": "A configuración do servidor foi actualizada",
|
"MessageServerConfigurationUpdated": "Actualizouse a configuración do servidor",
|
||||||
"MessageNamedServerConfigurationUpdatedWithValue": "A sección de configuración {0} do servidor foi actualizada",
|
"MessageNamedServerConfigurationUpdatedWithValue": "Actualizouse a sección de configuración {0} do servidor",
|
||||||
"MessageApplicationUpdatedTo": "O servidor Jellyfin foi actualizado a {0}",
|
"MessageApplicationUpdatedTo": "O servidor Jellyfin actualizouse a {0}",
|
||||||
"MessageApplicationUpdated": "O servidor Jellyfin foi actualizado",
|
"MessageApplicationUpdated": "O servidor Jellyfin actualizouse",
|
||||||
"Latest": "Último",
|
"Latest": "Último",
|
||||||
"LabelRunningTimeValue": "Tempo de execución: {0}",
|
"LabelRunningTimeValue": "Tempo en execución: {0}",
|
||||||
"LabelIpAddressValue": "Enderezo IP: {0}",
|
"LabelIpAddressValue": "Enderezo IP: {0}",
|
||||||
"ItemRemovedWithName": "{0} foi eliminado da biblioteca",
|
"ItemRemovedWithName": "{0} eliminouse da biblioteca",
|
||||||
"ItemAddedWithName": "{0} foi engadido a biblioteca",
|
"ItemAddedWithName": "{0} engadiuse á biblioteca",
|
||||||
"Inherit": "Herdar",
|
"Inherit": "Herdar",
|
||||||
"HomeVideos": "Videos caseiros",
|
"HomeVideos": "Videos caseiros",
|
||||||
"HeaderRecordingGroups": "Grupos de Grabación",
|
"HeaderRecordingGroups": "Grupos de grabación",
|
||||||
"HeaderNextUp": "De seguido",
|
"HeaderNextUp": "De seguido",
|
||||||
"HeaderLiveTV": "TV en directo",
|
"HeaderLiveTV": "TV en directo",
|
||||||
"HeaderFavoriteSongs": "Cancións Favoritas",
|
"HeaderFavoriteSongs": "Cancións favoritas",
|
||||||
"HeaderFavoriteShows": "Series de TV Favoritas",
|
"HeaderFavoriteShows": "Series de TV favoritas",
|
||||||
"HeaderFavoriteEpisodes": "Episodios Favoritos",
|
"HeaderFavoriteEpisodes": "Episodios favoritos",
|
||||||
"HeaderFavoriteArtists": "Artistas Favoritos",
|
"HeaderFavoriteArtists": "Artistas favoritos",
|
||||||
"HeaderFavoriteAlbums": "Álbunes Favoritos",
|
"HeaderFavoriteAlbums": "Álbums favoritos",
|
||||||
"HeaderContinueWatching": "Seguir vendo",
|
"HeaderContinueWatching": "Seguir vendo",
|
||||||
"HeaderAlbumArtists": "Artistas do Album",
|
"HeaderAlbumArtists": "Artistas do álbum",
|
||||||
"Genres": "Xéneros",
|
"Genres": "Xéneros",
|
||||||
"Forced": "Forzado",
|
"Forced": "Forzado",
|
||||||
"Folders": "Cartafoles",
|
"Folders": "Cartafoles",
|
||||||
"Favorites": "Favoritos",
|
"Favorites": "Favoritos",
|
||||||
"FailedLoginAttemptWithUserName": "Intento de incio de sesión fallido {0}",
|
"FailedLoginAttemptWithUserName": "Fallo de intento de inicio de sesión dende {0}",
|
||||||
"DeviceOnlineWithName": "{0} conectouse",
|
"DeviceOnlineWithName": "{0} conectouse",
|
||||||
"DeviceOfflineWithName": "{0} desconectouse",
|
"DeviceOfflineWithName": "{0} desconectouse",
|
||||||
"Default": "Por defecto",
|
"Default": "Por defecto",
|
||||||
"AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}",
|
"AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}",
|
||||||
"TaskCleanLogs": "Limpar Carpeta de Rexistros",
|
"TaskCleanLogs": "Limpar directorio de rexistros",
|
||||||
"TaskCleanActivityLog": "Limpar Rexistro de Actividade",
|
"TaskCleanActivityLog": "Limpar rexistro de actividade",
|
||||||
"TasksChannelsCategory": "Canáis de Internet",
|
"TasksChannelsCategory": "Canles da Internet",
|
||||||
"TaskUpdatePlugins": "Actualizar Plugins",
|
"TaskUpdatePlugins": "Actualizar plugins",
|
||||||
"User": "Usuario",
|
"User": "Usuario",
|
||||||
"Undefined": "Sen definir",
|
"Undefined": "Sen definir",
|
||||||
"TvShows": "Programas de TV",
|
"TvShows": "Programas de TV",
|
||||||
"System": "Sistema",
|
"System": "Sistema",
|
||||||
"Sync": "Sincronizar",
|
"Sync": "Sincronizar",
|
||||||
"SubtitleDownloadFailureFromForItem": "Fallou a descarga de subtítulos para {1} dende {0}",
|
"SubtitleDownloadFailureFromForItem": "Fallou a descarga de subtítulos para {1} dende {0}",
|
||||||
"StartupEmbyServerIsLoading": "O Servidor Jellyfin está cargando. Por favor, reinténteo en breve.",
|
"StartupEmbyServerIsLoading": "O servidor Jellyfin está cargando. Por favor, ténteo axiña outra vez.",
|
||||||
"Songs": "Cancións",
|
"Songs": "Cancións",
|
||||||
"Shows": "Programas",
|
"Shows": "Programas",
|
||||||
"ServerNameNeedsToBeRestarted": "{0} precisa ser reiniciado",
|
"ServerNameNeedsToBeRestarted": "{0} precisa ser reiniciado",
|
||||||
@@ -85,56 +85,57 @@
|
|||||||
"UserDeletedWithName": "O usuario {0} foi borrado",
|
"UserDeletedWithName": "O usuario {0} foi borrado",
|
||||||
"UserCreatedWithName": "O usuario {0} foi creado",
|
"UserCreatedWithName": "O usuario {0} foi creado",
|
||||||
"Plugin": "Plugin",
|
"Plugin": "Plugin",
|
||||||
"NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo parada",
|
"NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo detida",
|
||||||
"NotificationOptionVideoPlayback": "Reproducción de vídeo iniciada",
|
"NotificationOptionVideoPlayback": "Reproducción de vídeo iniciada",
|
||||||
"NotificationOptionUserLockedOut": "Usuario bloqueado",
|
"NotificationOptionUserLockedOut": "Usuario bloqueado",
|
||||||
"NotificationOptionTaskFailed": "Falla na tarefa axendada",
|
"NotificationOptionTaskFailed": "Falla na tarefa axendada",
|
||||||
"TaskCleanTranscodeDescription": "Borra os arquivos de transcode anteriores a un día.",
|
"TaskCleanTranscodeDescription": "Borra os ficheiros de transcodificación de hai más dun día.",
|
||||||
"TaskCleanTranscode": "Limpar Directorio de Transcode",
|
"TaskCleanTranscode": "Limpar o directorio de transcodificación",
|
||||||
"UserStoppedPlayingItemWithValues": "{0} rematou de reproducir {1} en {2}",
|
"UserStoppedPlayingItemWithValues": "{0} rematou de reproducir {1} en {2}",
|
||||||
"UserStartedPlayingItemWithValues": "{0} está reproducindo {1} en {2}",
|
"UserStartedPlayingItemWithValues": "{0} está a reproducir {1} en {2}",
|
||||||
"TaskDownloadMissingSubtitlesDescription": "Busca en internet por subtítulos que faltan baseado na configuración de metadatos.",
|
"TaskDownloadMissingSubtitlesDescription": "Procura na internet os subtítulos que faltan segundo a configuración de metadatos.",
|
||||||
"TaskDownloadMissingSubtitles": "Descargar subtítulos que faltan",
|
"TaskDownloadMissingSubtitles": "Descargar subtítulos que faltan",
|
||||||
"TaskRefreshChannelsDescription": "Refresca a información do canle de internet.",
|
"TaskRefreshChannelsDescription": "Refresca a información da canle de internet.",
|
||||||
"TaskRefreshChannels": "Refrescar Canles",
|
"TaskRefreshChannels": "Refrescar canles",
|
||||||
"TaskUpdatePluginsDescription": "Descarga e instala actualizacións para plugins que están configurados para actualizarse automáticamente.",
|
"TaskUpdatePluginsDescription": "Descarga e instala actualizacións dos plugins configurados para actualizarse automáticamente.",
|
||||||
"TaskRefreshPeopleDescription": "Actualiza os metadatos dos actores e directores na túa libraría multimedia.",
|
"TaskRefreshPeopleDescription": "Actualiza os metadatos dos actores e directores na túa biblioteca de medios.",
|
||||||
"TaskRefreshPeople": "Refrescar Persoas",
|
"TaskRefreshPeople": "Refrescar persoas",
|
||||||
"TaskCleanLogsDescription": "Borra arquivos de rexistro que son mais antigos que {0} días.",
|
"TaskCleanLogsDescription": "Borra ficheiros de rexistro con máis de {0} días de antigüidade.",
|
||||||
"TaskRefreshLibraryDescription": "Escanea a tua libraría multimedia buscando novos arquivos e refrescando os metadatos.",
|
"TaskRefreshLibraryDescription": "Escanea a túa biblioteca de medios á procura de novos ficheiros e refresca os metadatos.",
|
||||||
"TaskRefreshLibrary": "Escanear Libraría Multimedia",
|
"TaskRefreshLibrary": "Escanear a biblioteca de medios",
|
||||||
"TaskRefreshChapterImagesDescription": "Crea previsualizacións para videos que teñen capítulos.",
|
"TaskRefreshChapterImagesDescription": "Crea miniaturas dos vídeos que teñen capítulos.",
|
||||||
"TaskRefreshChapterImages": "Extraer Imaxes dos Capítulos",
|
"TaskRefreshChapterImages": "Extraer imaxes dos capítulos",
|
||||||
"TaskCleanCacheDescription": "Borra ficheiros da caché que xa non son necesarios para o sistema.",
|
"TaskCleanCacheDescription": "Borra ficheiros da caché que xa non son necesarios para o sistema.",
|
||||||
"TaskCleanCache": "Limpa Directorio de Caché",
|
"TaskCleanCache": "Limpar directorio de caché",
|
||||||
"TaskCleanActivityLogDescription": "Borra as entradas no rexistro de actividade anteriores á data configurada.",
|
"TaskCleanActivityLogDescription": "Borra do rexistro de actividade as entradas anteriores á data configurada.",
|
||||||
"TasksApplicationCategory": "Aplicación",
|
"TasksApplicationCategory": "Aplicación",
|
||||||
"ValueSpecialEpisodeName": "Especial - {0}",
|
"ValueSpecialEpisodeName": "Especial - {0}",
|
||||||
"ValueHasBeenAddedToLibrary": "{0} foi engadido a túa libraría multimedia",
|
"ValueHasBeenAddedToLibrary": "{0} engadiuse á túa biblioteca de medios",
|
||||||
"TasksLibraryCategory": "Libraría",
|
"TasksLibraryCategory": "Biblioteca",
|
||||||
"TasksMaintenanceCategory": "Mantemento",
|
"TasksMaintenanceCategory": "Mantemento",
|
||||||
"VersionNumber": "Versión {0}",
|
"VersionNumber": "Versión {0}",
|
||||||
"UserPolicyUpdatedWithName": "A política de usuario foi actualizada para {0}",
|
"UserPolicyUpdatedWithName": "A política de usuario foi actualizada para {0}",
|
||||||
"UserPasswordChangedWithName": "Cambiouse o contrasinal para o usuario {0}",
|
"UserPasswordChangedWithName": "Cambiouse o contrasinal para o usuario {0}",
|
||||||
"UserOnlineFromDevice": "{0} está en liña desde {1}",
|
"UserOnlineFromDevice": "{0} está en liña desde {1}",
|
||||||
"UserOfflineFromDevice": "{0} desconectouse desde {1}",
|
"UserOfflineFromDevice": "{0} desconectouse dende {1}",
|
||||||
"TaskOptimizeDatabaseDescription": "Compacta e libera o espazo libre da base de datos. Executar esta tarefa logo de realizar mudanzas que impliquen modificacións da base de datos ou despois de escanear a biblioteca pode traer mellorías de desempeño.",
|
"TaskOptimizeDatabaseDescription": "Compacta e libera espazo na base de datos. Executar esta tarefa logo de facer cambios que muden a base de datos ou despois de escanear a biblioteca pode mellorar o rendemento.",
|
||||||
"TaskOptimizeDatabase": "Optimizar base de datos",
|
"TaskOptimizeDatabase": "Optimizar base de datos",
|
||||||
"TaskKeyframeExtractorDescription": "Extrae fragmentos do vídeo para crear listas de reprodución HLS máis precisas. Podería levarlle bastante tempo.",
|
"TaskKeyframeExtractorDescription": "Extrae fotogramas clave dos vídeos para crear listas de reprodución HLS máis precisas. Podería levar moito tempo.",
|
||||||
"External": "Externo",
|
"External": "Externo",
|
||||||
"HearingImpaired": "Problemas de audición",
|
"HearingImpaired": "Problemas de audición",
|
||||||
"TaskKeyframeExtractor": "Extractor de fragmentos",
|
"TaskKeyframeExtractor": "Extractor de fotogramas clave",
|
||||||
"TaskAudioNormalization": "Normalización do audio",
|
"TaskAudioNormalization": "Normalización de volume",
|
||||||
"TaskRefreshTrickplayImagesDescription": "Crea vistas previas de reprodución con truco para vídeos en bibliotecas activadas.",
|
"TaskRefreshTrickplayImagesDescription": "Crea miniaturas de previsualización para os vídeos nas bibliotecas habilitadas.",
|
||||||
"TaskDownloadMissingLyrics": "Descargar letras que faltan",
|
"TaskDownloadMissingLyrics": "Descargar letras que faltan",
|
||||||
"TaskDownloadMissingLyricsDescription": "Descargas de letras das cancións",
|
"TaskDownloadMissingLyricsDescription": "Descarga as letras das cancións",
|
||||||
"TaskCleanCollectionsAndPlaylists": "Limpar coleccións e listas de reprodución",
|
"TaskCleanCollectionsAndPlaylists": "Limpar coleccións e listas de reprodución",
|
||||||
"TaskCleanCollectionsAndPlaylistsDescription": "Elimina elementos de coleccións e listas de reprodución que xa non existen.",
|
"TaskCleanCollectionsAndPlaylistsDescription": "Quita ítems que xa non existen das coleccións e listas de reprodución.",
|
||||||
"TaskExtractMediaSegmentsDescription": "Extrae ou obtén segmentos multimedia de complementos habilitados para o Segmento de medios.",
|
"TaskExtractMediaSegmentsDescription": "Procura segmentos de medios cos plugins habilitados.",
|
||||||
"TaskExtractMediaSegments": "Escaneo de segmentos multimedia",
|
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
|
||||||
"TaskMoveTrickplayImages": "Migrar a localización da imaxe de Trickplay",
|
"TaskMoveTrickplayImages": "Migrar as miniaturas de previsualización a outra ubicación",
|
||||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros de reprodución con trickplay existentes segundo a configuración da biblioteca.",
|
"TaskMoveTrickplayImagesDescription": "Move as miniaturas de previsualización segundo a configuración da biblioteca.",
|
||||||
"TaskRefreshTrickplayImages": "Xerar imaxes de Trickplay",
|
"TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
|
||||||
"TaskAudioNormalizationDescription": "Analiza ficheiros para obter datos de normalización de audio.",
|
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
|
||||||
"CleanupUserDataTask": "Tarefa de limpeza de datos do usuario"
|
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
|
||||||
|
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
"DeviceOfflineWithName": "{0} wurde getrennt",
|
"DeviceOfflineWithName": "{0} wurde getrennt",
|
||||||
"DeviceOnlineWithName": "{0} ist verbunden",
|
"DeviceOnlineWithName": "{0} ist verbunden",
|
||||||
"FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}",
|
"FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}",
|
||||||
"Favorites": "Favoriten",
|
"Favorites": "Favorite",
|
||||||
"Folders": "Ordner",
|
"Folders": "Ordner",
|
||||||
"Genres": "Genre",
|
"Genres": "Genre",
|
||||||
"HeaderAlbumArtists": "Album-Künstler",
|
"HeaderAlbumArtists": "Album-Künschtler",
|
||||||
"HeaderContinueWatching": "weiter schauen",
|
"HeaderContinueWatching": "weiter schauen",
|
||||||
"HeaderFavoriteAlbums": "Lieblingsalben",
|
"HeaderFavoriteAlbums": "Lieblingsalben",
|
||||||
"HeaderFavoriteArtists": "Lieblings-Künstler",
|
"HeaderFavoriteArtists": "Lieblings-Künstler",
|
||||||
|
|||||||
@@ -125,8 +125,8 @@
|
|||||||
"TaskKeyframeExtractor": "Izvoditelj ključnog okvira",
|
"TaskKeyframeExtractor": "Izvoditelj ključnog okvira",
|
||||||
"TaskOptimizeDatabaseDescription": "Sažima bazu podataka i uklanja prazan prostor. Pokretanje ovog zadatka, može poboljšati performanse nakon provođenja indeksiranja biblioteke ili provođenja drugih promjena koje utječu na bazu podataka.",
|
"TaskOptimizeDatabaseDescription": "Sažima bazu podataka i uklanja prazan prostor. Pokretanje ovog zadatka, može poboljšati performanse nakon provođenja indeksiranja biblioteke ili provođenja drugih promjena koje utječu na bazu podataka.",
|
||||||
"HearingImpaired": "Oštećen sluh",
|
"HearingImpaired": "Oštećen sluh",
|
||||||
"TaskRefreshTrickplayImages": "Generiraj Trickplay Slike",
|
"TaskRefreshTrickplayImages": "Generiraj slike brzog pregledavanja",
|
||||||
"TaskRefreshTrickplayImagesDescription": "Kreira trickplay pretpreglede za videe u omogućenim knjižnicama.",
|
"TaskRefreshTrickplayImagesDescription": "Stvara preglede brzog pregledavanja za videa u aktiviranim bibliotekama.",
|
||||||
"TaskAudioNormalization": "Normalizacija zvuka",
|
"TaskAudioNormalization": "Normalizacija zvuka",
|
||||||
"TaskAudioNormalizationDescription": "Skenira datoteke u potrazi za podacima o normalizaciji zvuka.",
|
"TaskAudioNormalizationDescription": "Skenira datoteke u potrazi za podacima o normalizaciji zvuka.",
|
||||||
"TaskCleanCollectionsAndPlaylistsDescription": "Uklanja stavke iz zbirki i popisa za reprodukciju koje više ne postoje.",
|
"TaskCleanCollectionsAndPlaylistsDescription": "Uklanja stavke iz zbirki i popisa za reprodukciju koje više ne postoje.",
|
||||||
@@ -135,6 +135,8 @@
|
|||||||
"TaskDownloadMissingLyrics": "Preuzmi tekstove koji nedostaju",
|
"TaskDownloadMissingLyrics": "Preuzmi tekstove koji nedostaju",
|
||||||
"TaskDownloadMissingLyricsDescription": "Preuzmi tekstove pjesama",
|
"TaskDownloadMissingLyricsDescription": "Preuzmi tekstove pjesama",
|
||||||
"TaskExtractMediaSegmentsDescription": "Izvlači ili pribavlja dijelove medija iz omogućenih media pluginova.",
|
"TaskExtractMediaSegmentsDescription": "Izvlači ili pribavlja dijelove medija iz omogućenih media pluginova.",
|
||||||
"TaskMoveTrickplayImages": "Preseli lokaciju Trickplay slika",
|
"TaskMoveTrickplayImages": "Premjesti mjesto slika brzog pregledavanja",
|
||||||
"TaskMoveTrickplayImagesDescription": "Preseli lokaciju Trickplay slika prema postavkama zbirke."
|
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja prema postavkama biblioteke.",
|
||||||
|
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
|
||||||
|
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,62 @@
|
|||||||
{
|
{
|
||||||
"Books": "liv"
|
"Books": "Liv",
|
||||||
|
"TasksLibraryCategory": "Libreri",
|
||||||
|
"Albums": "Albòm yo",
|
||||||
|
"Artists": "Atis yo",
|
||||||
|
"Application": "Aplikasyon",
|
||||||
|
"Channels": "Kanal yo",
|
||||||
|
"ChapterNameValue": "Chapit {0}",
|
||||||
|
"Default": "Defo",
|
||||||
|
"DeviceOnlineWithName": "{0} konekte",
|
||||||
|
"DeviceOfflineWithName": "{0} dekonekte",
|
||||||
|
"External": "Extèn",
|
||||||
|
"Collections": "Koleksyon yo",
|
||||||
|
"Favorites": "Pi Renmen",
|
||||||
|
"Folders": "Dosye",
|
||||||
|
"Genres": "Jan yo",
|
||||||
|
"Forced": "Fòse",
|
||||||
|
"HeaderAlbumArtists": "Albòm Atis",
|
||||||
|
"HeaderContinueWatching": "Kontinye Kade",
|
||||||
|
"HeaderFavoriteAlbums": "Albòm Pi Renmen",
|
||||||
|
"HeaderFavoriteArtists": "Atis Pi Renmen",
|
||||||
|
"HeaderFavoriteEpisodes": "Epizòd Pi Renmen",
|
||||||
|
"HeaderFavoriteShows": "Emisyon Pi Renmen",
|
||||||
|
"HeaderFavoriteSongs": "Mizik Pi Renmen",
|
||||||
|
"HeaderLiveTV": "Televizyon an Direk",
|
||||||
|
"HeaderNextUp": "Pwochen an",
|
||||||
|
"HomeVideos": "Videyo Lakay",
|
||||||
|
"Latest": "Pi Resan",
|
||||||
|
"MessageApplicationUpdated": "Sèvè Jellyfin met a jou",
|
||||||
|
"MessageApplicationUpdatedTo": "Sèvè Jellyfin met a jou sou {0}",
|
||||||
|
"Movies": "Fim",
|
||||||
|
"MixedContent": "Kontni Melanje",
|
||||||
|
"Music": "Mizik",
|
||||||
|
"MusicVideos": "Videyo Mizik",
|
||||||
|
"NameInstallFailed": "{0} enstalasyon fe fayit",
|
||||||
|
"NameSeasonNumber": "Sezon {0}",
|
||||||
|
"NameSeasonUnknown": "Sezon Enkoni",
|
||||||
|
"NotificationOptionCameraImageUploaded": "Imaj Kamera telechaje",
|
||||||
|
"NotificationOptionInstallationFailed": "Enstalasyon echwe",
|
||||||
|
"Photos": "Foto",
|
||||||
|
"PluginInstalledWithName": "{0} te enstale",
|
||||||
|
"PluginUninstalledWithName": "{0} te dezenstale",
|
||||||
|
"PluginUpdatedWithName": "{0} te mi a jou",
|
||||||
|
"ScheduledTaskFailedWithName": "{0} echwe",
|
||||||
|
"ScheduledTaskStartedWithName": "{0} komanse",
|
||||||
|
"Songs": "Mizik yo",
|
||||||
|
"Shows": "Emisyon yo",
|
||||||
|
"System": "Sistèm",
|
||||||
|
"TvShows": "Emisyon Tele",
|
||||||
|
"User": "Itilizatè",
|
||||||
|
"UserCreatedWithName": "Itilizatè {0} kreye",
|
||||||
|
"UserDeletedWithName": "Itilizatè {0} a efase",
|
||||||
|
"UserDownloadingItemWithValues": "{0} ap telechaje {1}",
|
||||||
|
"UserOfflineFromDevice": "{0} dekonekte de {1}",
|
||||||
|
"UserStartedPlayingItemWithValues": "{0} ap jwe {1} sou {2}",
|
||||||
|
"UserStoppedPlayingItemWithValues": "{0} fin jwe {1} sou {2}",
|
||||||
|
"UserPasswordChangedWithName": "Modpas la chanje pou Itilizatè {0}",
|
||||||
|
"ValueSpecialEpisodeName": "Spesyal - {0}",
|
||||||
|
"VersionNumber": "Vesyon {0}",
|
||||||
|
"TasksApplicationCategory": "Aplikasyon",
|
||||||
|
"TasksMaintenanceCategory": "Antretyen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"Books": "Номууд",
|
"Books": "Номнууд",
|
||||||
"HeaderNextUp": "Дараа нь",
|
"HeaderNextUp": "Дараа нь",
|
||||||
"HeaderContinueWatching": "Үргэлжлүүлэн үзэх",
|
"HeaderContinueWatching": "Үргэлжлүүлэн үзэх",
|
||||||
"Songs": "Дуунууд",
|
"Songs": "Дуунууд",
|
||||||
"Playlists": "Тоглуулах жагсаалт",
|
"Playlists": "Playlist-ууд",
|
||||||
"Movies": "Кино",
|
"Movies": "Кинонууд",
|
||||||
"Latest": "Сүүлийн үеийн",
|
"Latest": "Сүүлийн үеийн",
|
||||||
"Genres": "Төрлүүд",
|
"Genres": "Төрлүүд",
|
||||||
"Favorites": "Дуртай",
|
"Favorites": "Дуртай",
|
||||||
"Collections": "Багц",
|
"Collections": "Цуглуулгууд",
|
||||||
"Artists": "Уран бүтээлчид",
|
"Artists": "Уран бүтээлчид",
|
||||||
"Albums": "Цомгууд",
|
"Albums": "Дуут цомгууд",
|
||||||
"TaskExtractMediaSegments": "Медиа сегмент шалга",
|
"TaskExtractMediaSegments": "Медиа сегмент шалга",
|
||||||
"TaskExtractMediaSegmentsDescription": "MediaSegment идэвхжүүлсэн залгаасуудаас медиа сегментүүдийг задлах эсвэл олж авах.",
|
"TaskExtractMediaSegmentsDescription": "MediaSegment идэвхжүүлсэн залгаасуудаас медиа сегментүүдийг задлах эсвэл олж авах.",
|
||||||
"TaskMoveTrickplayImages": "Трикплэй зургуудын байршлыг шилжүүлэх",
|
"TaskMoveTrickplayImages": "Трикплэй зургуудын байршлыг шилжүүлэх",
|
||||||
@@ -63,11 +63,11 @@
|
|||||||
"CameraImageUploadedFrom": "{0}-с шинэ зураг байршуулагдлаа",
|
"CameraImageUploadedFrom": "{0}-с шинэ зураг байршуулагдлаа",
|
||||||
"Channels": "Сувгууд",
|
"Channels": "Сувгууд",
|
||||||
"ChapterNameValue": "{0}-р бүлэг",
|
"ChapterNameValue": "{0}-р бүлэг",
|
||||||
"Default": "Өгөгдмөл",
|
"Default": "Анхдагч",
|
||||||
"DeviceOfflineWithName": "{0}-н холболт саллаа",
|
"DeviceOfflineWithName": "{0}-н холболт саллаа",
|
||||||
"DeviceOnlineWithName": "{0} холбогдлоо",
|
"DeviceOnlineWithName": "{0} холбогдлоо",
|
||||||
"FailedLoginAttemptWithUserName": "{0}-н нэвтрэх оролдлого амжилтгүй",
|
"FailedLoginAttemptWithUserName": "{0}-н нэвтрэх оролдлого амжилтгүй",
|
||||||
"Folders": "Хавтаснууд",
|
"Folders": "Хавтасууд",
|
||||||
"Forced": "Хүчээр",
|
"Forced": "Хүчээр",
|
||||||
"HeaderAlbumArtists": "Цомгийн уран бүтээлчид",
|
"HeaderAlbumArtists": "Цомгийн уран бүтээлчид",
|
||||||
"HeaderFavoriteAlbums": "Дуртай цомгууд",
|
"HeaderFavoriteAlbums": "Дуртай цомгууд",
|
||||||
@@ -84,8 +84,8 @@
|
|||||||
"MessageApplicationUpdatedTo": "Jellyfin Server {0} болж шинэчлэгдлээ",
|
"MessageApplicationUpdatedTo": "Jellyfin Server {0} болж шинэчлэгдлээ",
|
||||||
"MessageServerConfigurationUpdated": "Server-н тохиргоо шинэчлэгдлээ",
|
"MessageServerConfigurationUpdated": "Server-н тохиргоо шинэчлэгдлээ",
|
||||||
"MixedContent": "Холимог агуулга",
|
"MixedContent": "Холимог агуулга",
|
||||||
"Music": "Дуу",
|
"Music": "Хөгжим",
|
||||||
"MusicVideos": "Дууны клип",
|
"MusicVideos": "Дууны клипүүд",
|
||||||
"NameInstallFailed": "{0} суулгахад алдаа гарлаа",
|
"NameInstallFailed": "{0} суулгахад алдаа гарлаа",
|
||||||
"NameSeasonNumber": "{0}-р улирал",
|
"NameSeasonNumber": "{0}-р улирал",
|
||||||
"NameSeasonUnknown": "Улирал олдсонгүй",
|
"NameSeasonUnknown": "Улирал олдсонгүй",
|
||||||
@@ -101,14 +101,14 @@
|
|||||||
"NotificationOptionUserLockedOut": "Хэрэглэгчийг түгжив",
|
"NotificationOptionUserLockedOut": "Хэрэглэгчийг түгжив",
|
||||||
"NotificationOptionVideoPlayback": "Бичлэгийг тоглуулж эхлэв",
|
"NotificationOptionVideoPlayback": "Бичлэгийг тоглуулж эхлэв",
|
||||||
"Photos": "Зургууд",
|
"Photos": "Зургууд",
|
||||||
"Plugin": "Plugin",
|
"Plugin": "Плагин",
|
||||||
"PluginInstalledWithName": "{0}-г суулгалаа",
|
"PluginInstalledWithName": "{0}-г суулгалаа",
|
||||||
"PluginUninstalledWithName": "{0}-г устгалаа",
|
"PluginUninstalledWithName": "{0}-г устгалаа",
|
||||||
"PluginUpdatedWithName": "{0}-г шинэчиллээ",
|
"PluginUpdatedWithName": "{0}-г шинэчиллээ",
|
||||||
"ProviderValue": "Нийлүүлэгч: {0}",
|
"ProviderValue": "Нийлүүлэгч: {0}",
|
||||||
"ScheduledTaskStartedWithName": "{0}-г эхлүүлэв",
|
"ScheduledTaskStartedWithName": "{0}-г эхлүүлэв",
|
||||||
"ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу",
|
"ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу",
|
||||||
"Shows": "Нэвтрүүлгүүд",
|
"Shows": "Шоу",
|
||||||
"Sync": "Дахин",
|
"Sync": "Дахин",
|
||||||
"System": "Систем",
|
"System": "Систем",
|
||||||
"TvShows": "ТВ нэвтрүүлгүүд",
|
"TvShows": "ТВ нэвтрүүлгүүд",
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
"UserPolicyUpdatedWithName": "Хэрэглэгчийн журмыг {0}-д зориулан шинэчиллээ",
|
"UserPolicyUpdatedWithName": "Хэрэглэгчийн журмыг {0}-д зориулан шинэчиллээ",
|
||||||
"UserStartedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж байна",
|
"UserStartedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж байна",
|
||||||
"UserStoppedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж дуусгалаа",
|
"UserStoppedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж дуусгалаа",
|
||||||
"ValueSpecialEpisodeName": "Тусгай - {0}",
|
"ValueSpecialEpisodeName": "Онцгой - {0}",
|
||||||
"VersionNumber": "Хувилбар {0}",
|
"VersionNumber": "Хувилбар {0}",
|
||||||
"TasksMaintenanceCategory": "Засвар",
|
"TasksMaintenanceCategory": "Засвар",
|
||||||
"TasksLibraryCategory": "Сан",
|
"TasksLibraryCategory": "Сан",
|
||||||
|
|||||||
@@ -136,5 +136,7 @@
|
|||||||
"TaskExtractMediaSegments": "Skann mediasegment",
|
"TaskExtractMediaSegments": "Skann mediasegment",
|
||||||
"TaskMoveTrickplayImages": "Migrer bildeplassering for Trickplay",
|
"TaskMoveTrickplayImages": "Migrer bildeplassering for Trickplay",
|
||||||
"TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.",
|
"TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.",
|
||||||
"TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment."
|
"TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.",
|
||||||
|
"CleanupUserDataTaskDescription": "Sletter all brukerdata (avspillings-status, favoritter osv.) fra innhold som har vært utilgjengelig i minst 90 dager.",
|
||||||
|
"CleanupUserDataTask": "Oppgave for opprydding av brukerdata"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,5 +41,77 @@
|
|||||||
"MixedContent": "Jumbled loot",
|
"MixedContent": "Jumbled loot",
|
||||||
"Music": "Tunes",
|
"Music": "Tunes",
|
||||||
"NameInstallFailed": "Ye couldn't bring {0} aboard yer ship",
|
"NameInstallFailed": "Ye couldn't bring {0} aboard yer ship",
|
||||||
"MessageApplicationUpdatedTo": "Yer Map of the Seas has been scribbled with {0}"
|
"MessageApplicationUpdatedTo": "Yer Map of the Seas has been scribbled with {0}",
|
||||||
|
"MessageNamedServerConfigurationUpdatedWithValue": "Yer Map Drawer has been rescribbled to {0}",
|
||||||
|
"MessageServerConfigurationUpdated": "Yer Map drawer has been rescribbled",
|
||||||
|
"Inherit": "Carry on what be passed along",
|
||||||
|
"Latest": "Newfangled",
|
||||||
|
"Movies": "Moving pictures",
|
||||||
|
"NewVersionIsAvailable": "A fresh build o’ Jellyfin Server be waitin’ fer ye to fetch.",
|
||||||
|
"NotificationOptionPluginInstalled": "Plugin nailed down",
|
||||||
|
"NotificationOptionVideoPlayback": "Video playback be underway",
|
||||||
|
"ScheduledTaskFailedWithName": "{0} ran aground",
|
||||||
|
"StartupEmbyServerIsLoading": "Jellyfin Server be preparin’ the ship. Try yer luck again soon.",
|
||||||
|
"UserOfflineFromDevice": "{0} severed ties with {1}",
|
||||||
|
"UserDownloadingItemWithValues": "{0} be haulin’ in {1}",
|
||||||
|
"UserStartedPlayingItemWithValues": "{0} be playin’ {1} aboard {2}",
|
||||||
|
"ValueHasBeenAddedToLibrary": "{0} be stashed in yer treasure trove",
|
||||||
|
"TaskCleanCacheDescription": "Wipes away cache cargo no longer called fer.",
|
||||||
|
"TaskCleanLogsDescription": "Clears the logbook o’ entries older than {0} days.",
|
||||||
|
"TaskRefreshPeopleDescription": "Refreshes the charts fer actors an’ directors in yer Treasure Trove.",
|
||||||
|
"UserLockedOutWithName": "Matey {0} be denied boarding",
|
||||||
|
"TaskAudioNormalization": "Steadyin’ the shanties",
|
||||||
|
"TaskAudioNormalizationDescription": "Scans files fer shanty steadiyin’ data.",
|
||||||
|
"HeaderRecordingGroups": "Loggin' Groups",
|
||||||
|
"MusicVideos": "Shanty films",
|
||||||
|
"Playlists": "Lists o’ plunder",
|
||||||
|
"Plugin": "Extra sail",
|
||||||
|
"NotificationOptionVideoPlaybackStopped": "Video playback dropped anchor",
|
||||||
|
"NameSeasonNumber": "Saga {0}",
|
||||||
|
"NameSeasonUnknown": "Saga be Lost",
|
||||||
|
"NotificationOptionApplicationUpdateAvailable": "A fresh build awaits",
|
||||||
|
"NotificationOptionApplicationUpdateInstalled": "App upgrade be aboard",
|
||||||
|
"NotificationOptionAudioPlayback": "Audio playback be rollin",
|
||||||
|
"NotificationOptionAudioPlaybackStopped": "Audio playback dropped anchor",
|
||||||
|
"NotificationOptionCameraImageUploaded": "Spyglass shot be hoisted",
|
||||||
|
"NotificationOptionInstallationFailed": "Install be wrecked",
|
||||||
|
"NotificationOptionNewLibraryContent": "Fresh plunder ready to claim",
|
||||||
|
"NotificationOptionPluginError": "Plugin ran aground",
|
||||||
|
"NotificationOptionPluginUninstalled": "Plugin cast overboard",
|
||||||
|
"NotificationOptionPluginUpdateInstalled": "Plugin patched ‘n ready",
|
||||||
|
"NotificationOptionServerRestartRequired": "Server be due fer a restart",
|
||||||
|
"NotificationOptionTaskFailed": "Set chore went overboard",
|
||||||
|
"TaskRefreshLibraryDescription": "Searches the Treasure Trove fer new plunder ‘n updates the charts.",
|
||||||
|
"PluginInstalledWithName": "{0} nailed down",
|
||||||
|
"TaskCleanLogs": "Swab the Log Hold",
|
||||||
|
"TaskRefreshPeople": "Freshen the Mateys",
|
||||||
|
"PluginUninstalledWithName": "{0} sent t’ Davy Jones",
|
||||||
|
"PluginUpdatedWithName": "{0} patched ‘n ready",
|
||||||
|
"ProviderValue": "Supplier o’ goods: {0}",
|
||||||
|
"ScheduledTaskStartedWithName": "{0} set sail",
|
||||||
|
"ServerNameNeedsToBeRestarted": "{0} be cravin’ a restart",
|
||||||
|
"Shows": "Sagas",
|
||||||
|
"SubtitleDownloadFailureFromForItem": "Subtitles be sunk fetchin’ from {0} fer {1}",
|
||||||
|
"Sync": "Match the tides",
|
||||||
|
"System": "The ship’s works",
|
||||||
|
"TvShows": "TV Sagas",
|
||||||
|
"Undefined": "Uncharted",
|
||||||
|
"User": "Matey",
|
||||||
|
"UserCreatedWithName": "Matey {0} joined the crew",
|
||||||
|
"UserDeletedWithName": "Matey {0} cast overboard",
|
||||||
|
"UserOnlineFromDevice": "{0} be aboard ship from {1}",
|
||||||
|
"UserPasswordChangedWithName": "New passphrase set fer Matey {0}",
|
||||||
|
"UserPolicyUpdatedWithName": "Ship rules be changed fer {0}",
|
||||||
|
"UserStoppedPlayingItemWithValues": "{0} be done playin’ {1} on {2",
|
||||||
|
"ValueSpecialEpisodeName": "Special Tale – {0}",
|
||||||
|
"VersionNumber": "Edition {0}",
|
||||||
|
"TasksMaintenanceCategory": "Hull patchin’",
|
||||||
|
"TasksLibraryCategory": "Treasure Trove",
|
||||||
|
"TasksApplicationCategory": "Ship",
|
||||||
|
"TaskCleanActivityLog": "Clear the Ship’s Log",
|
||||||
|
"TaskCleanActivityLogDescription": "Purges ship’s logs older than the chosen time.",
|
||||||
|
"TaskCleanCache": "Sweep the Cache Chest",
|
||||||
|
"TaskRefreshChapterImages": "Claim chapter portraits",
|
||||||
|
"TaskRefreshChapterImagesDescription": "Paints wee portraits fer videos that own chapters.",
|
||||||
|
"TaskRefreshLibrary": "Scan the Treasure Trove"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
"ScheduledTaskFailedWithName": "{0} - неудачна",
|
"ScheduledTaskFailedWithName": "{0} - неудачна",
|
||||||
"ScheduledTaskStartedWithName": "{0} - запущена",
|
"ScheduledTaskStartedWithName": "{0} - запущена",
|
||||||
"ServerNameNeedsToBeRestarted": "Необходим перезапуск {0}",
|
"ServerNameNeedsToBeRestarted": "Необходим перезапуск {0}",
|
||||||
"Shows": "Телешоу",
|
"Shows": "Сериалы",
|
||||||
"Songs": "Композиции",
|
"Songs": "Композиции",
|
||||||
"StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.",
|
"StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.",
|
||||||
"SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить",
|
"SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить",
|
||||||
|
|||||||
@@ -135,5 +135,7 @@
|
|||||||
"TaskCleanCollectionsAndPlaylists": "Pastron koleksionet dhe listat e këngëve",
|
"TaskCleanCollectionsAndPlaylists": "Pastron koleksionet dhe listat e këngëve",
|
||||||
"TaskCleanCollectionsAndPlaylistsDescription": "Heq elementet nga koleksionet dhe listat e këngëve që nuk ekzistojnë më.",
|
"TaskCleanCollectionsAndPlaylistsDescription": "Heq elementet nga koleksionet dhe listat e këngëve që nuk ekzistojnë më.",
|
||||||
"TaskAudioNormalization": "Normalizimi i audios",
|
"TaskAudioNormalization": "Normalizimi i audios",
|
||||||
"TaskAudioNormalizationDescription": "Skannon skedarët për të dhëna të normalizimit të audios."
|
"TaskAudioNormalizationDescription": "Skannon skedarët për të dhëna të normalizimit të audios.",
|
||||||
|
"CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.",
|
||||||
|
"CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,5 +126,16 @@
|
|||||||
"HearingImpaired": "ослабљен слух",
|
"HearingImpaired": "ослабљен слух",
|
||||||
"TaskAudioNormalization": "Нормализација звука",
|
"TaskAudioNormalization": "Нормализација звука",
|
||||||
"TaskCleanCollectionsAndPlaylists": "Очистите колекције и плејлисте",
|
"TaskCleanCollectionsAndPlaylists": "Очистите колекције и плејлисте",
|
||||||
"TaskAudioNormalizationDescription": "Скенира датотеке за податке о нормализацији звука."
|
"TaskAudioNormalizationDescription": "Скенира датотеке за податке о нормализацији звука.",
|
||||||
|
"TaskRefreshTrickplayImages": "Направи сличице за визуелно премотавање",
|
||||||
|
"TaskRefreshTrickplayImagesDescription": "Прављење сличица које помажу код визуелног премотавања видео-снимака.",
|
||||||
|
"TaskDownloadMissingLyrics": "Преузми стихове који недостају",
|
||||||
|
"TaskCleanCollectionsAndPlaylistsDescription": "Уклања ставке које више не постоје из колекција и плејлиста.",
|
||||||
|
"TaskExtractMediaSegments": "Скенирај сегменте медија",
|
||||||
|
"TaskExtractMediaSegmentsDescription": "Извлачи или добавља сегменте медија у додацима који раде са MediaSegment-ом.",
|
||||||
|
"TaskMoveTrickplayImagesDescription": "Премешта постојеће сличице за визуелно премотавање сходно подешавањима библиотеке.",
|
||||||
|
"CleanupUserDataTask": "Задатак чишћења корисничких података",
|
||||||
|
"CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
|
||||||
|
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
|
||||||
|
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,5 +59,6 @@
|
|||||||
"NotificationOptionAudioPlayback": "ఆడియో ప్లే కావడం మొదలైంది",
|
"NotificationOptionAudioPlayback": "ఆడియో ప్లే కావడం మొదలైంది",
|
||||||
"NotificationOptionCameraImageUploaded": "కెమెరా చిత్రాన్ని అప్లోడ్ చేశారు",
|
"NotificationOptionCameraImageUploaded": "కెమెరా చిత్రాన్ని అప్లోడ్ చేశారు",
|
||||||
"NotificationOptionInstallationFailed": "ఇన్స్టాలేషన్ విఫలమైంది",
|
"NotificationOptionInstallationFailed": "ఇన్స్టాలేషన్ విఫలమైంది",
|
||||||
"NotificationOptionServerRestartRequired": "సర్వర్ రీస్టార్ట్ అవసరం"
|
"NotificationOptionServerRestartRequired": "సర్వర్ రీస్టార్ట్ అవసరం",
|
||||||
|
"Inherit": "సంక్రమించు"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
{
|
{
|
||||||
"Books": "کتابیں"
|
"Books": "کتابیں",
|
||||||
|
"AppDeviceValues": "ایپ: {0}، ڈیوائس: {1}",
|
||||||
|
"Albums": "البمز",
|
||||||
|
"Application": "ایپلی کیشن",
|
||||||
|
"Artists": "فنکار",
|
||||||
|
"AuthenticationSucceededWithUserName": "{0} کی کامیابی سے تصدیق ہو چکی ہے",
|
||||||
|
"CameraImageUploadedFrom": "ایک نئی کیمرے کی تصویر {0} سے اپ لوڈ کی گئی ہے",
|
||||||
|
"Channels": "چینلز",
|
||||||
|
"ChapterNameValue": "باب {0}",
|
||||||
|
"Collections": "مجموعے",
|
||||||
|
"Default": "ڈیفالٹ",
|
||||||
|
"DeviceOfflineWithName": "{0} نے رابطہ منقطع کر دیا ہے",
|
||||||
|
"DeviceOnlineWithName": "{0} منسلک ہے",
|
||||||
|
"External": "بیرونی"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,5 +123,9 @@
|
|||||||
"TaskCleanActivityLogDescription": "تشکیل شدہ عمر سے زیادہ پرانی سرگرمی لاگ اندراجات کو حذف کرتا ہے۔",
|
"TaskCleanActivityLogDescription": "تشکیل شدہ عمر سے زیادہ پرانی سرگرمی لاگ اندراجات کو حذف کرتا ہے۔",
|
||||||
"External": "بیرونی",
|
"External": "بیرونی",
|
||||||
"HearingImpaired": "قوت سماعت سے محروم",
|
"HearingImpaired": "قوت سماعت سے محروم",
|
||||||
"TaskCleanActivityLog": "سرگرمی لاگ کو صاف کریں"
|
"TaskCleanActivityLog": "سرگرمی لاگ کو صاف کریں",
|
||||||
|
"TaskDownloadMissingLyrics": "غائب بول ڈاؤن لوڈ کریں",
|
||||||
|
"TaskDownloadMissingLyricsDescription": "گانے کے غائب بول ڈاؤن لوڈ کریں",
|
||||||
|
"TaskAudioNormalization": "آڈیو نارملائزیشن",
|
||||||
|
"TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
|
|
||||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private List<CultureDto> _cultures = [];
|
private List<CultureDto> _cultures = [];
|
||||||
|
|
||||||
private FrozenDictionary<string, string> _iso6392BtoT = null!;
|
private FrozenDictionary<string, string> _iso6392BtoT = null!;
|
||||||
@@ -161,6 +162,7 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
|
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_cultureCache.Clear();
|
||||||
_cultures = list;
|
_cultures = list;
|
||||||
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
@@ -169,20 +171,31 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public CultureDto? FindLanguageInfo(string language)
|
public CultureDto? FindLanguageInfo(string language)
|
||||||
{
|
{
|
||||||
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
if (string.IsNullOrEmpty(language))
|
||||||
for (var i = 0; i < _cultures.Count; i++)
|
|
||||||
{
|
{
|
||||||
var culture = _cultures[i];
|
return null;
|
||||||
if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return culture;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return default;
|
return _cultureCache.GetOrAdd(
|
||||||
|
language,
|
||||||
|
static (lang, cultures) =>
|
||||||
|
{
|
||||||
|
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
||||||
|
for (var i = 0; i < cultures.Count; i++)
|
||||||
|
{
|
||||||
|
var culture = cultures[i];
|
||||||
|
if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return culture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
_cultures);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -311,15 +324,19 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fall back to server default language for ratings check
|
// Fall back to server default language for ratings check
|
||||||
// If it has no ratings, use the US ratings
|
var ratingsDictionary = GetParentalRatingsDictionary();
|
||||||
var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us");
|
|
||||||
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
|
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
|
||||||
{
|
{
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't find anything, check all ratings systems
|
// If we don't find anything, check all ratings systems, starting with US
|
||||||
|
if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue))
|
||||||
|
{
|
||||||
|
return usValue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var dictionary in _allParentalRatings.Values)
|
foreach (var dictionary in _allParentalRatings.Values)
|
||||||
{
|
{
|
||||||
if (dictionary.TryGetValue(rating, out var value))
|
if (dictionary.TryGetValue(rating, out var value))
|
||||||
|
|||||||
@@ -347,8 +347,8 @@ pli||pi|Pali|pali
|
|||||||
pol||pl|Polish|polonais
|
pol||pl|Polish|polonais
|
||||||
pon|||Pohnpeian|pohnpei
|
pon|||Pohnpeian|pohnpei
|
||||||
por||pt|Portuguese|portugais
|
por||pt|Portuguese|portugais
|
||||||
por||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
|
pop||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
|
||||||
por||pt-br|Portuguese (Brazil)|portugais (pt-br)
|
pob||pt-br|Portuguese (Brazil)|portugais (pt-br)
|
||||||
pra|||Prakrit languages|prâkrit, langues
|
pra|||Prakrit languages|prâkrit, langues
|
||||||
pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500)
|
pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500)
|
||||||
pus||ps|Pushto; Pashto|pachto
|
pus||ps|Pushto; Pashto|pachto
|
||||||
@@ -402,8 +402,8 @@ sog|||Sogdian|sogdien
|
|||||||
som||so|Somali|somali
|
som||so|Somali|somali
|
||||||
son|||Songhai languages|songhai, langues
|
son|||Songhai languages|songhai, langues
|
||||||
sot||st|Sotho, Southern|sotho du Sud
|
sot||st|Sotho, Southern|sotho du Sud
|
||||||
spa||es-419|Spanish; Latin|espagnol; Latin
|
|
||||||
spa||es|Spanish; Castilian|espagnol; castillan
|
spa||es|Spanish; Castilian|espagnol; castillan
|
||||||
|
spa||es-419|Spanish; Latin|espagnol; Latin
|
||||||
sqi|alb|sq|Albanian|albanais
|
sqi|alb|sq|Albanian|albanais
|
||||||
srd||sc|Sardinian|sarde
|
srd||sc|Sardinian|sarde
|
||||||
srn|||Sranan Tongo|sranan tongo
|
srn|||Sranan Tongo|sranan tongo
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.MediaEncoding;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Redis-backed implementation of <see cref="ITranscodeSessionStore"/> that provides
|
||||||
|
/// durable, distributed session tracking with lease-based ownership between pods.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RedisTranscodeSessionStore : ITranscodeSessionStore
|
||||||
|
{
|
||||||
|
private const string KeyPrefix = "jellyfin:transcode:";
|
||||||
|
private const string LiveStreamKeyPrefix = "jellyfin:livestream:";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lua script for atomic takeover: reads the stored session, checks whether the lease has
|
||||||
|
/// expired (comparing <c>LeaseExpiresUtc.Ticks</c> against the caller-supplied current ticks),
|
||||||
|
/// and if expired, updates the owner and expiry before returning 1; returns 0 otherwise.
|
||||||
|
/// </summary>
|
||||||
|
private const string TakeoverScript = @"
|
||||||
|
local raw = redis.call('GET', KEYS[1])
|
||||||
|
if not raw then return 0 end
|
||||||
|
local session = cjson.decode(raw)
|
||||||
|
local currentTicks = tonumber(ARGV[1])
|
||||||
|
if session['LeaseExpiresUtc'] > currentTicks then return 0 end
|
||||||
|
session['OwnerPod'] = ARGV[2]
|
||||||
|
local leaseDurationMs = tonumber(ARGV[3])
|
||||||
|
local newTicks = currentTicks + (leaseDurationMs * 10000)
|
||||||
|
session['LeaseExpiresUtc'] = newTicks
|
||||||
|
redis.call('SET', KEYS[1], cjson.encode(session), 'PX', leaseDurationMs)
|
||||||
|
return 1";
|
||||||
|
|
||||||
|
private readonly IConnectionMultiplexer _redis;
|
||||||
|
private readonly IDatabase _db;
|
||||||
|
private readonly TranscodeStoreOptions _options;
|
||||||
|
private readonly ILogger<RedisTranscodeSessionStore> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RedisTranscodeSessionStore"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||||
|
/// <param name="options">The transcode store configuration options.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public RedisTranscodeSessionStore(
|
||||||
|
IConnectionMultiplexer redis,
|
||||||
|
IOptions<TranscodeStoreOptions> options,
|
||||||
|
ILogger<RedisTranscodeSessionStore> logger)
|
||||||
|
{
|
||||||
|
_redis = redis;
|
||||||
|
_db = redis.GetDatabase();
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var key = GetKey(session.PlaySessionId);
|
||||||
|
var json = JsonSerializer.Serialize(session);
|
||||||
|
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
|
||||||
|
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
|
||||||
|
_logger.LogDebug("Set transcode session {PlaySessionId} in Redis.", session.PlaySessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var key = GetKey(playSessionId);
|
||||||
|
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
|
||||||
|
if (!raw.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
|
||||||
|
|
||||||
|
// Check LeaseExpiresUtc in addition to Redis TTL to guard against the window between
|
||||||
|
// Redis TTL evaluation and the GET result being returned to the caller.
|
||||||
|
if (session is null || session.LeaseExpiresUtc <= DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var key = GetKey(playSessionId);
|
||||||
|
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
|
||||||
|
if (!raw.HasValue)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
|
||||||
|
session.LeaseExpiresUtc = DateTime.UtcNow.AddMilliseconds(leaseDurationMs);
|
||||||
|
var json = JsonSerializer.Serialize(session);
|
||||||
|
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
|
||||||
|
_logger.LogDebug("Renewed lease for transcode session {PlaySessionId}.", playSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var key = GetKey(playSessionId);
|
||||||
|
await _db.KeyDeleteAsync(key).ConfigureAwait(false);
|
||||||
|
_logger.LogDebug("Deleted transcode session {PlaySessionId} from Redis.", playSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var key = GetKey(playSessionId);
|
||||||
|
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
|
||||||
|
var currentTicks = DateTime.UtcNow.Ticks;
|
||||||
|
|
||||||
|
var result = (long?)await _db.ScriptEvaluateAsync(
|
||||||
|
TakeoverScript,
|
||||||
|
keys: new RedisKey[] { key },
|
||||||
|
values: new RedisValue[] { currentTicks, claimingPod, leaseDurationMs }).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var succeeded = result == 1;
|
||||||
|
if (succeeded)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Pod {ClaimingPod} successfully took over transcode session {PlaySessionId}.", claimingPod, playSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return succeeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetKey(string playSessionId) => KeyPrefix + playSessionId;
|
||||||
|
|
||||||
|
private static string GetLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId)
|
||||||
|
=> LiveStreamKeyPrefix + liveStreamId + ":" + sessionIdOrPlaySessionId;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var sessions = new List<TranscodeSession>();
|
||||||
|
var servers = _redis.GetServers();
|
||||||
|
|
||||||
|
foreach (var server in servers)
|
||||||
|
{
|
||||||
|
if (!server.IsConnected)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = new List<RedisKey>();
|
||||||
|
await foreach (var key in server.KeysAsync(database: _db.Database, pattern: KeyPrefix + "*", pageSize: 1000).WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
keys.Add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks = keys.Select(key => _db.StringGetAsync(key)).ToList();
|
||||||
|
var values = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var raw in values)
|
||||||
|
{
|
||||||
|
if (!raw.HasValue)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TranscodeSession? session;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to deserialize transcode session from Redis.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session is not null)
|
||||||
|
{
|
||||||
|
sessions.Add(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var key = GetLiveStreamKey(session.LiveStreamId, session.SessionId);
|
||||||
|
var json = JsonSerializer.Serialize(session);
|
||||||
|
// Live stream records use the same lease duration as transcode sessions.
|
||||||
|
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
|
||||||
|
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Also index by play session id so the caller can look up by either key.
|
||||||
|
if (!string.IsNullOrEmpty(session.PlaySessionId))
|
||||||
|
{
|
||||||
|
var playKey = GetLiveStreamKey(session.LiveStreamId, session.PlaySessionId);
|
||||||
|
await _db.StringSetAsync(playKey, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Set live stream session {LiveStreamId}/{SessionId} in Redis.",
|
||||||
|
session.LiveStreamId,
|
||||||
|
session.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId);
|
||||||
|
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
|
||||||
|
if (!raw.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<LiveStreamSession>(raw.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId);
|
||||||
|
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
|
||||||
|
if (raw.HasValue)
|
||||||
|
{
|
||||||
|
var session = JsonSerializer.Deserialize<LiveStreamSession>(raw.ToString());
|
||||||
|
if (session is not null)
|
||||||
|
{
|
||||||
|
// Remove both the session-id key and the play-session-id key if present.
|
||||||
|
var keysToDelete = new System.Collections.Generic.List<RedisKey>
|
||||||
|
{
|
||||||
|
GetLiveStreamKey(liveStreamId, session.SessionId)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(session.PlaySessionId))
|
||||||
|
{
|
||||||
|
keysToDelete.Add(GetLiveStreamKey(liveStreamId, session.PlaySessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.KeyDeleteAsync(keysToDelete.ToArray()).ConfigureAwait(false);
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Deleted live stream session {LiveStreamId}/{SessionId} from Redis.",
|
||||||
|
liveStreamId,
|
||||||
|
session.SessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: delete just the key that was supplied.
|
||||||
|
await _db.KeyDeleteAsync(key).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,6 +244,7 @@ namespace Emby.Server.Implementations.Playlists
|
|||||||
|
|
||||||
// Update the playlist in the repository
|
// Update the playlist in the repository
|
||||||
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
|
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
|
||||||
|
playlist.DateLastMediaAdded = DateTime.UtcNow;
|
||||||
|
|
||||||
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
|
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -314,7 +315,7 @@ namespace Emby.Server.Implementations.Playlists
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var newPriorItemIndex = newIndex > oldIndexAccessible ? newIndex : newIndex - 1 < 0 ? 0 : newIndex - 1;
|
var newPriorItemIndex = Math.Max(newIndex - 1, 0);
|
||||||
var newPriorItemId = accessibleChildren[newPriorItemIndex].Item1.ItemId;
|
var newPriorItemId = accessibleChildren[newPriorItemIndex].Item1.ItemId;
|
||||||
var newPriorItemIndexOnAllChildren = children.FindIndex(c => c.Item1.ItemId.Equals(newPriorItemId));
|
var newPriorItemIndexOnAllChildren = children.FindIndex(c => c.Item1.ItemId.Equals(newPriorItemId));
|
||||||
var adjustedNewIndex = DetermineAdjustedIndex(newPriorItemIndexOnAllChildren, newIndex);
|
var adjustedNewIndex = DetermineAdjustedIndex(newPriorItemIndexOnAllChildren, newIndex);
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
private readonly ILogger<AudioNormalizationTask> _logger;
|
private readonly ILogger<AudioNormalizationTask> _logger;
|
||||||
|
|
||||||
|
private static readonly TimeSpan _dbSaveInterval = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="AudioNormalizationTask"/> class.
|
/// Initializes a new instance of the <see cref="AudioNormalizationTask"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -82,7 +84,9 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||||||
|
|
||||||
foreach (var library in libraries)
|
foreach (var library in libraries)
|
||||||
{
|
{
|
||||||
|
var startDbSaveInterval = Stopwatch.GetTimestamp();
|
||||||
var albums = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Parent = library, Recursive = true });
|
var albums = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Parent = library, Recursive = true });
|
||||||
|
var toSaveDbItems = new List<BaseItem>();
|
||||||
|
|
||||||
double nextPercent = numComplete + 1;
|
double nextPercent = numComplete + 1;
|
||||||
nextPercent /= libraries.Length;
|
nextPercent /= libraries.Length;
|
||||||
@@ -114,14 +118,33 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||||||
string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile),
|
string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile),
|
||||||
OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleting the concat file
|
OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleting the concat file
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
toSaveDbItems.Add(a);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
File.Delete(tempFile);
|
try
|
||||||
|
{
|
||||||
|
File.Delete(tempFile);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete concat file: {FileName}.", tempFile);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Stopwatch.GetElapsedTime(startDbSaveInterval) > _dbSaveInterval)
|
||||||
|
{
|
||||||
|
if (toSaveDbItems.Count > 1)
|
||||||
|
{
|
||||||
|
_itemRepository.SaveItems(toSaveDbItems, cancellationToken);
|
||||||
|
toSaveDbItems.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
startDbSaveInterval = Stopwatch.GetTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
// Update sub-progress for album gain
|
// Update sub-progress for album gain
|
||||||
albumComplete++;
|
albumComplete++;
|
||||||
double albumPercent = albumComplete;
|
double albumPercent = albumComplete;
|
||||||
@@ -133,7 +156,13 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||||||
// Update progress to start at the track gain percent calculation
|
// Update progress to start at the track gain percent calculation
|
||||||
percent += nextPercent;
|
percent += nextPercent;
|
||||||
|
|
||||||
_itemRepository.SaveItems(albums, cancellationToken);
|
if (toSaveDbItems.Count > 1)
|
||||||
|
{
|
||||||
|
_itemRepository.SaveItems(toSaveDbItems, cancellationToken);
|
||||||
|
toSaveDbItems.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
startDbSaveInterval = Stopwatch.GetTimestamp();
|
||||||
|
|
||||||
// Track gain
|
// Track gain
|
||||||
var tracks = _libraryManager.GetItemList(new InternalItemsQuery { MediaTypes = [MediaType.Audio], IncludeItemTypes = [BaseItemKind.Audio], Parent = library, Recursive = true });
|
var tracks = _libraryManager.GetItemList(new InternalItemsQuery { MediaTypes = [MediaType.Audio], IncludeItemTypes = [BaseItemKind.Audio], Parent = library, Recursive = true });
|
||||||
@@ -147,6 +176,18 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||||||
string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)),
|
string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)),
|
||||||
false,
|
false,
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
toSaveDbItems.Add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Stopwatch.GetElapsedTime(startDbSaveInterval) > _dbSaveInterval)
|
||||||
|
{
|
||||||
|
if (toSaveDbItems.Count > 1)
|
||||||
|
{
|
||||||
|
_itemRepository.SaveItems(toSaveDbItems, cancellationToken);
|
||||||
|
toSaveDbItems.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
startDbSaveInterval = Stopwatch.GetTimestamp();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sub-progress for track gain
|
// Update sub-progress for track gain
|
||||||
@@ -157,7 +198,10 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||||||
progress.Report(100 * (percent + (trackPercent * nextPercent)));
|
progress.Report(100 * (percent + (trackPercent * nextPercent)));
|
||||||
}
|
}
|
||||||
|
|
||||||
_itemRepository.SaveItems(tracks, cancellationToken);
|
if (toSaveDbItems.Count > 1)
|
||||||
|
{
|
||||||
|
_itemRepository.SaveItems(toSaveDbItems, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
// Update progress
|
// Update progress
|
||||||
numComplete++;
|
numComplete++;
|
||||||
@@ -195,9 +239,9 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("Starting ffmpeg with arguments: {Arguments}", args);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Starting ffmpeg with arguments: {Arguments}", args);
|
|
||||||
process.Start();
|
process.Start();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -206,16 +250,33 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process.PriorityClass = ProcessPriorityClass.BelowNormal;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error setting ffmpeg process priority");
|
||||||
|
}
|
||||||
|
|
||||||
using var reader = process.StandardError;
|
using var reader = process.StandardError;
|
||||||
float? lufs = null;
|
float? lufs = null;
|
||||||
|
var foundLufs = false;
|
||||||
await foreach (var line in reader.ReadAllLinesAsync(cancellationToken).ConfigureAwait(false))
|
await foreach (var line in reader.ReadAllLinesAsync(cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
Match match = LUFSRegex().Match(line);
|
if (foundLufs)
|
||||||
if (match.Success)
|
|
||||||
{
|
{
|
||||||
lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
|
continue;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Match match = LUFSRegex().Match(line);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
|
||||||
|
foundLufs = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lufs is null)
|
if (lufs is null)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
using MediaBrowser.Model.Globalization;
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
@@ -21,6 +22,7 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
private readonly IConfigurationManager _configurationManager;
|
private readonly IConfigurationManager _configurationManager;
|
||||||
private readonly IFileSystem _fileSystem;
|
private readonly IFileSystem _fileSystem;
|
||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
|
private readonly ITranscodeSessionStore _sessionStore;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
|
/// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
|
||||||
@@ -29,16 +31,19 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||||
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
|
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
|
||||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||||
|
/// <param name="sessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface.</param>
|
||||||
public DeleteTranscodeFileTask(
|
public DeleteTranscodeFileTask(
|
||||||
ILogger<DeleteTranscodeFileTask> logger,
|
ILogger<DeleteTranscodeFileTask> logger,
|
||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
IConfigurationManager configurationManager,
|
IConfigurationManager configurationManager,
|
||||||
ILocalizationManager localization)
|
ILocalizationManager localization,
|
||||||
|
ITranscodeSessionStore sessionStore)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
_configurationManager = configurationManager;
|
_configurationManager = configurationManager;
|
||||||
_localization = localization;
|
_localization = localization;
|
||||||
|
_sessionStore = sessionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -78,25 +83,39 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var minDateModified = DateTime.UtcNow.AddDays(-1);
|
var minDateModified = DateTime.UtcNow.AddDays(-1);
|
||||||
progress.Report(50);
|
progress.Report(50);
|
||||||
|
|
||||||
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, progress, cancellationToken);
|
IEnumerable<TranscodeSession> activeSessions;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
activeSessions = await _sessionStore.GetActiveSessionsAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to retrieve active transcode sessions. Skipping deletion to avoid removing files in use.");
|
||||||
|
progress.Report(100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, activeSessions, progress, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes the transcoded temp files from directory with a last write time less than a given date.
|
/// Deletes the transcoded temp files from directory with a last write time less than a given date,
|
||||||
|
/// skipping any files that belong to an active transcode session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="directory">The directory.</param>
|
/// <param name="directory">The directory.</param>
|
||||||
/// <param name="minDateModified">The min date modified.</param>
|
/// <param name="minDateModified">The min date modified.</param>
|
||||||
|
/// <param name="activeSessions">The currently active transcode sessions.</param>
|
||||||
/// <param name="progress">The progress.</param>
|
/// <param name="progress">The progress.</param>
|
||||||
/// <param name="cancellationToken">The task cancellation token.</param>
|
/// <param name="cancellationToken">The task cancellation token.</param>
|
||||||
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
|
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IEnumerable<TranscodeSession> activeSessions, IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var activeSessionList = activeSessions.ToList();
|
||||||
|
|
||||||
var filesToDelete = _fileSystem.GetFiles(directory, true)
|
var filesToDelete = _fileSystem.GetFiles(directory, true)
|
||||||
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
|
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -112,6 +131,13 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (IsFileProtectedByActiveSession(file.FullName, activeSessionList))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping deletion of {FilePath} as it belongs to an active transcode session.", file.FullName);
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
|
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
|
||||||
|
|
||||||
index++;
|
index++;
|
||||||
@@ -121,4 +147,24 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsFileProtectedByActiveSession(string filePath, IList<TranscodeSession> activeSessions)
|
||||||
|
{
|
||||||
|
foreach (var session in activeSessions)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(session.ManifestPath) &&
|
||||||
|
string.Equals(filePath, session.ManifestPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(session.SegmentPathPrefix) &&
|
||||||
|
filePath.StartsWith(session.SegmentPathPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
yield return new TaskTriggerInfo
|
yield return new TaskTriggerInfo
|
||||||
{
|
{
|
||||||
Type = TaskTriggerInfoType.IntervalTrigger,
|
Type = TaskTriggerInfoType.IntervalTrigger,
|
||||||
IntervalTicks = TimeSpan.FromHours(24).Ticks
|
IntervalTicks = TimeSpan.FromHours(6).Ticks
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Buffers;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Model.Globalization;
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
|
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
|
||||||
|
|
||||||
@@ -15,16 +19,19 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
{
|
{
|
||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
|
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||||
public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization)
|
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
|
||||||
|
public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_localization = localization;
|
_localization = localization;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -62,8 +69,61 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return _libraryManager.ValidatePeopleAsync(progress, cancellationToken);
|
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
||||||
|
await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
|
||||||
|
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (context.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var dupQuery = context.Peoples
|
||||||
|
.GroupBy(e => new { e.Name, e.PersonType })
|
||||||
|
.Where(e => e.Count() > 1)
|
||||||
|
.Select(e => e.Select(f => f.Id).ToArray());
|
||||||
|
|
||||||
|
var total = dupQuery.Count();
|
||||||
|
|
||||||
|
const int PartitionSize = 100;
|
||||||
|
var iterator = 0;
|
||||||
|
int itemCounter;
|
||||||
|
var buffer = ArrayPool<Guid[]>.Shared.Rent(PartitionSize)!;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
itemCounter = 0;
|
||||||
|
await foreach (var item in dupQuery
|
||||||
|
.Take(PartitionSize)
|
||||||
|
.AsAsyncEnumerable()
|
||||||
|
.WithCancellation(cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
buffer[itemCounter++] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < itemCounter; i++)
|
||||||
|
{
|
||||||
|
var item = buffer[i];
|
||||||
|
var reference = item[0];
|
||||||
|
var dups = item[1..];
|
||||||
|
await context.PeopleBaseItemMap.WhereOneOrMany(dups, e => e.PeopleId)
|
||||||
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PeopleId, reference), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await context.Peoples.Where(e => dups.Contains(e.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
subProgress.Report(100f / total * ((iterator * PartitionSize) + i));
|
||||||
|
}
|
||||||
|
|
||||||
|
iterator++;
|
||||||
|
} while (itemCounter == PartitionSize && !cancellationToken.IsCancellationRequested);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<Guid[]>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
subProgress.Report(100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ using MediaBrowser.Controller.Events;
|
|||||||
using MediaBrowser.Controller.Events.Authentication;
|
using MediaBrowser.Controller.Events.Authentication;
|
||||||
using MediaBrowser.Controller.Events.Session;
|
using MediaBrowser.Controller.Events.Session;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
using MediaBrowser.Controller.Net;
|
using MediaBrowser.Controller.Net;
|
||||||
using MediaBrowser.Controller.Session;
|
using MediaBrowser.Controller.Session;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
@@ -60,6 +61,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
private readonly IMediaSourceManager _mediaSourceManager;
|
private readonly IMediaSourceManager _mediaSourceManager;
|
||||||
private readonly IServerApplicationHost _appHost;
|
private readonly IServerApplicationHost _appHost;
|
||||||
private readonly IDeviceManager _deviceManager;
|
private readonly IDeviceManager _deviceManager;
|
||||||
|
private readonly ITranscodeSessionStore _transcodeSessionStore;
|
||||||
private readonly CancellationTokenRegistration _shutdownCallback;
|
private readonly CancellationTokenRegistration _shutdownCallback;
|
||||||
private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections
|
private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections
|
||||||
= new(StringComparer.OrdinalIgnoreCase);
|
= new(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -89,6 +91,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
/// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
|
/// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
|
||||||
/// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
|
/// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
|
||||||
/// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param>
|
/// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param>
|
||||||
|
/// <param name="transcodeSessionStore">Instance of <see cref="ITranscodeSessionStore"/> interface.</param>
|
||||||
public SessionManager(
|
public SessionManager(
|
||||||
ILogger<SessionManager> logger,
|
ILogger<SessionManager> logger,
|
||||||
IEventManager eventManager,
|
IEventManager eventManager,
|
||||||
@@ -102,7 +105,8 @@ namespace Emby.Server.Implementations.Session
|
|||||||
IServerApplicationHost appHost,
|
IServerApplicationHost appHost,
|
||||||
IDeviceManager deviceManager,
|
IDeviceManager deviceManager,
|
||||||
IMediaSourceManager mediaSourceManager,
|
IMediaSourceManager mediaSourceManager,
|
||||||
IHostApplicationLifetime hostApplicationLifetime)
|
IHostApplicationLifetime hostApplicationLifetime,
|
||||||
|
ITranscodeSessionStore transcodeSessionStore)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_eventManager = eventManager;
|
_eventManager = eventManager;
|
||||||
@@ -116,6 +120,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
_appHost = appHost;
|
_appHost = appHost;
|
||||||
_deviceManager = deviceManager;
|
_deviceManager = deviceManager;
|
||||||
_mediaSourceManager = mediaSourceManager;
|
_mediaSourceManager = mediaSourceManager;
|
||||||
|
_transcodeSessionStore = transcodeSessionStore;
|
||||||
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
|
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
|
||||||
|
|
||||||
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
|
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
|
||||||
@@ -343,9 +348,38 @@ namespace Emby.Server.Implementations.Session
|
|||||||
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
|
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// In-memory state is absent — this pod may have taken over from a crashed pod.
|
||||||
|
// Check the durable store to determine whether the live stream record exists.
|
||||||
|
LiveStreamSession durableRecord = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
durableRecord = await _transcodeSessionStore.TryGetLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to query live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durableRecord is not null)
|
||||||
|
{
|
||||||
|
liveStreamNeedsToBeClosed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the durable record regardless of which code path set liveStreamNeedsToBeClosed.
|
||||||
if (liveStreamNeedsToBeClosed)
|
if (liveStreamNeedsToBeClosed)
|
||||||
{
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _transcodeSessionStore.DeleteLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to delete live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
|
await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
|
||||||
@@ -776,7 +810,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(info.LiveStreamId))
|
if (!string.IsNullOrEmpty(info.LiveStreamId))
|
||||||
{
|
{
|
||||||
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
|
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var eventArgs = new PlaybackStartEventArgs
|
var eventArgs = new PlaybackStartEventArgs
|
||||||
@@ -836,7 +870,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
return OnPlaybackProgress(info, false);
|
return OnPlaybackProgress(info, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
|
private async Task UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
|
||||||
{
|
{
|
||||||
var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(liveStreamId, _ => new ConcurrentDictionary<string, string>());
|
var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(liveStreamId, _ => new ConcurrentDictionary<string, string>());
|
||||||
|
|
||||||
@@ -860,6 +894,26 @@ namespace Emby.Server.Implementations.Session
|
|||||||
activeSessionMappings[sessionId] = string.Empty;
|
activeSessionMappings[sessionId] = string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist to the durable store so a takeover pod can discover open live streams.
|
||||||
|
var ownerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
|
||||||
|
var liveStreamSession = new LiveStreamSession
|
||||||
|
{
|
||||||
|
LiveStreamId = liveStreamId,
|
||||||
|
SessionId = sessionId,
|
||||||
|
PlaySessionId = playSessionId ?? string.Empty,
|
||||||
|
OwnerPod = ownerPod,
|
||||||
|
OpenedAtUtc = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _transcodeSessionStore.SetLiveStreamAsync(liveStreamSession).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to persist live stream session {LiveStreamId}/{SessionId} to durable store.", liveStreamId, sessionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -904,7 +958,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(info.LiveStreamId))
|
if (!string.IsNullOrEmpty(info.LiveStreamId))
|
||||||
{
|
{
|
||||||
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
|
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var eventArgs = new PlaybackProgressEventArgs
|
var eventArgs = new PlaybackProgressEventArgs
|
||||||
@@ -1175,7 +1229,8 @@ namespace Emby.Server.Implementations.Session
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
/// <inheritdoc />
|
||||||
|
public SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
||||||
{
|
{
|
||||||
return new SessionInfoDto
|
return new SessionInfoDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ using Jellyfin.Database.Implementations.Entities;
|
|||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Sorting;
|
using MediaBrowser.Controller.Sorting;
|
||||||
using MediaBrowser.Model.Querying;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Sorting
|
namespace Emby.Server.Implementations.Sorting
|
||||||
{
|
{
|
||||||
@@ -54,7 +53,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsFavoriteOrLiked(User) ? 0 : 1;
|
return x.IsFavoriteOrLiked(User, userItemData: null) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using Jellyfin.Database.Implementations.Entities;
|
|||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Sorting;
|
using MediaBrowser.Controller.Sorting;
|
||||||
using MediaBrowser.Model.Querying;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Sorting
|
namespace Emby.Server.Implementations.Sorting
|
||||||
{
|
{
|
||||||
@@ -55,7 +54,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsPlayed(User) ? 0 : 1;
|
return x.IsPlayed(User, userItemData: null) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using Jellyfin.Database.Implementations.Entities;
|
|||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Sorting;
|
using MediaBrowser.Controller.Sorting;
|
||||||
using MediaBrowser.Model.Querying;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Sorting
|
namespace Emby.Server.Implementations.Sorting
|
||||||
{
|
{
|
||||||
@@ -55,7 +54,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsUnplayed(User) ? 0 : 1;
|
return x.IsUnplayed(User, userItemData: null) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
|
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
|
||||||
return Array.Empty<PackageInfo>();
|
return Array.Empty<PackageInfo>();
|
||||||
}
|
}
|
||||||
|
catch (NotSupportedException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}", manifest);
|
||||||
|
return Array.Empty<PackageInfo>();
|
||||||
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
|
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
|
||||||
@@ -223,15 +228,14 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
Guid id = default,
|
Guid id = default,
|
||||||
Version? specificVersion = null)
|
Version? specificVersion = null)
|
||||||
{
|
{
|
||||||
if (name is not null)
|
|
||||||
{
|
|
||||||
availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!id.IsEmpty())
|
if (!id.IsEmpty())
|
||||||
{
|
{
|
||||||
availablePackages = availablePackages.Where(x => x.Id.Equals(id));
|
availablePackages = availablePackages.Where(x => x.Id.Equals(id));
|
||||||
}
|
}
|
||||||
|
else if (name is not null)
|
||||||
|
{
|
||||||
|
availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
if (specificVersion is not null)
|
if (specificVersion is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,18 +92,18 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[ProducesAudioFile]
|
[ProducesAudioFile]
|
||||||
public async Task<ActionResult> GetAudioStream(
|
public async Task<ActionResult> GetAudioStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -114,7 +114,7 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -133,8 +133,8 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -259,18 +259,18 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[ProducesAudioFile]
|
[ProducesAudioFile]
|
||||||
public async Task<ActionResult> GetAudioStreamByContainer(
|
public async Task<ActionResult> GetAudioStreamByContainer(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -281,7 +281,7 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -300,8 +300,8 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
|
|||||||
@@ -96,9 +96,6 @@ public class DisplayPreferencesController : BaseJellyfinApiController
|
|||||||
dto.CustomPrefs.TryAdd(key, value);
|
dto.CustomPrefs.TryAdd(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This will essentially be a noop if no changes have been made, but new prefs must be saved at least.
|
|
||||||
_displayPreferencesManager.SaveChanges();
|
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,8 +207,8 @@ public class DisplayPreferencesController : BaseJellyfinApiController
|
|||||||
|
|
||||||
// Set all remaining custom preferences.
|
// Set all remaining custom preferences.
|
||||||
_displayPreferencesManager.SetCustomItemDisplayPreferences(userId.Value, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
|
_displayPreferencesManager.SetCustomItemDisplayPreferences(userId.Value, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
|
||||||
_displayPreferencesManager.SaveChanges();
|
_displayPreferencesManager.UpdateItemDisplayPreferences(itemPrefs);
|
||||||
|
_displayPreferencesManager.UpdateDisplayPreferences(existingDisplayPreferences);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
|
private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
|
||||||
private readonly DynamicHlsHelper _dynamicHlsHelper;
|
private readonly DynamicHlsHelper _dynamicHlsHelper;
|
||||||
private readonly EncodingOptions _encodingOptions;
|
private readonly EncodingOptions _encodingOptions;
|
||||||
|
private readonly ITranscodeSessionStore _transcodeSessionStore;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
|
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
|
||||||
@@ -75,6 +76,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
/// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
|
/// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
|
||||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||||
/// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param>
|
/// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param>
|
||||||
|
/// <param name="transcodeSessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface used to register and renew HLS transcoding session leases in the durable store.</param>
|
||||||
public DynamicHlsController(
|
public DynamicHlsController(
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
IUserManager userManager,
|
IUserManager userManager,
|
||||||
@@ -86,7 +88,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
ILogger<DynamicHlsController> logger,
|
ILogger<DynamicHlsController> logger,
|
||||||
DynamicHlsHelper dynamicHlsHelper,
|
DynamicHlsHelper dynamicHlsHelper,
|
||||||
EncodingHelper encodingHelper,
|
EncodingHelper encodingHelper,
|
||||||
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
|
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator,
|
||||||
|
ITranscodeSessionStore transcodeSessionStore)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
@@ -99,6 +102,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
_dynamicHlsHelper = dynamicHlsHelper;
|
_dynamicHlsHelper = dynamicHlsHelper;
|
||||||
_encodingHelper = encodingHelper;
|
_encodingHelper = encodingHelper;
|
||||||
_dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
|
_dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
|
||||||
|
_transcodeSessionStore = transcodeSessionStore;
|
||||||
|
|
||||||
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
|
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
|
||||||
}
|
}
|
||||||
@@ -167,18 +171,18 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[ProducesPlaylistFile]
|
[ProducesPlaylistFile]
|
||||||
public async Task<ActionResult> GetLiveHlsStream(
|
public async Task<ActionResult> GetLiveHlsStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -189,7 +193,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -208,8 +212,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -309,15 +313,36 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
// If the playlist doesn't already exist, startup ffmpeg
|
// If the playlist doesn't already exist, startup ffmpeg
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Check whether this session is already registered in the HA store (takeover scenario).
|
||||||
|
var isHaMode = false;
|
||||||
|
if (!string.IsNullOrEmpty(playSessionId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingSession = await _transcodeSessionStore.TryGetAsync(playSessionId, cancellationToken).ConfigureAwait(false);
|
||||||
|
isHaMode = existingSession is not null;
|
||||||
|
}
|
||||||
|
catch (Exception haEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(haEx, "Failed to check HA mode for live-stream session {PlaySessionId}.", playSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
job = await _transcodeManager.StartFfMpeg(
|
job = await _transcodeManager.StartFfMpeg(
|
||||||
state,
|
state,
|
||||||
playlistPath,
|
playlistPath,
|
||||||
GetCommandLineArguments(playlistPath, state, true, 0),
|
GetCommandLineArguments(playlistPath, state, true, 0, isHaMode),
|
||||||
Request.HttpContext.User.GetUserId(),
|
Request.HttpContext.User.GetUserId(),
|
||||||
TranscodingJobType,
|
TranscodingJobType,
|
||||||
cancellationTokenSource)
|
cancellationTokenSource)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
job.IsLiveOutput = true;
|
job.IsLiveOutput = true;
|
||||||
|
await RegisterTranscodeSessionAsync(
|
||||||
|
playSessionId ?? string.Empty,
|
||||||
|
mediaSourceId ?? string.Empty,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
StartLeaseRenewal(playSessionId ?? string.Empty, cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -416,12 +441,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery, Required] string mediaSourceId,
|
[FromQuery, Required] string mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -432,7 +457,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -453,8 +478,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -592,12 +617,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery, Required] string mediaSourceId,
|
[FromQuery, Required] string mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -609,7 +634,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -628,8 +653,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -762,12 +787,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -778,7 +803,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -799,8 +824,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -934,12 +959,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -951,7 +976,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -970,8 +995,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1107,7 +1132,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] string playlistId,
|
[FromRoute, Required] string playlistId,
|
||||||
[FromRoute, Required] int segmentId,
|
[FromRoute, Required] int segmentId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery, Required] long runtimeTicks,
|
[FromQuery, Required] long runtimeTicks,
|
||||||
[FromQuery, Required] long actualSegmentLengthTicks,
|
[FromQuery, Required] long actualSegmentLengthTicks,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
@@ -1115,12 +1140,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -1131,7 +1156,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -1152,8 +1177,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1292,7 +1317,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] string playlistId,
|
[FromRoute, Required] string playlistId,
|
||||||
[FromRoute, Required] int segmentId,
|
[FromRoute, Required] int segmentId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery, Required] long runtimeTicks,
|
[FromQuery, Required] long runtimeTicks,
|
||||||
[FromQuery, Required] long actualSegmentLengthTicks,
|
[FromQuery, Required] long actualSegmentLengthTicks,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
@@ -1300,12 +1325,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -1317,7 +1342,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -1336,8 +1361,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1421,10 +1446,20 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
cancellationTokenSource.Token)
|
cancellationTokenSource.Token)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
var mediaSourceId = state.BaseRequest.MediaSourceId;
|
var mediaSourceId = state.BaseRequest.MediaSourceId;
|
||||||
|
double fps = state.TargetFramerate ?? 0.0f;
|
||||||
|
int segmentLength = state.SegmentLength * 1000;
|
||||||
|
|
||||||
|
// If video is transcoded and framerate is fractional (i.e. 23.976), we need to slightly adjust segment length
|
||||||
|
if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && Math.Abs(fps - Math.Floor(fps + 0.001f)) > 0.001)
|
||||||
|
{
|
||||||
|
double nearestIntFramerate = Math.Ceiling(fps);
|
||||||
|
segmentLength = (int)Math.Ceiling(segmentLength * (nearestIntFramerate / fps));
|
||||||
|
}
|
||||||
|
|
||||||
var request = new CreateMainPlaylistRequest(
|
var request = new CreateMainPlaylistRequest(
|
||||||
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
|
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
|
||||||
state.MediaPath,
|
state.MediaPath,
|
||||||
state.SegmentLength * 1000,
|
segmentLength,
|
||||||
state.RunTimeTicks ?? 0,
|
state.RunTimeTicks ?? 0,
|
||||||
state.Request.SegmentContainer ?? string.Empty,
|
state.Request.SegmentContainer ?? string.Empty,
|
||||||
"hls1/main/",
|
"hls1/main/",
|
||||||
@@ -1525,14 +1560,35 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
|
|
||||||
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
|
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
|
||||||
|
|
||||||
|
// Check whether this session is already registered in the HA store (takeover scenario).
|
||||||
|
var isHaMode = false;
|
||||||
|
if (!string.IsNullOrEmpty(streamingRequest.PlaySessionId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingSession = await _transcodeSessionStore.TryGetAsync(streamingRequest.PlaySessionId, cancellationToken).ConfigureAwait(false);
|
||||||
|
isHaMode = existingSession is not null;
|
||||||
|
}
|
||||||
|
catch (Exception haEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(haEx, "Failed to check HA mode for segment session {PlaySessionId}.", streamingRequest.PlaySessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.WaitForPath = segmentPath;
|
state.WaitForPath = segmentPath;
|
||||||
job = await _transcodeManager.StartFfMpeg(
|
job = await _transcodeManager.StartFfMpeg(
|
||||||
state,
|
state,
|
||||||
playlistPath,
|
playlistPath,
|
||||||
GetCommandLineArguments(playlistPath, state, false, segmentId),
|
GetCommandLineArguments(playlistPath, state, false, segmentId, isHaMode),
|
||||||
Request.HttpContext.User.GetUserId(),
|
Request.HttpContext.User.GetUserId(),
|
||||||
TranscodingJobType,
|
TranscodingJobType,
|
||||||
cancellationTokenSource).ConfigureAwait(false);
|
cancellationTokenSource).ConfigureAwait(false);
|
||||||
|
await RegisterTranscodeSessionAsync(
|
||||||
|
streamingRequest.PlaySessionId ?? string.Empty,
|
||||||
|
streamingRequest.MediaSourceId ?? string.Empty,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
StartLeaseRenewal(streamingRequest.PlaySessionId ?? string.Empty, cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -1560,6 +1616,77 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
private static double[] GetSegmentLengths(StreamState state)
|
private static double[] GetSegmentLengths(StreamState state)
|
||||||
=> GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
|
=> GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
|
||||||
|
|
||||||
|
private async Task RegisterTranscodeSessionAsync(string playSessionId, string mediaSourceId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var session = new TranscodeSession
|
||||||
|
{
|
||||||
|
PlaySessionId = playSessionId,
|
||||||
|
OwnerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID")
|
||||||
|
?? Environment.MachineName,
|
||||||
|
LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(30),
|
||||||
|
ManifestPath = string.Empty,
|
||||||
|
SegmentPathPrefix = string.Empty,
|
||||||
|
MediaSourceId = mediaSourceId,
|
||||||
|
LastCompletedSegmentIndex = 0,
|
||||||
|
LastDurablePlaybackOffset = 0L,
|
||||||
|
};
|
||||||
|
await _transcodeSessionStore.SetAsync(session, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to register HLS session {PlaySessionId} in durable store.", playSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartLeaseRenewal(string playSessionId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_ = Task.Run(
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _transcodeSessionStore.RenewLeaseAsync(playSessionId, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to renew lease for HLS session {PlaySessionId}.", playSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Lease renewal loop for HLS session {PlaySessionId} encountered an unexpected error.", playSessionId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _transcodeSessionStore.DeleteAsync(playSessionId, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to delete HLS session {PlaySessionId} from durable store.", playSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength)
|
internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength)
|
||||||
{
|
{
|
||||||
var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
|
var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
|
||||||
@@ -1581,7 +1708,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
|
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber, bool isHaMode = false)
|
||||||
{
|
{
|
||||||
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
||||||
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
|
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
|
||||||
@@ -1604,10 +1731,20 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||||
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
||||||
|
|
||||||
|
// In HA mode, use shorter segments and a bounded rolling buffer for faster failover recovery.
|
||||||
|
// state.SegmentLength is already validated by the streaming pipeline; RecoverySegmentLengthSeconds
|
||||||
|
// comes from EncodingOptions (user-editable config) so it is clamped here.
|
||||||
|
var effectiveSegmentLength = isHaMode
|
||||||
|
? Math.Clamp(_encodingOptions.RecoverySegmentLengthSeconds, 1, 6)
|
||||||
|
: state.SegmentLength;
|
||||||
|
var hlsListSize = isHaMode
|
||||||
|
? Math.Clamp(_encodingOptions.RecoverySegmentBufferCount, 2, 10)
|
||||||
|
: 0;
|
||||||
|
|
||||||
var segmentFormat = string.Empty;
|
var segmentFormat = string.Empty;
|
||||||
var segmentContainer = outputExtension.TrimStart('.');
|
var segmentContainer = outputExtension.TrimStart('.');
|
||||||
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
|
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
|
||||||
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
|
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size {hlsListSize}";
|
||||||
|
|
||||||
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -1625,8 +1762,11 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
|
|
||||||
var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions;
|
var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions;
|
||||||
|
|
||||||
// fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
|
if (state.VideoStream is not null && state.IsOutputVideo)
|
||||||
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
|
{
|
||||||
|
// fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
|
||||||
|
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
|
||||||
|
}
|
||||||
|
|
||||||
segmentFormat = "fmp4" + outputFmp4HeaderArg;
|
segmentFormat = "fmp4" + outputFmp4HeaderArg;
|
||||||
}
|
}
|
||||||
@@ -1656,10 +1796,10 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
|
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
|
||||||
threads,
|
threads,
|
||||||
mapArgs,
|
mapArgs,
|
||||||
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer),
|
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer, effectiveSegmentLength),
|
||||||
GetAudioArguments(state),
|
GetAudioArguments(state),
|
||||||
maxMuxingQueueSize,
|
maxMuxingQueueSize,
|
||||||
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
|
effectiveSegmentLength.ToString(CultureInfo.InvariantCulture),
|
||||||
segmentFormat,
|
segmentFormat,
|
||||||
startNumber.ToString(CultureInfo.InvariantCulture),
|
startNumber.ToString(CultureInfo.InvariantCulture),
|
||||||
baseUrlParam,
|
baseUrlParam,
|
||||||
@@ -1801,8 +1941,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
/// <param name="startNumber">The first number in the hls sequence.</param>
|
/// <param name="startNumber">The first number in the hls sequence.</param>
|
||||||
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
|
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
|
||||||
/// <param name="segmentContainer">The segment container.</param>
|
/// <param name="segmentContainer">The segment container.</param>
|
||||||
|
/// <param name="segmentLength">The effective segment length in seconds (overrides <see cref="StreamState.SegmentLength"/> when HA mode is active).</param>
|
||||||
/// <returns>The command line arguments for video transcoding.</returns>
|
/// <returns>The command line arguments for video transcoding.</returns>
|
||||||
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer)
|
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer, int? segmentLength = null)
|
||||||
{
|
{
|
||||||
if (state.VideoStream is null)
|
if (state.VideoStream is null)
|
||||||
{
|
{
|
||||||
@@ -1836,8 +1977,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (isActualOutputVideoCodecHevc)
|
if (isActualOutputVideoCodecHevc)
|
||||||
{
|
{
|
||||||
// Prefer dvh1 to dvhe
|
// Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari.
|
||||||
args += " -tag:v:0 dvh1 -strict -2";
|
var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1";
|
||||||
|
args += $" -tag:v:0 {codecTag} -strict -2";
|
||||||
}
|
}
|
||||||
else if (isActualOutputVideoCodecAv1)
|
else if (isActualOutputVideoCodecAv1)
|
||||||
{
|
{
|
||||||
@@ -1876,7 +2018,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
|
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
|
||||||
|
|
||||||
// Set the key frame params for video encoding to match the hls segment time.
|
// Set the key frame params for video encoding to match the hls segment time.
|
||||||
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber);
|
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, segmentLength ?? state.SegmentLength, isEventPlaylist, startNumber);
|
||||||
|
|
||||||
// Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
|
// Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
|
||||||
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (item is IHasAlbumArtist hasAlbumArtists)
|
if (item is IHasAlbumArtist hasAlbumArtists)
|
||||||
{
|
{
|
||||||
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name);
|
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +426,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (item is IHasArtist hasArtists)
|
if (item is IHasArtist hasArtists)
|
||||||
{
|
{
|
||||||
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name);
|
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ using MediaBrowser.Controller.Entities;
|
|||||||
using MediaBrowser.Controller.Entities.Audio;
|
using MediaBrowser.Controller.Entities.Audio;
|
||||||
using MediaBrowser.Controller.Entities.Movies;
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
using MediaBrowser.Controller.Entities.TV;
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
using MediaBrowser.Model.Activity;
|
using MediaBrowser.Model.Activity;
|
||||||
@@ -700,7 +701,18 @@ public class LibraryController : BaseJellyfinApiController
|
|||||||
// Quotes are valid in linux. They'll possibly cause issues here.
|
// Quotes are valid in linux. They'll possibly cause issues here.
|
||||||
var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
|
var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
|
||||||
|
|
||||||
return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true);
|
var filePath = item.Path;
|
||||||
|
if (item.IsFileProtocol)
|
||||||
|
{
|
||||||
|
// PhysicalFile does not work well with symlinks at the moment.
|
||||||
|
var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true);
|
||||||
|
if (resolved is not null && resolved.Exists)
|
||||||
|
{
|
||||||
|
filePath = resolved.FullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -779,11 +791,14 @@ public class LibraryController : BaseJellyfinApiController
|
|||||||
var query = new InternalItemsQuery(user)
|
var query = new InternalItemsQuery(user)
|
||||||
{
|
{
|
||||||
Genres = item.Genres,
|
Genres = item.Genres,
|
||||||
|
Tags = item.Tags,
|
||||||
Limit = limit,
|
Limit = limit,
|
||||||
IncludeItemTypes = includeItemTypes.ToArray(),
|
IncludeItemTypes = includeItemTypes.ToArray(),
|
||||||
DtoOptions = dtoOptions,
|
DtoOptions = dtoOptions,
|
||||||
EnableTotalRecordCount = !isMovie ?? true,
|
EnableTotalRecordCount = !isMovie ?? true,
|
||||||
EnableGroupByMetadataKey = isMovie ?? false,
|
EnableGroupByMetadataKey = isMovie ?? false,
|
||||||
|
ExcludeItemIds = [itemId],
|
||||||
|
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||||
};
|
};
|
||||||
|
|
||||||
// ExcludeArtistIds
|
// ExcludeArtistIds
|
||||||
|
|||||||
@@ -342,6 +342,17 @@ public class LibraryStructureController : BaseJellyfinApiController
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LibraryOptions options = item.GetLibraryOptions();
|
||||||
|
foreach (var mediaPath in request.LibraryOptions!.PathInfos)
|
||||||
|
{
|
||||||
|
if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_libraryManager.CreateShortcut(item.Path, mediaPath);
|
||||||
|
}
|
||||||
|
|
||||||
item.UpdateLibraryOptions(request.LibraryOptions);
|
item.UpdateLibraryOptions(request.LibraryOptions);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpPost("Tuners/{tunerId}/Reset")]
|
[HttpPost("Tuners/{tunerId}/Reset")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
|
public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
|
||||||
{
|
{
|
||||||
await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
|
await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
|
||||||
@@ -983,7 +983,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created tuner host returned.</response>
|
/// <response code="200">Created tuner host returned.</response>
|
||||||
/// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
|
/// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
|
||||||
[HttpPost("TunerHosts")]
|
[HttpPost("TunerHosts")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
|
public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
|
||||||
=> await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
|
=> await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
|
||||||
@@ -995,7 +995,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="204">Tuner host deleted.</response>
|
/// <response code="204">Tuner host deleted.</response>
|
||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpDelete("TunerHosts")]
|
[HttpDelete("TunerHosts")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public ActionResult DeleteTunerHost([FromQuery] string? id)
|
public ActionResult DeleteTunerHost([FromQuery] string? id)
|
||||||
{
|
{
|
||||||
@@ -1028,7 +1028,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created listings provider returned.</response>
|
/// <response code="200">Created listings provider returned.</response>
|
||||||
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
|
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
|
||||||
[HttpPost("ListingProviders")]
|
[HttpPost("ListingProviders")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
|
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
|
||||||
public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
|
public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
|
||||||
@@ -1054,7 +1054,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="204">Listing provider deleted.</response>
|
/// <response code="204">Listing provider deleted.</response>
|
||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpDelete("ListingProviders")]
|
[HttpDelete("ListingProviders")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public ActionResult DeleteListingProvider([FromQuery] string? id)
|
public ActionResult DeleteListingProvider([FromQuery] string? id)
|
||||||
{
|
{
|
||||||
@@ -1087,7 +1087,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Available countries returned.</response>
|
/// <response code="200">Available countries returned.</response>
|
||||||
/// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
|
/// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
|
||||||
[HttpGet("ListingProviders/SchedulesDirect/Countries")]
|
[HttpGet("ListingProviders/SchedulesDirect/Countries")]
|
||||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesFile(MediaTypeNames.Application.Json)]
|
[ProducesFile(MediaTypeNames.Application.Json)]
|
||||||
public async Task<ActionResult> GetSchedulesDirectCountries()
|
public async Task<ActionResult> GetSchedulesDirectCountries()
|
||||||
@@ -1108,7 +1108,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Channel mapping options returned.</response>
|
/// <response code="200">Channel mapping options returned.</response>
|
||||||
/// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
|
||||||
[HttpGet("ChannelMappingOptions")]
|
[HttpGet("ChannelMappingOptions")]
|
||||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
|
public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
|
||||||
=> _listingsManager.GetChannelMappingOptions(providerId);
|
=> _listingsManager.GetChannelMappingOptions(providerId);
|
||||||
@@ -1120,7 +1120,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created channel mapping returned.</response>
|
/// <response code="200">Created channel mapping returned.</response>
|
||||||
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
|
||||||
[HttpPost("ChannelMappings")]
|
[HttpPost("ChannelMappings")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
|
public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
|
||||||
=> _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
|
=> _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
|
||||||
@@ -1144,7 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
|
||||||
[HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
|
[HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
|
||||||
[HttpGet("Tuners/Discover")]
|
[HttpGet("Tuners/Discover")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
|
public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
|
||||||
=> _tunerHostManager.DiscoverTuners(newDevicesOnly);
|
=> _tunerHostManager.DiscoverTuners(newDevicesOnly);
|
||||||
@@ -1192,7 +1192,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public ActionResult GetLiveStreamFile(
|
public ActionResult GetLiveStreamFile(
|
||||||
[FromRoute, Required] string streamId,
|
[FromRoute, Required] string streamId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container)
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container)
|
||||||
{
|
{
|
||||||
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
|
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
|
||||||
if (liveStreamInfo is null)
|
if (liveStreamInfo is null)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public class SyncPlayController : BaseJellyfinApiController
|
|||||||
[FromBody, Required] NewGroupRequestDto requestData)
|
[FromBody, Required] NewGroupRequestDto requestData)
|
||||||
{
|
{
|
||||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||||
var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
|
var syncPlayRequest = new NewGroupRequest(requestData.GroupName.Trim());
|
||||||
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
|
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] int index,
|
[FromRoute, Required] int index,
|
||||||
[FromQuery] Guid? mediaSourceId)
|
[FromQuery] Guid? mediaSourceId)
|
||||||
{
|
{
|
||||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
var item = _libraryManager.GetItemById<BaseItem>(mediaSourceId ?? itemId, User.GetUserId());
|
||||||
if (item is null)
|
if (item is null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|||||||
@@ -102,13 +102,13 @@ public class UniversalAudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] Guid? userId,
|
[FromQuery] Guid? userId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] int? transcodingAudioChannels,
|
[FromQuery] int? transcodingAudioChannels,
|
||||||
[FromQuery] int? maxStreamingBitrate,
|
[FromQuery] int? maxStreamingBitrate,
|
||||||
[FromQuery] int? audioBitRate,
|
[FromQuery] int? audioBitRate,
|
||||||
[FromQuery] long? startTimeTicks,
|
[FromQuery] long? startTimeTicks,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? transcodingContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? transcodingContainer,
|
||||||
[FromQuery] MediaStreamProtocol? transcodingProtocol,
|
[FromQuery] MediaStreamProtocol? transcodingProtocol,
|
||||||
[FromQuery] int? maxAudioSampleRate,
|
[FromQuery] int? maxAudioSampleRate,
|
||||||
[FromQuery] int? maxAudioBitDepth,
|
[FromQuery] int? maxAudioBitDepth,
|
||||||
|
|||||||
@@ -315,18 +315,18 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public async Task<ActionResult> GetVideoStream(
|
public async Task<ActionResult> GetVideoStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -337,7 +337,7 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -358,8 +358,8 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -556,18 +556,18 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public Task<ActionResult> GetVideoStreamByContainer(
|
public Task<ActionResult> GetVideoStreamByContainer(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery] string? deviceProfileId,
|
[FromQuery] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -578,7 +578,7 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -599,8 +599,8 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ public class YearsController : BaseJellyfinApiController
|
|||||||
bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes);
|
bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes);
|
||||||
|
|
||||||
IReadOnlyList<BaseItem> items;
|
IReadOnlyList<BaseItem> items;
|
||||||
|
int totalCount = -1;
|
||||||
if (parentItem.IsFolder)
|
if (parentItem.IsFolder)
|
||||||
{
|
{
|
||||||
var folder = (Folder)parentItem;
|
var folder = (Folder)parentItem;
|
||||||
@@ -118,7 +119,7 @@ public class YearsController : BaseJellyfinApiController
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
items = recursive ? folder.GetRecursiveChildren(user, query) : folder.GetChildren(user, true).Where(Filter).ToArray();
|
items = recursive ? folder.GetRecursiveChildren(user, query, out totalCount) : folder.GetChildren(user, true).Where(Filter).ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -153,7 +154,7 @@ public class YearsController : BaseJellyfinApiController
|
|||||||
|
|
||||||
var result = new QueryResult<BaseItemDto>(
|
var result = new QueryResult<BaseItemDto>(
|
||||||
startIndex,
|
startIndex,
|
||||||
ibnItemsArray.Count,
|
totalCount == -1 ? ibnItemsArray.Count : totalCount,
|
||||||
dtos.Where(i => i is not null).ToArray());
|
dtos.Where(i => i is not null).ToArray());
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
using System;
|
||||||
using System.Net.Mime;
|
using System.Net.Mime;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||||
|
|
||||||
namespace Jellyfin.Api.Formatters;
|
namespace Jellyfin.Api.Formatters;
|
||||||
@@ -6,7 +10,7 @@ namespace Jellyfin.Api.Formatters;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Xml output formatter.
|
/// Xml output formatter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class XmlOutputFormatter : StringOutputFormatter
|
public sealed class XmlOutputFormatter : TextOutputFormatter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
|
/// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
|
||||||
@@ -15,5 +19,24 @@ public sealed class XmlOutputFormatter : StringOutputFormatter
|
|||||||
{
|
{
|
||||||
SupportedMediaTypes.Clear();
|
SupportedMediaTypes.Clear();
|
||||||
SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
|
SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
|
||||||
|
|
||||||
|
SupportedEncodings.Add(Encoding.UTF8);
|
||||||
|
SupportedEncodings.Add(Encoding.Unicode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
ArgumentNullException.ThrowIfNull(selectedEncoding);
|
||||||
|
|
||||||
|
var valueAsString = context.Object?.ToString();
|
||||||
|
if (string.IsNullOrEmpty(valueAsString))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = context.HttpContext.Response;
|
||||||
|
await response.WriteAsync(valueAsString, selectedEncoding).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ public class DynamicHlsHelper
|
|||||||
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
|
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
|
||||||
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString());
|
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
||||||
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
||||||
}
|
}
|
||||||
@@ -173,10 +173,21 @@ public class DynamicHlsHelper
|
|||||||
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main stream
|
// Video rotation metadata is only supported in fMP4 remuxing
|
||||||
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
if (state.VideoStream is not null
|
||||||
|
&& state.VideoRequest is not null
|
||||||
|
&& (state.VideoStream?.Rotation ?? 0) != 0
|
||||||
|
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
|
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
||||||
|
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
queryString += "&AllowVideoStreamCopy=false";
|
||||||
|
}
|
||||||
|
|
||||||
playlistUrl += queryString;
|
// Main stream
|
||||||
|
var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||||
|
var playlistUrl = baseUrl + queryString;
|
||||||
|
var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
|
|
||||||
var subtitleStreams = state.MediaSource
|
var subtitleStreams = state.MediaSource
|
||||||
.MediaStreams
|
.MediaStreams
|
||||||
@@ -198,37 +209,36 @@ public class DynamicHlsHelper
|
|||||||
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video rotation metadata is only supported in fMP4 remuxing
|
|
||||||
if (state.VideoStream is not null
|
|
||||||
&& state.VideoRequest is not null
|
|
||||||
&& (state.VideoStream?.Rotation ?? 0) != 0
|
|
||||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
|
||||||
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
|
||||||
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
playlistUrl += "&AllowVideoStreamCopy=false";
|
|
||||||
}
|
|
||||||
|
|
||||||
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
||||||
|
|
||||||
if (state.VideoStream is not null && state.VideoRequest is not null)
|
if (state.VideoStream is not null && state.VideoRequest is not null)
|
||||||
{
|
{
|
||||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||||
|
|
||||||
// Provide SDR HEVC entrance for backward compatibility.
|
// Provide AV1 and HEVC SDR entrances for backward compatibility.
|
||||||
if (encodingOptions.AllowHevcEncoding
|
foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
|
||||||
&& !encodingOptions.AllowAv1Encoding
|
|
||||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
|
||||||
&& state.VideoStream.VideoRange == VideoRange.HDR
|
|
||||||
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
|
var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding
|
||||||
if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
|
&& string.Equals(sdrVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isHevcEncodingAllowed = encodingOptions.AllowHevcEncoding
|
||||||
|
&& string.Equals(sdrVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isEncodingAllowed = isAv1EncodingAllowed || isHevcEncodingAllowed;
|
||||||
|
|
||||||
|
if (isEncodingAllowed
|
||||||
|
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
|
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||||
{
|
{
|
||||||
// Force HEVC Main Profile and disable video stream copy.
|
// Force AV1 and HEVC Main Profile and disable video stream copy.
|
||||||
state.OutputVideoCodec = "hevc";
|
state.OutputVideoCodec = sdrVideoCodec;
|
||||||
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
|
|
||||||
sdrVideoUrl += "&AllowVideoStreamCopy=false";
|
var sdrPlaylistQuery = playlistQuery;
|
||||||
|
sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec;
|
||||||
|
sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main";
|
||||||
|
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||||
|
|
||||||
|
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||||
|
|
||||||
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||||
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||||
@@ -238,12 +248,30 @@ public class DynamicHlsHelper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provide H.264 SDR entrance for backward compatibility.
|
||||||
|
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
|
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||||
|
{
|
||||||
|
// Force H.264 and disable video stream copy.
|
||||||
|
state.OutputVideoCodec = "h264";
|
||||||
|
|
||||||
|
var sdrPlaylistQuery = playlistQuery;
|
||||||
|
sdrPlaylistQuery["VideoCodec"] = "h264";
|
||||||
|
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||||
|
|
||||||
|
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||||
|
|
||||||
|
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||||
|
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||||
|
|
||||||
|
// Restore the video codec
|
||||||
|
state.OutputVideoCodec = "copy";
|
||||||
|
}
|
||||||
|
|
||||||
// Provide Level 5.0 entrance for backward compatibility.
|
// Provide Level 5.0 entrance for backward compatibility.
|
||||||
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
|
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
|
||||||
// but in fact it is capable of playing videos up to Level 6.1.
|
// but in fact it is capable of playing videos up to Level 6.1.
|
||||||
if (encodingOptions.AllowHevcEncoding
|
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
&& !encodingOptions.AllowAv1Encoding
|
|
||||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
|
||||||
&& state.VideoStream.Level.HasValue
|
&& state.VideoStream.Level.HasValue
|
||||||
&& state.VideoStream.Level > 150
|
&& state.VideoStream.Level > 150
|
||||||
&& state.VideoStream.VideoRange == VideoRange.SDR
|
&& state.VideoStream.VideoRange == VideoRange.SDR
|
||||||
@@ -273,12 +301,15 @@ public class DynamicHlsHelper
|
|||||||
var variation = GetBitrateVariation(totalBitrate);
|
var variation = GetBitrateVariation(totalBitrate);
|
||||||
|
|
||||||
var newBitrate = totalBitrate - variation;
|
var newBitrate = totalBitrate - variation;
|
||||||
var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
var variantQuery = playlistQuery;
|
||||||
|
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||||
|
var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||||
|
|
||||||
variation *= 2;
|
variation *= 2;
|
||||||
newBitrate = totalBitrate - variation;
|
newBitrate = totalBitrate - variation;
|
||||||
variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||||
|
variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,23 +894,6 @@ public class DynamicHlsHelper
|
|||||||
return variation;
|
return variation;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
|
|
||||||
{
|
|
||||||
return url.Replace(
|
|
||||||
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
|
|
||||||
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
|
|
||||||
StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
|
|
||||||
{
|
|
||||||
string profileStr = codec + "-profile=";
|
|
||||||
return url.Replace(
|
|
||||||
profileStr + oldValue,
|
|
||||||
profileStr + newValue,
|
|
||||||
StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
|
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
|
||||||
{
|
{
|
||||||
var oldPlaylist = playlist.ToString();
|
var oldPlaylist = playlist.ToString();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Api.Extensions;
|
using Jellyfin.Api.Extensions;
|
||||||
@@ -17,9 +18,7 @@ using MediaBrowser.Controller.MediaEncoding;
|
|||||||
using MediaBrowser.Controller.Streaming;
|
using MediaBrowser.Controller.Streaming;
|
||||||
using MediaBrowser.Model.Dlna;
|
using MediaBrowser.Model.Dlna;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
namespace Jellyfin.Api.Helpers;
|
namespace Jellyfin.Api.Helpers;
|
||||||
@@ -159,6 +158,13 @@ public static class StreamingHelpers
|
|||||||
|
|
||||||
string? containerInternal = Path.GetExtension(state.RequestedUrl);
|
string? containerInternal = Path.GetExtension(state.RequestedUrl);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(containerInternal)
|
||||||
|
&& (!string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)
|
||||||
|
|| (mediaSource != null && mediaSource.IsInfiniteStream)))
|
||||||
|
{
|
||||||
|
containerInternal = ".ts";
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(streamingRequest.Container))
|
if (!string.IsNullOrEmpty(streamingRequest.Container))
|
||||||
{
|
{
|
||||||
containerInternal = streamingRequest.Container;
|
containerInternal = streamingRequest.Container;
|
||||||
@@ -415,14 +421,18 @@ public static class StreamingHelpers
|
|||||||
request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null && IsValidCodecName(val))
|
||||||
{
|
{
|
||||||
videoRequest.VideoCodec = val;
|
videoRequest.VideoCodec = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 5:
|
||||||
request.AudioCodec = val;
|
if (IsValidCodecName(val))
|
||||||
|
{
|
||||||
|
request.AudioCodec = val;
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 6:
|
case 6:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null)
|
||||||
@@ -476,7 +486,7 @@ public static class StreamingHelpers
|
|||||||
request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
|
request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
|
||||||
break;
|
break;
|
||||||
case 15:
|
case 15:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null && Regex.IsMatch(val, EncodingHelper.LevelValidationRegexStr))
|
||||||
{
|
{
|
||||||
videoRequest.Level = val;
|
videoRequest.Level = val;
|
||||||
}
|
}
|
||||||
@@ -497,7 +507,7 @@ public static class StreamingHelpers
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case 18:
|
case 18:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null && IsValidCodecName(val))
|
||||||
{
|
{
|
||||||
videoRequest.Profile = val;
|
videoRequest.Profile = val;
|
||||||
}
|
}
|
||||||
@@ -556,7 +566,11 @@ public static class StreamingHelpers
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case 30:
|
case 30:
|
||||||
request.SubtitleCodec = val;
|
if (IsValidCodecName(val))
|
||||||
|
{
|
||||||
|
request.SubtitleCodec = val;
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 31:
|
case 31:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null)
|
||||||
@@ -579,6 +593,11 @@ public static class StreamingHelpers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsValidCodecName(string val)
|
||||||
|
{
|
||||||
|
return EncodingHelper.ContainerValidationRegex().IsMatch(val);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the container into its file extension.
|
/// Parses the container into its file extension.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Jellyfin.Api.Models.SyncPlayDtos;
|
namespace Jellyfin.Api.Models.SyncPlayDtos;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -17,5 +19,6 @@ public class NewGroupRequestDto
|
|||||||
/// Gets or sets the group name.
|
/// Gets or sets the group name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The name of the new group.</value>
|
/// <value>The name of the new group.</value>
|
||||||
|
[StringLength(200, ErrorMessage = "Group name must not exceed 200 characters.")]
|
||||||
public string GroupName { get; set; }
|
public string GroupName { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data;
|
using Jellyfin.Data;
|
||||||
using Jellyfin.Database.Implementations.Enums;
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
@@ -6,6 +7,7 @@ using MediaBrowser.Controller.Authentication;
|
|||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Net;
|
using MediaBrowser.Controller.Net;
|
||||||
using MediaBrowser.Controller.Session;
|
using MediaBrowser.Controller.Session;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Session;
|
using MediaBrowser.Model.Session;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ namespace Jellyfin.Api.WebSocketListeners;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Class SessionInfoWebSocketListener.
|
/// Class SessionInfoWebSocketListener.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState>
|
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfoDto>, WebSocketListenerState>
|
||||||
{
|
{
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -51,9 +53,26 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
|
|||||||
/// Gets the data to send.
|
/// Gets the data to send.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>Task{SystemInfo}.</returns>
|
/// <returns>Task{SystemInfo}.</returns>
|
||||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSend()
|
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSend()
|
||||||
{
|
{
|
||||||
return Task.FromResult(_sessionManager.Sessions);
|
return Task.FromResult(_sessionManager.Sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSendForConnection(IWebSocketConnection connection)
|
||||||
|
{
|
||||||
|
var sessions = _sessionManager.Sessions;
|
||||||
|
|
||||||
|
// For non-admin users, filter the sessions to only include their own sessions
|
||||||
|
if (connection.AuthorizationInfo?.User is not null &&
|
||||||
|
!connection.AuthorizationInfo.IsApiKey &&
|
||||||
|
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
|
||||||
|
{
|
||||||
|
var userId = connection.AuthorizationInfo.User.Id;
|
||||||
|
sessions = sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -80,11 +99,10 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
|
|||||||
/// <param name="message">The message.</param>
|
/// <param name="message">The message.</param>
|
||||||
protected override void Start(WebSocketMessageInfo message)
|
protected override void Start(WebSocketMessageInfo message)
|
||||||
{
|
{
|
||||||
if (!message.Connection.AuthorizationInfo.IsApiKey
|
// Allow all authenticated users to subscribe to session information
|
||||||
&& (message.Connection.AuthorizationInfo.User is null
|
if (message.Connection.AuthorizationInfo.User is null && !message.Connection.AuthorizationInfo.IsApiKey)
|
||||||
|| !message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)))
|
|
||||||
{
|
{
|
||||||
throw new AuthenticationException("Only admin users can subscribe to session information.");
|
throw new AuthenticationException("User must be authenticated to subscribe to session Information.");
|
||||||
}
|
}
|
||||||
|
|
||||||
base.Start(message);
|
base.Start(message);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Data</PackageId>
|
<PackageId>Jellyfin.Data</PackageId>
|
||||||
<VersionPrefix>10.11.0</VersionPrefix>
|
<VersionPrefix>10.11.7</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ public static class UserEntityExtensions
|
|||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, false));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));
|
entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ using System.Reflection;
|
|||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||||
using Jellyfin.Database.Implementations.Locking;
|
using Jellyfin.Database.Implementations.Locking;
|
||||||
|
using Jellyfin.Database.Providers.PostgreSQL;
|
||||||
using Jellyfin.Database.Providers.Sqlite;
|
using Jellyfin.Database.Providers.Sqlite;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Npgsql;
|
||||||
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
|
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
|
||||||
|
|
||||||
namespace Jellyfin.Server.Implementations.Extensions;
|
namespace Jellyfin.Server.Implementations.Extensions;
|
||||||
@@ -24,6 +26,13 @@ public static class ServiceCollectionExtensions
|
|||||||
private static IEnumerable<Type> DatabaseProviderTypes()
|
private static IEnumerable<Type> DatabaseProviderTypes()
|
||||||
{
|
{
|
||||||
yield return typeof(SqliteDatabaseProvider);
|
yield return typeof(SqliteDatabaseProvider);
|
||||||
|
yield return typeof(PostgreSqlDatabaseProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetPoolOption(IEnumerable<CustomDatabaseOption>? options, string key, int defaultValue)
|
||||||
|
{
|
||||||
|
var value = options?.FirstOrDefault(o => o.Key.Equals(key, StringComparison.OrdinalIgnoreCase))?.Value;
|
||||||
|
return int.TryParse(value, out var parsed) ? parsed : defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IDictionary<string, JellyfinDbProviderFactory> GetSupportedDbProviders()
|
private static IDictionary<string, JellyfinDbProviderFactory> GetSupportedDbProviders()
|
||||||
@@ -123,6 +132,50 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
serviceCollection.AddSingleton<IJellyfinDatabaseProvider>(providerFactory!);
|
serviceCollection.AddSingleton<IJellyfinDatabaseProvider>(providerFactory!);
|
||||||
|
|
||||||
|
if (efCoreConfiguration.DatabaseType.Equals("Jellyfin-PostgreSQL", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
serviceCollection.AddSingleton<NpgsqlDataSource>(static sp =>
|
||||||
|
{
|
||||||
|
var config = sp.GetRequiredService<IServerConfigurationManager>().GetConfiguration<DatabaseConfigurationOptions>("database");
|
||||||
|
var options = config.CustomProviderOptions?.Options;
|
||||||
|
|
||||||
|
var connectionString =
|
||||||
|
Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
|
||||||
|
?? options
|
||||||
|
?.FirstOrDefault(o => o.Key.Equals("ConnectionString", StringComparison.OrdinalIgnoreCase))
|
||||||
|
?.Value
|
||||||
|
?? config.CustomProviderOptions?.ConnectionString
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"No PostgreSQL connection string found. Set the POSTGRES_CONNECTION_STRING environment variable, " +
|
||||||
|
"or provide it via CustomProviderOptions.Options[\"ConnectionString\"] or CustomProviderOptions.ConnectionString.");
|
||||||
|
|
||||||
|
// Support postgresql:// / postgres:// URI format (e.g. DATABASE_URL convention).
|
||||||
|
// NpgsqlDataSourceBuilder requires ADO.NET key=value format; convert if needed.
|
||||||
|
if (connectionString.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| connectionString.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var uri = new Uri(connectionString);
|
||||||
|
var userInfoParts = uri.UserInfo.Split(':', 2);
|
||||||
|
connectionString = new NpgsqlConnectionStringBuilder
|
||||||
|
{
|
||||||
|
Host = uri.Host,
|
||||||
|
Port = uri.Port > 0 ? uri.Port : 5432,
|
||||||
|
Database = uri.AbsolutePath.TrimStart('/'),
|
||||||
|
Username = userInfoParts.Length > 0 ? Uri.UnescapeDataString(userInfoParts[0]) : null,
|
||||||
|
Password = userInfoParts.Length > 1 ? Uri.UnescapeDataString(userInfoParts[1]) : null,
|
||||||
|
}.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||||||
|
|
||||||
|
dataSourceBuilder.ConnectionStringBuilder.MinPoolSize = GetPoolOption(options, "MinPoolSize", 2);
|
||||||
|
dataSourceBuilder.ConnectionStringBuilder.MaxPoolSize = GetPoolOption(options, "MaxPoolSize", 20);
|
||||||
|
dataSourceBuilder.ConnectionStringBuilder.CommandTimeout = GetPoolOption(options, "CommandTimeout", 30);
|
||||||
|
|
||||||
|
return dataSourceBuilder.Build();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
switch (efCoreConfiguration.LockingBehavior)
|
switch (efCoreConfiguration.LockingBehavior)
|
||||||
{
|
{
|
||||||
case DatabaseLockingBehaviorTypes.NoLock:
|
case DatabaseLockingBehaviorTypes.NoLock:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class BackupService : IBackupService
|
|||||||
ReferenceHandler = ReferenceHandler.IgnoreCycles,
|
ReferenceHandler = ReferenceHandler.IgnoreCycles,
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly Version _backupEngineVersion = Version.Parse("0.2.0");
|
private readonly Version _backupEngineVersion = new Version(0, 2, 0);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="BackupService"/> class.
|
/// Initializes a new instance of the <see cref="BackupService"/> class.
|
||||||
@@ -118,17 +118,24 @@ public class BackupService : IBackupService
|
|||||||
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
|
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
|
||||||
}
|
}
|
||||||
|
|
||||||
void CopyDirectory(string source, string target)
|
void CopyDirectory(string source, string target, string[]? exclude = null)
|
||||||
{
|
{
|
||||||
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
|
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
|
||||||
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
|
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
|
||||||
|
var excludePaths = exclude?.Select(e => $"{source}/{e}/").ToArray();
|
||||||
foreach (var item in zipArchive.Entries)
|
foreach (var item in zipArchive.Entries)
|
||||||
{
|
{
|
||||||
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
|
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
|
||||||
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
|
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
|
||||||
|
|
||||||
|
if (excludePaths is not null && excludePaths.Any(e => item.FullName.StartsWith(e, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|
||||||
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal))
|
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
|
||||||
|
|| Path.EndsInDirectorySeparator(item.FullName))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -141,8 +148,10 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
|
|
||||||
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
|
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
|
||||||
CopyDirectory("Data", _applicationPaths.DataPath);
|
CopyDirectory("Data", _applicationPaths.DataPath, exclude: ["metadata", "metadata-default"]);
|
||||||
CopyDirectory("Root", _applicationPaths.RootFolderPath);
|
CopyDirectory("Root", _applicationPaths.RootFolderPath);
|
||||||
|
CopyDirectory("Data/metadata", _applicationPaths.InternalMetadataPath);
|
||||||
|
CopyDirectory("Data/metadata-default", _applicationPaths.DefaultInternalMetadataPath);
|
||||||
|
|
||||||
if (manifest.Options.Database)
|
if (manifest.Options.Database)
|
||||||
{
|
{
|
||||||
@@ -199,7 +208,7 @@ public class BackupService : IBackupService
|
|||||||
var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
|
var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
|
||||||
if (zipEntry is null)
|
if (zipEntry is null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
|
_logger.LogInformation("No backup of expected table {Table} is present in backup, continuing anyway", entityType.Type.Name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +232,7 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
|
_logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,11 +242,11 @@ public class BackupService : IBackupService
|
|||||||
|
|
||||||
_logger.LogInformation("Try restore Database");
|
_logger.LogInformation("Try restore Database");
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
_logger.LogInformation("Restored database.");
|
_logger.LogInformation("Restored database");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated);
|
_logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +272,8 @@ public class BackupService : IBackupService
|
|||||||
Options = Map(backupOptions)
|
Options = Map(backupOptions)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_logger.LogInformation("Running database optimization before backup");
|
||||||
|
|
||||||
await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
|
await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
var backupFolder = Path.Combine(_applicationPaths.BackupPath);
|
var backupFolder = Path.Combine(_applicationPaths.BackupPath);
|
||||||
@@ -281,130 +292,163 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
|
var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
|
||||||
_logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath);
|
|
||||||
var fileStream = File.OpenWrite(backupPath);
|
try
|
||||||
await using (fileStream.ConfigureAwait(false))
|
|
||||||
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
|
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Start backup process.");
|
_logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath);
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var fileStream = File.OpenWrite(backupPath);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (fileStream.ConfigureAwait(false))
|
||||||
|
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
|
||||||
{
|
{
|
||||||
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
_logger.LogInformation("Starting backup process");
|
||||||
static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
|
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
||||||
var enumerable = method.Invoke(dbSet, null)!;
|
|
||||||
return (IAsyncEnumerable<object>)enumerable;
|
|
||||||
}
|
|
||||||
|
|
||||||
// include the migration history as well
|
static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
|
||||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
|
||||||
var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [
|
|
||||||
.. typeof(JellyfinDbContext)
|
|
||||||
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
|
||||||
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
|
|
||||||
.Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
|
|
||||||
(Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
|
|
||||||
];
|
|
||||||
manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
|
|
||||||
var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using (transaction.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Begin Database backup");
|
|
||||||
|
|
||||||
foreach (var entityType in entityTypes)
|
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
|
var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
|
||||||
var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
|
var enumerable = method.Invoke(dbSet, null)!;
|
||||||
var entities = 0;
|
return (IAsyncEnumerable<object>)enumerable;
|
||||||
var zipEntryStream = zipEntry.Open();
|
}
|
||||||
await using (zipEntryStream.ConfigureAwait(false))
|
|
||||||
|
// include the migration history as well
|
||||||
|
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||||
|
var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
|
||||||
|
[
|
||||||
|
.. typeof(JellyfinDbContext)
|
||||||
|
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||||
|
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
|
||||||
|
.Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
|
||||||
|
(Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
|
||||||
|
];
|
||||||
|
manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
|
||||||
|
var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using (transaction.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Begin Database backup");
|
||||||
|
|
||||||
|
foreach (var entityType in entityTypes)
|
||||||
{
|
{
|
||||||
var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
|
_logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
|
||||||
await using (jsonSerializer.ConfigureAwait(false))
|
var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
|
||||||
|
var entities = 0;
|
||||||
|
var zipEntryStream = zipEntry.Open();
|
||||||
|
await using (zipEntryStream.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
jsonSerializer.WriteStartArray();
|
var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
|
||||||
|
await using (jsonSerializer.ConfigureAwait(false))
|
||||||
var set = entityType.ValueFactory().ConfigureAwait(false);
|
|
||||||
await foreach (var item in set.ConfigureAwait(false))
|
|
||||||
{
|
{
|
||||||
entities++;
|
jsonSerializer.WriteStartArray();
|
||||||
try
|
|
||||||
|
var set = entityType.ValueFactory().ConfigureAwait(false);
|
||||||
|
await foreach (var item in set.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerializer);
|
entities++;
|
||||||
}
|
try
|
||||||
catch (Exception ex)
|
{
|
||||||
{
|
using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
|
||||||
_logger.LogError(ex, "Could not load entity {Entity}", item);
|
document.WriteTo(jsonSerializer);
|
||||||
throw;
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not load entity {Entity}", item);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jsonSerializer.WriteEndArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonSerializer.WriteEndArray();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, entities);
|
_logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
|
_logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
|
||||||
foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
|
foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
|
||||||
.Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
|
.Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
|
||||||
{
|
|
||||||
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item))));
|
|
||||||
}
|
|
||||||
|
|
||||||
void CopyDirectory(string source, string target, string filter = "*")
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(source))
|
|
||||||
{
|
{
|
||||||
return;
|
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item))));
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Backup of folder {Table}", source);
|
void CopyDirectory(string source, string target, string filter = "*")
|
||||||
|
|
||||||
foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
|
|
||||||
{
|
{
|
||||||
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
|
if (!Directory.Exists(source))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Backup of folder {Table}", source);
|
||||||
|
|
||||||
|
foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
|
||||||
|
if (backupOptions.Subtitles)
|
||||||
|
{
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupOptions.Trickplay)
|
||||||
|
{
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupOptions.Metadata)
|
||||||
|
{
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
|
||||||
|
|
||||||
|
// If a custom metadata path is configured, the default location may still contain data.
|
||||||
|
if (!string.Equals(
|
||||||
|
Path.GetFullPath(_applicationPaths.DefaultInternalMetadataPath),
|
||||||
|
Path.GetFullPath(_applicationPaths.InternalMetadataPath),
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.DefaultInternalMetadataPath), Path.Combine("Data", "metadata-default"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
|
||||||
|
await using (manifestStream.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
|
_logger.LogInformation("Backup created");
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
|
return Map(manifest, backupPath);
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
|
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
|
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
|
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
|
|
||||||
if (backupOptions.Subtitles)
|
|
||||||
{
|
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (backupOptions.Trickplay)
|
|
||||||
{
|
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (backupOptions.Metadata)
|
|
||||||
{
|
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
|
|
||||||
}
|
|
||||||
|
|
||||||
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
|
|
||||||
await using (manifestStream.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(backupPath))
|
||||||
|
{
|
||||||
|
File.Delete(backupPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception innerEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(innerEx, "Unable to remove failed backup");
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Backup created");
|
throw;
|
||||||
return Map(manifest, backupPath);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -422,7 +466,7 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath);
|
_logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +503,7 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Could not load {BackupArchive} path.", item);
|
_logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ public sealed class BaseItemRepository
|
|||||||
private static readonly IReadOnlyList<ItemValueType> _getAlbumArtistValueTypes = [ItemValueType.AlbumArtist];
|
private static readonly IReadOnlyList<ItemValueType> _getAlbumArtistValueTypes = [ItemValueType.AlbumArtist];
|
||||||
private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios];
|
private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios];
|
||||||
private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre];
|
private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre];
|
||||||
|
private static readonly IReadOnlyList<char> SearchWildcardTerms = ['%', '_', '[', ']', '^'];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="BaseItemRepository"/> class.
|
/// Initializes a new instance of the <see cref="BaseItemRepository"/> class.
|
||||||
@@ -99,11 +100,11 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void DeleteItem(Guid id)
|
public void DeleteItem(params IReadOnlyList<Guid> ids)
|
||||||
{
|
{
|
||||||
if (id.IsEmpty() || id.Equals(PlaceholderId))
|
if (ids is null || ids.Count == 0 || ids.Any(f => f.Equals(PlaceholderId)))
|
||||||
{
|
{
|
||||||
throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(id));
|
throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
using var context = _dbProvider.CreateDbContext();
|
using var context = _dbProvider.CreateDbContext();
|
||||||
@@ -111,13 +112,15 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
var date = (DateTime?)DateTime.UtcNow;
|
var date = (DateTime?)DateTime.UtcNow;
|
||||||
|
|
||||||
|
var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray();
|
||||||
|
|
||||||
// Remove any UserData entries for the placeholder item that would conflict with the UserData
|
// Remove any UserData entries for the placeholder item that would conflict with the UserData
|
||||||
// being detached from the item being deleted. This is necessary because, during an update,
|
// being detached from the item being deleted. This is necessary because, during an update,
|
||||||
// UserData may be reattached to a new entry, but some entries can be left behind.
|
// UserData may be reattached to a new entry, but some entries can be left behind.
|
||||||
// Ensures there are no duplicate UserId/CustomDataKey combinations for the placeholder.
|
// Ensures there are no duplicate UserId/CustomDataKey combinations for the placeholder.
|
||||||
context.UserData
|
context.UserData
|
||||||
.Join(
|
.Join(
|
||||||
context.UserData.Where(e => e.ItemId == id),
|
context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId),
|
||||||
placeholder => new { placeholder.UserId, placeholder.CustomDataKey },
|
placeholder => new { placeholder.UserId, placeholder.CustomDataKey },
|
||||||
userData => new { userData.UserId, userData.CustomDataKey },
|
userData => new { userData.UserId, userData.CustomDataKey },
|
||||||
(placeholder, userData) => placeholder)
|
(placeholder, userData) => placeholder)
|
||||||
@@ -125,29 +128,31 @@ public sealed class BaseItemRepository
|
|||||||
.ExecuteDelete();
|
.ExecuteDelete();
|
||||||
|
|
||||||
// Detach all user watch data
|
// Detach all user watch data
|
||||||
context.UserData.Where(e => e.ItemId == id)
|
context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId)
|
||||||
.ExecuteUpdate(e => e
|
.ExecuteUpdate(e => e
|
||||||
.SetProperty(f => f.RetentionDate, date)
|
.SetProperty(f => f.RetentionDate, date)
|
||||||
.SetProperty(f => f.ItemId, PlaceholderId));
|
.SetProperty(f => f.ItemId, PlaceholderId));
|
||||||
|
|
||||||
context.AncestorIds.Where(e => e.ItemId == id || e.ParentItemId == id).ExecuteDelete();
|
context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.AttachmentStreamInfos.Where(e => e.ItemId == id).ExecuteDelete();
|
context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDelete();
|
||||||
context.BaseItemImageInfos.Where(e => e.ItemId == id).ExecuteDelete();
|
context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.BaseItemMetadataFields.Where(e => e.ItemId == id).ExecuteDelete();
|
context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.BaseItemProviders.Where(e => e.ItemId == id).ExecuteDelete();
|
context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.BaseItemTrailerTypes.Where(e => e.ItemId == id).ExecuteDelete();
|
context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.BaseItems.Where(e => e.Id == id).ExecuteDelete();
|
context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.Chapters.Where(e => e.ItemId == id).ExecuteDelete();
|
context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete();
|
||||||
context.CustomItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete();
|
context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.ItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete();
|
context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
|
context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete();
|
context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete();
|
||||||
context.ItemValuesMap.Where(e => e.ItemId == id).ExecuteDelete();
|
context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.KeyframeData.Where(e => e.ItemId == id).ExecuteDelete();
|
context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.MediaSegments.Where(e => e.ItemId == id).ExecuteDelete();
|
context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.MediaStreamInfos.Where(e => e.ItemId == id).ExecuteDelete();
|
context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.PeopleBaseItemMap.Where(e => e.ItemId == id).ExecuteDelete();
|
var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray();
|
||||||
context.Peoples.Where(e => e.BaseItems!.Count == 0).ExecuteDelete();
|
context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.TrickplayInfos.Where(e => e.ItemId == id).ExecuteDelete();
|
context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete();
|
||||||
|
context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
|
||||||
context.SaveChanges();
|
context.SaveChanges();
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
@@ -262,13 +267,15 @@ public sealed class BaseItemRepository
|
|||||||
IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter);
|
IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter);
|
||||||
|
|
||||||
dbQuery = TranslateQuery(dbQuery, context, filter);
|
dbQuery = TranslateQuery(dbQuery, context, filter);
|
||||||
|
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||||
|
|
||||||
if (filter.EnableTotalRecordCount)
|
if (filter.EnableTotalRecordCount)
|
||||||
{
|
{
|
||||||
result.TotalRecordCount = dbQuery.Count();
|
result.TotalRecordCount = dbQuery.Count();
|
||||||
}
|
}
|
||||||
|
|
||||||
dbQuery = ApplyGroupingFilter(dbQuery, filter);
|
|
||||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||||
|
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||||
|
|
||||||
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||||
result.StartIndex = filter.StartIndex ?? 0;
|
result.StartIndex = filter.StartIndex ?? 0;
|
||||||
@@ -286,9 +293,29 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
dbQuery = TranslateQuery(dbQuery, context, filter);
|
dbQuery = TranslateQuery(dbQuery, context, filter);
|
||||||
|
|
||||||
dbQuery = ApplyGroupingFilter(dbQuery, filter);
|
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||||
|
|
||||||
|
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
|
||||||
|
if (hasRandomSort)
|
||||||
|
{
|
||||||
|
var orderedIds = dbQuery.Select(e => e.Id).ToList();
|
||||||
|
if (orderedIds.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter)
|
||||||
|
.AsEnumerable()
|
||||||
|
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
|
||||||
|
.Where(dto => dto is not null)
|
||||||
|
.ToDictionary(i => i!.Id);
|
||||||
|
|
||||||
|
return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||||
|
|
||||||
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,9 +355,11 @@ public sealed class BaseItemRepository
|
|||||||
var mainquery = PrepareItemQuery(context, filter);
|
var mainquery = PrepareItemQuery(context, filter);
|
||||||
mainquery = TranslateQuery(mainquery, context, filter);
|
mainquery = TranslateQuery(mainquery, context, filter);
|
||||||
mainquery = mainquery.Where(g => g.DateCreated >= subqueryGrouped.Min(s => s.MaxDateCreated));
|
mainquery = mainquery.Where(g => g.DateCreated >= subqueryGrouped.Min(s => s.MaxDateCreated));
|
||||||
mainquery = ApplyGroupingFilter(mainquery, filter);
|
mainquery = ApplyGroupingFilter(context, mainquery, filter);
|
||||||
mainquery = ApplyQueryPaging(mainquery, filter);
|
mainquery = ApplyQueryPaging(mainquery, filter);
|
||||||
|
|
||||||
|
mainquery = ApplyNavigations(mainquery, filter);
|
||||||
|
|
||||||
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,36 +394,65 @@ public sealed class BaseItemRepository
|
|||||||
return query.ToArray();
|
return query.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IQueryable<BaseItemEntity> ApplyGroupingFilter(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||||
{
|
{
|
||||||
// This whole block is needed to filter duplicate entries on request
|
// This whole block is needed to filter duplicate entries on request
|
||||||
// for the time being it cannot be used because it would destroy the ordering
|
// for the time being it cannot be used because it would destroy the ordering
|
||||||
// this results in "duplicate" responses for queries that try to lookup individual series or multiple versions but
|
// this results in "duplicate" responses for queries that try to lookup individual series or multiple versions but
|
||||||
// for that case the invoker has to run a DistinctBy(e => e.PresentationUniqueKey) on their own
|
// for that case the invoker has to run a DistinctBy(e => e.PresentationUniqueKey) on their own
|
||||||
|
|
||||||
// var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
|
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
|
||||||
// if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
|
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
|
||||||
// {
|
{
|
||||||
// dbQuery = ApplyOrder(dbQuery, filter);
|
var tempQuery = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.FirstOrDefault()).Select(e => e!.Id);
|
||||||
// dbQuery = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.First());
|
dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id));
|
||||||
// }
|
}
|
||||||
// else if (enableGroupByPresentationUniqueKey)
|
else if (enableGroupByPresentationUniqueKey)
|
||||||
// {
|
{
|
||||||
// dbQuery = ApplyOrder(dbQuery, filter);
|
var tempQuery = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.FirstOrDefault()).Select(e => e!.Id);
|
||||||
// dbQuery = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.First());
|
dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id));
|
||||||
// }
|
}
|
||||||
// else if (filter.GroupBySeriesPresentationUniqueKey)
|
else if (filter.GroupBySeriesPresentationUniqueKey)
|
||||||
// {
|
{
|
||||||
// dbQuery = ApplyOrder(dbQuery, filter);
|
var tempQuery = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.FirstOrDefault()).Select(e => e!.Id);
|
||||||
// dbQuery = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.First());
|
dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id));
|
||||||
// }
|
}
|
||||||
// else
|
else
|
||||||
// {
|
{
|
||||||
// dbQuery = dbQuery.Distinct();
|
dbQuery = dbQuery.Distinct();
|
||||||
// dbQuery = ApplyOrder(dbQuery, filter);
|
}
|
||||||
// }
|
|
||||||
dbQuery = dbQuery.Distinct();
|
dbQuery = ApplyOrder(dbQuery, filter, context);
|
||||||
dbQuery = ApplyOrder(dbQuery, filter);
|
|
||||||
|
return dbQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||||
|
{
|
||||||
|
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
|
||||||
|
{
|
||||||
|
dbQuery = dbQuery.Include(e => e.TrailerTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
|
||||||
|
{
|
||||||
|
dbQuery = dbQuery.Include(e => e.Provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
|
||||||
|
{
|
||||||
|
dbQuery = dbQuery.Include(e => e.LockedFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.DtoOptions.EnableUserData)
|
||||||
|
{
|
||||||
|
dbQuery = dbQuery.Include(e => e.UserData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.DtoOptions.EnableImages)
|
||||||
|
{
|
||||||
|
dbQuery = dbQuery.Include(e => e.Images);
|
||||||
|
}
|
||||||
|
|
||||||
return dbQuery;
|
return dbQuery;
|
||||||
}
|
}
|
||||||
@@ -422,24 +480,16 @@ public sealed class BaseItemRepository
|
|||||||
private IQueryable<BaseItemEntity> ApplyQueryFilter(IQueryable<BaseItemEntity> dbQuery, JellyfinDbContext context, InternalItemsQuery filter)
|
private IQueryable<BaseItemEntity> ApplyQueryFilter(IQueryable<BaseItemEntity> dbQuery, JellyfinDbContext context, InternalItemsQuery filter)
|
||||||
{
|
{
|
||||||
dbQuery = TranslateQuery(dbQuery, context, filter);
|
dbQuery = TranslateQuery(dbQuery, context, filter);
|
||||||
dbQuery = ApplyOrder(dbQuery, filter);
|
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||||
dbQuery = ApplyGroupingFilter(dbQuery, filter);
|
|
||||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||||
|
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||||
return dbQuery;
|
return dbQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
private IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter)
|
private IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter)
|
||||||
{
|
{
|
||||||
IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking();
|
IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking();
|
||||||
dbQuery = dbQuery.AsSingleQuery()
|
dbQuery = dbQuery.AsSingleQuery();
|
||||||
.Include(e => e.TrailerTypes)
|
|
||||||
.Include(e => e.Provider)
|
|
||||||
.Include(e => e.LockedFields);
|
|
||||||
|
|
||||||
if (filter.DtoOptions.EnableImages)
|
|
||||||
{
|
|
||||||
dbQuery = dbQuery.Include(e => e.Images);
|
|
||||||
}
|
|
||||||
|
|
||||||
return dbQuery;
|
return dbQuery;
|
||||||
}
|
}
|
||||||
@@ -470,7 +520,7 @@ public sealed class BaseItemRepository
|
|||||||
var counts = dbQuery
|
var counts = dbQuery
|
||||||
.GroupBy(x => x.Type)
|
.GroupBy(x => x.Type)
|
||||||
.Select(x => new { x.Key, Count = x.Count() })
|
.Select(x => new { x.Key, Count = x.Count() })
|
||||||
.AsEnumerable();
|
.ToArray();
|
||||||
|
|
||||||
var lookup = _itemTypeLookup.BaseItemKindNames;
|
var lookup = _itemTypeLookup.BaseItemKindNames;
|
||||||
var result = new ItemCounts();
|
var result = new ItemCounts();
|
||||||
@@ -586,7 +636,6 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
var ids = tuples.Select(f => f.Item.Id).ToArray();
|
var ids = tuples.Select(f => f.Item.Id).ToArray();
|
||||||
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
|
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
|
||||||
var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
|
|
||||||
|
|
||||||
foreach (var item in tuples)
|
foreach (var item in tuples)
|
||||||
{
|
{
|
||||||
@@ -601,25 +650,25 @@ public sealed class BaseItemRepository
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||||
|
context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||||
|
context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||||
|
|
||||||
|
if (entity.Images is { Count: > 0 })
|
||||||
|
{
|
||||||
|
context.BaseItemImageInfos.AddRange(entity.Images);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.LockedFields is { Count: > 0 })
|
||||||
|
{
|
||||||
|
context.BaseItemMetadataFields.AddRange(entity.LockedFields);
|
||||||
|
}
|
||||||
|
|
||||||
context.BaseItems.Attach(entity).State = EntityState.Modified;
|
context.BaseItems.Attach(entity).State = EntityState.Modified;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.SaveChanges();
|
context.SaveChanges();
|
||||||
|
|
||||||
foreach (var item in newItems)
|
|
||||||
{
|
|
||||||
// reattach old userData entries
|
|
||||||
var userKeys = item.UserDataKey.ToArray();
|
|
||||||
var retentionDate = (DateTime?)null;
|
|
||||||
context.UserData
|
|
||||||
.Where(e => e.ItemId == PlaceholderId)
|
|
||||||
.Where(e => userKeys.Contains(e.CustomDataKey))
|
|
||||||
.ExecuteUpdate(e => e
|
|
||||||
.SetProperty(f => f.ItemId, item.Item.Id)
|
|
||||||
.SetProperty(f => f.RetentionDate, retentionDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
var itemValueMaps = tuples
|
var itemValueMaps = tuples
|
||||||
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
|
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
@@ -715,6 +764,43 @@ public sealed class BaseItemRepository
|
|||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (transaction.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var userKeys = item.GetUserDataKeys().ToArray();
|
||||||
|
var retentionDate = (DateTime?)null;
|
||||||
|
|
||||||
|
await dbContext.UserData
|
||||||
|
.Where(e => e.ItemId == PlaceholderId)
|
||||||
|
.Where(e => userKeys.Contains(e.CustomDataKey))
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
e => e
|
||||||
|
.SetProperty(f => f.ItemId, item.Id)
|
||||||
|
.SetProperty(f => f.RetentionDate, retentionDate),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Rehydrate the cached userdata
|
||||||
|
item.UserData = await dbContext.UserData
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.ItemId == item.Id)
|
||||||
|
.ToArrayAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public BaseItemDto? RetrieveItem(Guid id)
|
public BaseItemDto? RetrieveItem(Guid id)
|
||||||
{
|
{
|
||||||
@@ -724,13 +810,20 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = _dbProvider.CreateDbContext();
|
using var context = _dbProvider.CreateDbContext();
|
||||||
var item = PrepareItemQuery(context, new()
|
var dbQuery = PrepareItemQuery(context, new()
|
||||||
{
|
{
|
||||||
DtoOptions = new()
|
DtoOptions = new()
|
||||||
{
|
{
|
||||||
EnableImages = true
|
EnableImages = true
|
||||||
}
|
}
|
||||||
}).FirstOrDefault(e => e.Id == id);
|
});
|
||||||
|
dbQuery = dbQuery.Include(e => e.TrailerTypes)
|
||||||
|
.Include(e => e.Provider)
|
||||||
|
.Include(e => e.LockedFields)
|
||||||
|
.Include(e => e.UserData)
|
||||||
|
.Include(e => e.Images);
|
||||||
|
|
||||||
|
var item = dbQuery.FirstOrDefault(e => e.Id == id);
|
||||||
if (item is null)
|
if (item is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -745,8 +838,9 @@ public sealed class BaseItemRepository
|
|||||||
/// <param name="entity">The entity.</param>
|
/// <param name="entity">The entity.</param>
|
||||||
/// <param name="dto">The dto base instance.</param>
|
/// <param name="dto">The dto base instance.</param>
|
||||||
/// <param name="appHost">The Application server Host.</param>
|
/// <param name="appHost">The Application server Host.</param>
|
||||||
|
/// <param name="logger">The applogger.</param>
|
||||||
/// <returns>The dto to map.</returns>
|
/// <returns>The dto to map.</returns>
|
||||||
public static BaseItemDto Map(BaseItemEntity entity, BaseItemDto dto, IServerApplicationHost? appHost)
|
public static BaseItemDto Map(BaseItemEntity entity, BaseItemDto dto, IServerApplicationHost? appHost, ILogger logger)
|
||||||
{
|
{
|
||||||
dto.Id = entity.Id;
|
dto.Id = entity.Id;
|
||||||
dto.ParentId = entity.ParentId.GetValueOrDefault();
|
dto.ParentId = entity.ParentId.GetValueOrDefault();
|
||||||
@@ -791,6 +885,8 @@ public sealed class BaseItemRepository
|
|||||||
dto.OwnerId = string.IsNullOrWhiteSpace(entity.OwnerId) ? Guid.Empty : (Guid.TryParse(entity.OwnerId, out var ownerId) ? ownerId : Guid.Empty);
|
dto.OwnerId = string.IsNullOrWhiteSpace(entity.OwnerId) ? Guid.Empty : (Guid.TryParse(entity.OwnerId, out var ownerId) ? ownerId : Guid.Empty);
|
||||||
dto.Width = entity.Width.GetValueOrDefault();
|
dto.Width = entity.Width.GetValueOrDefault();
|
||||||
dto.Height = entity.Height.GetValueOrDefault();
|
dto.Height = entity.Height.GetValueOrDefault();
|
||||||
|
dto.UserData = entity.UserData;
|
||||||
|
|
||||||
if (entity.Provider is not null)
|
if (entity.Provider is not null)
|
||||||
{
|
{
|
||||||
dto.ProviderIds = entity.Provider.ToDictionary(e => e.ProviderId, e => e.ProviderValue);
|
dto.ProviderIds = entity.Provider.ToDictionary(e => e.ProviderId, e => e.ProviderValue);
|
||||||
@@ -812,7 +908,7 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
|
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
|
||||||
dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? [];
|
dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
|
||||||
dto.Studios = entity.Studios?.Split('|') ?? [];
|
dto.Studios = entity.Studios?.Split('|') ?? [];
|
||||||
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
|
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
|
||||||
|
|
||||||
@@ -974,7 +1070,7 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
|
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
|
||||||
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null;
|
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p))) : null;
|
||||||
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
|
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
|
||||||
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
|
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
|
||||||
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
|
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
|
||||||
@@ -1144,7 +1240,7 @@ public sealed class BaseItemRepository
|
|||||||
dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialize unknown type.");
|
dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialize unknown type.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Map(baseItemEntity, dto, appHost);
|
return Map(baseItemEntity, dto, appHost, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
private QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType)
|
private QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType)
|
||||||
@@ -1209,8 +1305,20 @@ public sealed class BaseItemRepository
|
|||||||
ExcludeItemIds = filter.ExcludeItemIds
|
ExcludeItemIds = filter.ExcludeItemIds
|
||||||
};
|
};
|
||||||
|
|
||||||
var query = TranslateQuery(innerQuery, context, outerQueryFilter)
|
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter)
|
||||||
.GroupBy(e => e.PresentationUniqueKey);
|
.GroupBy(e => e.PresentationUniqueKey)
|
||||||
|
.Select(e => e.FirstOrDefault())
|
||||||
|
.Select(e => e!.Id);
|
||||||
|
|
||||||
|
var query = context.BaseItems
|
||||||
|
.Include(e => e.TrailerTypes)
|
||||||
|
.Include(e => e.Provider)
|
||||||
|
.Include(e => e.LockedFields)
|
||||||
|
.Include(e => e.Images)
|
||||||
|
.AsSingleQuery()
|
||||||
|
.Where(e => masterQuery.Contains(e.Id));
|
||||||
|
|
||||||
|
query = ApplyOrder(query, filter, context);
|
||||||
|
|
||||||
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
|
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
|
||||||
if (filter.EnableTotalRecordCount)
|
if (filter.EnableTotalRecordCount)
|
||||||
@@ -1265,12 +1373,7 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
var resultQuery = query.Select(e => new
|
var resultQuery = query.Select(e => new
|
||||||
{
|
{
|
||||||
item = e.AsQueryable()
|
item = e,
|
||||||
.Include(e => e.TrailerTypes)
|
|
||||||
.Include(e => e.Provider)
|
|
||||||
.Include(e => e.LockedFields)
|
|
||||||
.Include(e => e.Images)
|
|
||||||
.AsSingleQuery().First(),
|
|
||||||
// TODO: This is bad refactor!
|
// TODO: This is bad refactor!
|
||||||
itemCount = new ItemCounts()
|
itemCount = new ItemCounts()
|
||||||
{
|
{
|
||||||
@@ -1302,7 +1405,6 @@ public sealed class BaseItemRepository
|
|||||||
result.Items =
|
result.Items =
|
||||||
[
|
[
|
||||||
.. query
|
.. query
|
||||||
.Select(e => e.First())
|
|
||||||
.AsEnumerable()
|
.AsEnumerable()
|
||||||
.Where(e => e is not null)
|
.Where(e => e is not null)
|
||||||
.Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e =>
|
.Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e =>
|
||||||
@@ -1482,51 +1584,58 @@ public sealed class BaseItemRepository
|
|||||||
|| query.IncludeItemTypes.Contains(BaseItemKind.Season);
|
|| query.IncludeItemTypes.Contains(BaseItemKind.Season);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter)
|
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
|
||||||
{
|
{
|
||||||
var orderBy = filter.OrderBy;
|
var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray();
|
||||||
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
|
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
|
||||||
|
|
||||||
if (hasSearch)
|
if (hasSearch)
|
||||||
{
|
{
|
||||||
orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
|
orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
|
||||||
}
|
}
|
||||||
else if (orderBy.Count == 0)
|
else if (orderBy.Length == 0)
|
||||||
{
|
{
|
||||||
return query.OrderBy(e => e.SortName);
|
return query.OrderBy(e => e.SortName);
|
||||||
}
|
}
|
||||||
|
|
||||||
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
|
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
|
||||||
|
|
||||||
|
// When searching, prioritize by match quality: exact match > prefix match > contains
|
||||||
|
if (hasSearch)
|
||||||
|
{
|
||||||
|
orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!));
|
||||||
|
}
|
||||||
|
|
||||||
var firstOrdering = orderBy.FirstOrDefault();
|
var firstOrdering = orderBy.FirstOrDefault();
|
||||||
if (firstOrdering != default)
|
if (firstOrdering != default)
|
||||||
{
|
{
|
||||||
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter);
|
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
|
||||||
if (firstOrdering.SortOrder == SortOrder.Ascending)
|
if (orderedQuery is null)
|
||||||
{
|
{
|
||||||
orderedQuery = query.OrderBy(expression);
|
// No search relevance ordering, start fresh
|
||||||
|
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
|
||||||
|
? query.OrderBy(expression)
|
||||||
|
: query.OrderByDescending(expression);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
orderedQuery = query.OrderByDescending(expression);
|
// Search relevance ordering already applied, chain with ThenBy
|
||||||
|
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
|
||||||
|
? orderedQuery.ThenBy(expression)
|
||||||
|
: orderedQuery.ThenByDescending(expression);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
|
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
|
||||||
{
|
{
|
||||||
if (firstOrdering.SortOrder is SortOrder.Ascending)
|
orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending
|
||||||
{
|
? orderedQuery.ThenBy(e => e.Name)
|
||||||
orderedQuery = orderedQuery.ThenBy(e => e.Name);
|
: orderedQuery.ThenByDescending(e => e.Name);
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
orderedQuery = orderedQuery.ThenByDescending(e => e.Name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var item in orderBy.Skip(1))
|
foreach (var item in orderBy.Skip(1))
|
||||||
{
|
{
|
||||||
var expression = OrderMapper.MapOrderByField(item.OrderBy, filter);
|
var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
|
||||||
if (item.SortOrder == SortOrder.Ascending)
|
if (item.SortOrder == SortOrder.Ascending)
|
||||||
{
|
{
|
||||||
orderedQuery = orderedQuery!.ThenBy(expression);
|
orderedQuery = orderedQuery!.ThenBy(expression);
|
||||||
@@ -1608,19 +1717,18 @@ public sealed class BaseItemRepository
|
|||||||
var tags = filter.Tags.ToList();
|
var tags = filter.Tags.ToList();
|
||||||
var excludeTags = filter.ExcludeTags.ToList();
|
var excludeTags = filter.ExcludeTags.ToList();
|
||||||
|
|
||||||
if (filter.IsMovie == true)
|
if (filter.IsMovie.HasValue)
|
||||||
{
|
{
|
||||||
if (filter.IncludeItemTypes.Length == 0
|
var shouldIncludeAllMovieTypes = filter.IsMovie.Value
|
||||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
|
&& (filter.IncludeItemTypes.Length == 0
|
||||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
|
|| filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
|
||||||
|
|| filter.IncludeItemTypes.Contains(BaseItemKind.Trailer));
|
||||||
|
|
||||||
|
if (!shouldIncludeAllMovieTypes)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.IsMovie);
|
baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (filter.IsMovie.HasValue)
|
|
||||||
{
|
|
||||||
baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.IsSeries.HasValue)
|
if (filter.IsSeries.HasValue)
|
||||||
{
|
{
|
||||||
@@ -1665,8 +1773,17 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(filter.SearchTerm))
|
if (!string.IsNullOrEmpty(filter.SearchTerm))
|
||||||
{
|
{
|
||||||
var searchTerm = filter.SearchTerm.ToLower();
|
var cleanedSearchTerm = GetCleanValue(filter.SearchTerm);
|
||||||
baseQuery = baseQuery.Where(e => e.CleanName!.ToLower().Contains(searchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(searchTerm)));
|
var originalSearchTerm = filter.SearchTerm.ToLower();
|
||||||
|
if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f)))
|
||||||
|
{
|
||||||
|
cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%";
|
||||||
|
baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.IsFolder.HasValue)
|
if (filter.IsFolder.HasValue)
|
||||||
@@ -1719,7 +1836,8 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.Path))
|
if (!string.IsNullOrWhiteSpace(filter.Path))
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.Path == filter.Path);
|
var pathToQuery = GetPathToSave(filter.Path);
|
||||||
|
baseQuery = baseQuery.Where(e => e.Path == pathToQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey))
|
if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey))
|
||||||
@@ -1838,10 +1956,17 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.PersonIds.Length > 0)
|
if (filter.PersonIds.Length > 0)
|
||||||
{
|
{
|
||||||
|
var peopleEntityIds = context.BaseItems
|
||||||
|
.WhereOneOrMany(filter.PersonIds, b => b.Id)
|
||||||
|
.Join(
|
||||||
|
context.Peoples,
|
||||||
|
b => b.Name,
|
||||||
|
p => p.Name,
|
||||||
|
(b, p) => p.Id);
|
||||||
|
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e =>
|
.Where(e => context.PeopleBaseItemMap
|
||||||
context.PeopleBaseItemMap.Where(w => context.BaseItems.Where(r => filter.PersonIds.Contains(r.Id)).Any(f => f.Name == w.People.Name))
|
.Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId)));
|
||||||
.Any(f => f.ItemId == e.Id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.Person))
|
if (!string.IsNullOrWhiteSpace(filter.Person))
|
||||||
@@ -1869,34 +1994,50 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.Name))
|
if (!string.IsNullOrWhiteSpace(filter.Name))
|
||||||
{
|
{
|
||||||
var cleanName = GetCleanValue(filter.Name);
|
if (filter.UseRawName == true)
|
||||||
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
|
{
|
||||||
|
baseQuery = baseQuery.Where(e => e.Name == filter.Name);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var cleanName = GetCleanValue(filter.Name);
|
||||||
|
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// These are the same, for now
|
// These are the same, for now
|
||||||
var nameContains = filter.NameContains;
|
var nameContains = filter.NameContains;
|
||||||
if (!string.IsNullOrWhiteSpace(nameContains))
|
if (!string.IsNullOrWhiteSpace(nameContains))
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e =>
|
if (SearchWildcardTerms.Any(f => nameContains.Contains(f)))
|
||||||
e.CleanName!.Contains(nameContains)
|
{
|
||||||
|| e.OriginalTitle!.ToLower().Contains(nameContains!));
|
nameContains = $"%{nameContains.Trim('%')}%";
|
||||||
|
baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName, nameContains) || EF.Functions.Like(e.OriginalTitle, nameContains));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
baseQuery = baseQuery.Where(e =>
|
||||||
|
e.CleanName!.Contains(nameContains)
|
||||||
|
|| e.OriginalTitle!.ToLower().Contains(nameContains!));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
|
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith) || e.Name!.StartsWith(filter.NameStartsWith));
|
var startsWithLower = filter.NameStartsWith.ToLowerInvariant();
|
||||||
|
baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
|
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
|
||||||
{
|
{
|
||||||
// i hate this
|
var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant();
|
||||||
baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() > filter.NameStartsWithOrGreater[0] || e.Name!.FirstOrDefault() > filter.NameStartsWithOrGreater[0]);
|
baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
|
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
|
||||||
{
|
{
|
||||||
// i hate this
|
var lessThanLower = filter.NameLessThan.ToLowerInvariant();
|
||||||
baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() < filter.NameLessThan[0] || e.Name!.FirstOrDefault() < filter.NameLessThan[0]);
|
baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.ImageTypes.Length > 0)
|
if (filter.ImageTypes.Length > 0)
|
||||||
@@ -1962,7 +2103,7 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.ArtistIds.Length > 0)
|
if (filter.ArtistIds.Length > 0)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ArtistIds);
|
baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.AlbumArtistIds.Length > 0)
|
if (filter.AlbumArtistIds.Length > 0)
|
||||||
@@ -1972,7 +2113,18 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.ContributingArtistIds.Length > 0)
|
if (filter.ContributingArtistIds.Length > 0)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ContributingArtistIds);
|
var contributingNames = context.BaseItems
|
||||||
|
.Where(b => filter.ContributingArtistIds.Contains(b.Id))
|
||||||
|
.Select(b => b.CleanName);
|
||||||
|
|
||||||
|
baseQuery = baseQuery.Where(e =>
|
||||||
|
e.ItemValues!.Any(ivm =>
|
||||||
|
ivm.ItemValue.Type == ItemValueType.Artist &&
|
||||||
|
contributingNames.Contains(ivm.ItemValue.CleanValue))
|
||||||
|
&&
|
||||||
|
!e.ItemValues!.Any(ivm =>
|
||||||
|
ivm.ItemValue.Type == ItemValueType.AlbumArtist &&
|
||||||
|
contributingNames.Contains(ivm.ItemValue.CleanValue)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.AlbumIds.Length > 0)
|
if (filter.AlbumIds.Length > 0)
|
||||||
@@ -1983,7 +2135,7 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.ExcludeArtistIds.Length > 0)
|
if (filter.ExcludeArtistIds.Length > 0)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ExcludeArtistIds, true);
|
baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.GenreIds.Count > 0)
|
if (filter.GenreIds.Count > 0)
|
||||||
@@ -2027,22 +2179,26 @@ public sealed class BaseItemRepository
|
|||||||
if (filter.MinParentalRating != null)
|
if (filter.MinParentalRating != null)
|
||||||
{
|
{
|
||||||
var min = filter.MinParentalRating;
|
var min = filter.MinParentalRating;
|
||||||
minParentalRatingFilter = e => e.InheritedParentalRatingValue >= min.Score || e.InheritedParentalRatingValue == null;
|
var minScore = min.Score;
|
||||||
if (min.SubScore != null)
|
var minSubScore = min.SubScore ?? 0;
|
||||||
{
|
|
||||||
minParentalRatingFilter = minParentalRatingFilter.And(e => e.InheritedParentalRatingValue >= min.SubScore || e.InheritedParentalRatingValue == null);
|
minParentalRatingFilter = e =>
|
||||||
}
|
e.InheritedParentalRatingValue == null ||
|
||||||
|
e.InheritedParentalRatingValue > minScore ||
|
||||||
|
(e.InheritedParentalRatingValue == minScore && (e.InheritedParentalRatingSubValue ?? 0) >= minSubScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
Expression<Func<BaseItemEntity, bool>>? maxParentalRatingFilter = null;
|
Expression<Func<BaseItemEntity, bool>>? maxParentalRatingFilter = null;
|
||||||
if (filter.MaxParentalRating != null)
|
if (filter.MaxParentalRating != null)
|
||||||
{
|
{
|
||||||
var max = filter.MaxParentalRating;
|
var max = filter.MaxParentalRating;
|
||||||
maxParentalRatingFilter = e => e.InheritedParentalRatingValue <= max.Score || e.InheritedParentalRatingValue == null;
|
var maxScore = max.Score;
|
||||||
if (max.SubScore != null)
|
var maxSubScore = max.SubScore ?? 0;
|
||||||
{
|
|
||||||
maxParentalRatingFilter = maxParentalRatingFilter.And(e => e.InheritedParentalRatingValue <= max.SubScore || e.InheritedParentalRatingValue == null);
|
maxParentalRatingFilter = e =>
|
||||||
}
|
e.InheritedParentalRatingValue == null ||
|
||||||
|
e.InheritedParentalRatingValue < maxScore ||
|
||||||
|
(e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.HasParentalRating ?? false)
|
if (filter.HasParentalRating ?? false)
|
||||||
@@ -2270,23 +2426,39 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
|
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
|
||||||
{
|
{
|
||||||
var include = filter.HasAnyProviderId.Select(e => $"{e.Key}:{e.Value}").ToArray();
|
// Allow setting a null or empty value to get all items that have the specified provider set.
|
||||||
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => include.Contains(f)));
|
var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray();
|
||||||
|
if (includeAny.Length > 0)
|
||||||
|
{
|
||||||
|
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray();
|
||||||
|
if (includeSelected.Length > 0)
|
||||||
|
{
|
||||||
|
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.HasImdbId.HasValue)
|
if (filter.HasImdbId.HasValue)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "imdb"));
|
baseQuery = filter.HasImdbId.Value
|
||||||
|
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower()))
|
||||||
|
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.HasTmdbId.HasValue)
|
if (filter.HasTmdbId.HasValue)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tmdb"));
|
baseQuery = filter.HasTmdbId.Value
|
||||||
|
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower()))
|
||||||
|
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.HasTvdbId.HasValue)
|
if (filter.HasTvdbId.HasValue)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tvdb"));
|
baseQuery = filter.HasTvdbId.Value
|
||||||
|
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower()))
|
||||||
|
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower()));
|
||||||
}
|
}
|
||||||
|
|
||||||
var queryTopParentIds = filter.TopParentIds;
|
var queryTopParentIds = filter.TopParentIds;
|
||||||
@@ -2324,40 +2496,24 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.ExcludeInheritedTags.Length > 0)
|
if (filter.ExcludeInheritedTags.Length > 0)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
var excludedTags = filter.ExcludeInheritedTags;
|
||||||
.Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags)
|
baseQuery = baseQuery.Where(e =>
|
||||||
.Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)));
|
!e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
|
||||||
|
&& (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.IncludeInheritedTags.Length > 0)
|
if (filter.IncludeInheritedTags.Length > 0)
|
||||||
{
|
{
|
||||||
// Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client.
|
var includeTags = filter.IncludeInheritedTags;
|
||||||
// In addition to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well.
|
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
|
||||||
if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode)
|
baseQuery = baseQuery.Where(e =>
|
||||||
{
|
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
|
||||||
baseQuery = baseQuery
|
|
||||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
|
||||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
|
||||||
||
|
|
||||||
(e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value && (w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags))
|
|
||||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A playlist should be accessible to its owner regardless of allowed tags.
|
// For seasons and episodes, we also need to check the parent series' tags.
|
||||||
else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist)
|
|| (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)))
|
||||||
{
|
|
||||||
baseQuery = baseQuery
|
// A playlist should be accessible to its owner regardless of allowed tags
|
||||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
|
||||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
|
||||||
|| e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
|
|
||||||
// d ^^ this is stupid it hate this.
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
baseQuery = baseQuery
|
|
||||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
|
||||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.SeriesStatuses.Length > 0)
|
if (filter.SeriesStatuses.Length > 0)
|
||||||
@@ -2449,4 +2605,83 @@ public sealed class BaseItemRepository
|
|||||||
return await dbContext.BaseItems.AnyAsync(f => f.Id == id).ConfigureAwait(false);
|
return await dbContext.BaseItems.AnyAsync(f => f.Id == id).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool GetIsPlayed(User user, Guid id, bool recursive)
|
||||||
|
{
|
||||||
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
|
||||||
|
if (recursive)
|
||||||
|
{
|
||||||
|
var folderList = TraverseHirachyDown(id, dbContext, item => (item.IsFolder || item.IsVirtualItem));
|
||||||
|
|
||||||
|
return dbContext.BaseItems
|
||||||
|
.Where(e => folderList.Contains(e.ParentId!.Value) && !e.IsFolder && !e.IsVirtualItem)
|
||||||
|
.All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<Guid> TraverseHirachyDown(Guid parentId, JellyfinDbContext dbContext, Expression<Func<BaseItemEntity, bool>>? filter = null)
|
||||||
|
{
|
||||||
|
var folderStack = new HashSet<Guid>()
|
||||||
|
{
|
||||||
|
parentId
|
||||||
|
};
|
||||||
|
var folderList = new HashSet<Guid>()
|
||||||
|
{
|
||||||
|
parentId
|
||||||
|
};
|
||||||
|
|
||||||
|
while (folderStack.Count != 0)
|
||||||
|
{
|
||||||
|
var items = folderStack.ToArray();
|
||||||
|
folderStack.Clear();
|
||||||
|
var query = dbContext.BaseItems
|
||||||
|
.WhereOneOrMany(items, e => e.ParentId!.Value);
|
||||||
|
|
||||||
|
if (filter != null)
|
||||||
|
{
|
||||||
|
query = query.Where(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in query.Select(e => e.Id).ToArray())
|
||||||
|
{
|
||||||
|
if (folderList.Add(item))
|
||||||
|
{
|
||||||
|
folderStack.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return folderList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames)
|
||||||
|
{
|
||||||
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
|
||||||
|
var artists = dbContext.BaseItems.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!)
|
||||||
|
.Where(e => artistNames.Contains(e.Name))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var lookup = artists
|
||||||
|
.GroupBy(e => e.Name!)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||||
|
|
||||||
|
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
|
||||||
|
foreach (var name in artistNames)
|
||||||
|
{
|
||||||
|
if (lookup.TryGetValue(name, out var artistArray))
|
||||||
|
{
|
||||||
|
result[name] = artistArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user