Compare commits
448 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d501ba418 | |||
| 2a8d168796 | |||
| 34cc90f5e2 | |||
| 26fbd1eac0 | |||
| 35e86416af | |||
| c210ea87f2 | |||
| 4a1fcca182 | |||
| f5211a6722 | |||
| 0b77a520fa | |||
| d0e0e291f2 | |||
| 82ecfde2f7 | |||
| 7be16e9f74 | |||
| f441c5f125 | |||
| e6e3099da9 | |||
| d7dd98858e | |||
| 3d3d3c26b8 | |||
| 5f3e938d09 | |||
| 3031aa91ba | |||
| fb763c47bf | |||
| a65b614449 | |||
| 505a6ebf8f | |||
| 10d108a1f4 | |||
| 428c0f19bc | |||
| afd91a3d9d | |||
| a0b291a463 | |||
| a9e4c826aa | |||
| b189813eb1 | |||
| 7c20ea2708 | |||
| e63c05137a | |||
| c091ffdc6b | |||
| 70ffd25b50 | |||
| 247ee406a9 | |||
| 2218f2931c | |||
| 6bc1c18004 | |||
| eee818094e | |||
| 0ec7cc5f5b | |||
| 871970120d | |||
| 59bef7940b | |||
| 830d93e446 | |||
| d696cea9bc | |||
| e83125c17c | |||
| c5da7db120 | |||
| f9b7f2edf7 | |||
| 4192a4dd32 | |||
| 84ffe668d0 | |||
| d6da6906a4 | |||
| b7e2d21038 | |||
| e120b7f2dd | |||
| 6c37a6ef8b | |||
| 9162c17834 | |||
| 929834c982 | |||
| 5b3da3bcd7 | |||
| 4860f945a7 | |||
| 9f74fd7354 | |||
| 1c5c95ad1d | |||
| dd81e52f81 | |||
| 868ad089f8 | |||
| 2a08a30982 | |||
| 812c819162 | |||
| 234a49903b | |||
| cd5962df50 | |||
| 9d99164c19 | |||
| 2db10787af | |||
| 614e5c7912 | |||
| 17b0453cfb | |||
| d5a5b56484 | |||
| ef1ce3d6eb | |||
| 07fb350333 | |||
| f49a501f71 | |||
| f6ca06e9bf | |||
| ef84408199 | |||
| 0db3759325 | |||
| df1611cf95 | |||
| a6d09815f2 | |||
| 38aa870514 | |||
| d13c65229d | |||
| 61e75599b3 | |||
| b2485af6e9 | |||
| 4adaf7f146 | |||
| 563a00faaa | |||
| 5e33a2d376 | |||
| d9d9efc96e | |||
| dd1380e562 | |||
| cfb00b58a2 | |||
| 990ab18353 | |||
| 3578e9a332 | |||
| f60cfc788b | |||
| e2586eed9b | |||
| 7fbc1ff8c0 | |||
| 5767a3a800 | |||
| 4b51ef2c2f | |||
| f50fc0fa86 | |||
| a817391f67 | |||
| cc877229b1 | |||
| a3e7024355 | |||
| d3c7aa6bd2 | |||
| 40601523e7 | |||
| e3664a9561 | |||
| a1ed4b9001 | |||
| e09492de62 | |||
| d10381a0e2 | |||
| 9596115c45 | |||
| ed0be243e9 | |||
| f502fd312e | |||
| 9d0dffe818 | |||
| 4e2089b6a1 | |||
| 55518c06ee | |||
| a272efb9a2 | |||
| 33a8cdfc0b | |||
| 38c6c5b478 | |||
| d759df03b5 | |||
| e2c1121079 | |||
| fe3585577b | |||
| b3a0611302 | |||
| c20cc766e1 | |||
| 8044534774 | |||
| cac463d4c3 | |||
| 7faa35dab4 | |||
| 24022092ad | |||
| e2e3094d2d | |||
| d669994bf0 | |||
| 45b8f6c4c8 | |||
| 044f651299 | |||
| f865910a90 | |||
| f2360c07a3 | |||
| 4c812c9ba4 | |||
| 26261dbfe7 | |||
| ae90e0e52e | |||
| 72d0895401 | |||
| 705368ee49 | |||
| cbc2c7c323 | |||
| e16c8a07bd | |||
| a7a138db31 | |||
| 76956c0abc | |||
| d4376e0539 | |||
| b94ba9d409 | |||
| 744ca84a8b | |||
| c55fde25a5 | |||
| 030031dcff | |||
| 341c19bace | |||
| e3a8d209b4 | |||
| e816870f67 | |||
| f28acc7fa1 | |||
| b2ae39e0d9 | |||
| 893662ba50 | |||
| 314004bc19 | |||
| 0915a61c19 | |||
| e123a13e38 | |||
| b15d3c9ad3 | |||
| 3da05330b2 | |||
| 8293eb26b9 | |||
| d8fc0a9914 | |||
| 1f4f4acb46 | |||
| d64e18b69a | |||
| 7a4271c85f | |||
| afb716566d | |||
| e5c68e1257 | |||
| a94588497c | |||
| 4cedbe28e3 | |||
| 9a258c089d | |||
| 9ff92f3f0d | |||
| 5f5c71ab75 | |||
| d92e59aa72 | |||
| d593436281 | |||
| 41b8bd4459 | |||
| f6f0fed2d1 | |||
| e2adf2e2bf | |||
| 78b535b229 | |||
| 7215950bd4 | |||
| c435f63d09 | |||
| eed664b7d3 | |||
| 5b550517b2 | |||
| e565073fd5 | |||
| 2b93191714 | |||
| 046225654a | |||
| 0db27d7b1b | |||
| f571cd5a6a | |||
| 79a55327dc | |||
| dbc796b0b0 | |||
| 02d8e7d828 | |||
| 1e4d126cb9 | |||
| f3a1d56c56 | |||
| 67b8684f3c | |||
| b04614e18d | |||
| 689a90d275 | |||
| 766be1e8bb | |||
| f2f66606d7 | |||
| 949fa4b513 | |||
| 40fa2fd390 | |||
| 7f26bd1091 | |||
| 86ac1aaa6b | |||
| 3c9727d36c | |||
| 5a2809e337 | |||
| d1d89dfb12 | |||
| 45ec0ed8b5 | |||
| ebb66f6ca3 | |||
| b85c9186ef | |||
| c606102f6d | |||
| 8b70582561 | |||
| b62fef3c1f | |||
| 71ab342838 | |||
| 83c4681e99 | |||
| 42e52f60ad | |||
| 6ac64c5319 | |||
| 83e0cfd7ee | |||
| 700e53a807 | |||
| 4d1f90b2f7 | |||
| d74babd8f3 | |||
| b7b2700425 | |||
| 04e4505402 | |||
| 95330223f4 | |||
| f28fa563c9 | |||
| d6ce6ae8b9 | |||
| 8f57f35372 | |||
| 9aa79682ba | |||
| d4cddb8a5d | |||
| dc300fae53 | |||
| 70980f09de | |||
| ca0cf763ff | |||
| 3d4c52092e | |||
| fc0af10509 | |||
| 88216e0ec4 | |||
| 474ae50c36 | |||
| 929e1936eb | |||
| 6382563440 | |||
| 733b4ba73a | |||
| 5d580abb08 | |||
| fc43f151a2 | |||
| 526f4051e9 | |||
| 635fd0433d | |||
| 370170bab0 | |||
| b317af0d30 | |||
| ca1f7af445 | |||
| 65836cc844 | |||
| b4090bdcb2 | |||
| ed61acc19a | |||
| 9b9b609c83 | |||
| 4cc69f4be0 | |||
| 527ba2e11c | |||
| 25d4e207f7 | |||
| 7a269987d2 | |||
| 081944d358 | |||
| 299810a4a9 | |||
| 53e58d8b1b | |||
| b99703301f | |||
| bdf263d867 | |||
| 9d5aedba5f | |||
| 0f88fe3c7f | |||
| 557b14e33e | |||
| c222d370ce | |||
| 0d629591ed | |||
| 23fa02eb59 | |||
| bea016c962 | |||
| fae1e4c556 | |||
| f2b9c68969 | |||
| e04ac6fd35 | |||
| 25011224cf | |||
| 191be0931e | |||
| 2cd2f36fe4 | |||
| 4f19ed5730 | |||
| 5949be852a | |||
| ffe075650c | |||
| abb35571ef | |||
| 474399683d | |||
| a238d59a07 | |||
| c779488ade | |||
| cc44f333b6 | |||
| 2b6302c06e | |||
| c3901fde56 | |||
| aead9c0e22 | |||
| 2fd747432a | |||
| 236db6d2f6 | |||
| 181c057cb8 | |||
| 7daef156e5 | |||
| f068ca3bd6 | |||
| 6c0eff5b39 | |||
| 5e946a45ee | |||
| 7d83779b6f | |||
| 663a873e07 | |||
| 3321a0cf09 | |||
| 5a9fb80239 | |||
| 8ac2d1f7bc | |||
| 9fa9d26341 | |||
| dd0b273b26 | |||
| cab108a839 | |||
| 3d83e67a52 | |||
| b809d964b3 | |||
| 96727072c8 | |||
| 9ec0d7c63b | |||
| bfe0a47b32 | |||
| 9b5f830462 | |||
| 42f9ed76c0 | |||
| 69faa6c583 | |||
| 0c7428f136 | |||
| 97e666c566 | |||
| 5cd3d7ebb7 | |||
| 62a5ded920 | |||
| 21801e8ba1 | |||
| 1a45fc82b5 | |||
| 4fb779920a | |||
| d7727224c2 | |||
| a96dc8bd9b | |||
| d146c347b4 | |||
| f2dea2a3fd | |||
| 4d1dd42420 | |||
| 8eb0ba60c1 | |||
| 2bf15d95df | |||
| be2db99586 | |||
| 37fe9df27c | |||
| 8759ad4d49 | |||
| 051ff4cf6d | |||
| fa4626c080 | |||
| d36c8ebce8 | |||
| 4cc577a72d | |||
| cdf7ce0fc4 | |||
| d894a98b79 | |||
| 2b57a5725c | |||
| 3f96790904 | |||
| 2bac9a8f0c | |||
| 997093ae3a | |||
| 5cbafa566d | |||
| 0d3cf0169e | |||
| 6e3c187493 | |||
| f8771b52ec | |||
| e3d610c3c5 | |||
| 486ffaa6dd | |||
| d96d1f1119 | |||
| 30f28456de | |||
| f8aad322cc | |||
| c6545a8b68 | |||
| 0fe9c1ce91 | |||
| b6882c86dc | |||
| 4503ad295c | |||
| 1a9bd2417a | |||
| 0d2f1b26b3 | |||
| bc59e56ef5 | |||
| b8bac71270 | |||
| 6238448716 | |||
| 56c970c2db | |||
| fcce108948 | |||
| cfeaef6180 | |||
| ebab369eac | |||
| 2a44c35224 | |||
| 0584a102ee | |||
| 2aae53bc15 | |||
| 911044b415 | |||
| b77def6883 | |||
| 5c48935fa8 | |||
| 9e996d612c | |||
| 5be844e1b7 | |||
| e2d9d592bc | |||
| 6f189bf2b8 | |||
| 47f567b6f1 | |||
| 305160d2e4 | |||
| f3ff7a446b | |||
| a3ec1a3712 | |||
| 6bbd6dcd44 | |||
| ce43df6f43 | |||
| 2250015e78 | |||
| 933f7eea1b | |||
| 631a314d24 | |||
| ed06fd5139 | |||
| 2326ecdedc | |||
| 38813f7d42 | |||
| 853922443f | |||
| 8b826d981b | |||
| 8bf1710e07 | |||
| 5e42941d3b | |||
| a1255bda6a | |||
| 9a2fdb3573 | |||
| 08f6627a24 | |||
| 53aafcd38e | |||
| d73e65172a | |||
| fff710585f | |||
| 1294990f4f | |||
| 974038e9b0 | |||
| ab0d0d1890 | |||
| f3fbe5575a | |||
| 860cb75d58 | |||
| e31f168e9b | |||
| 7f5537ca47 | |||
| a03072b617 | |||
| 31d05dbdd6 | |||
| d4faf7bd72 | |||
| 6e728b009f | |||
| efd3814a7e | |||
| b391cd5e9e | |||
| 8433773fad | |||
| 381bf18161 | |||
| f8ffccae7f | |||
| 2528bc1032 | |||
| 4b5f5d6ca3 | |||
| e2eaacd239 | |||
| 77d4ab9ea9 | |||
| 6883cd0969 | |||
| 43a152359e | |||
| 482cf4b8c3 | |||
| 2b4945217a | |||
| ccc1712d10 | |||
| 8622c3bfb7 | |||
| 820e7b91da | |||
| 08ba3717ea | |||
| 8f3eb3205d | |||
| 379c58a48d | |||
| 28b43838dd | |||
| 38f1d9749e | |||
| 9cc25d133d | |||
| 8e1b69b37f | |||
| a43fd188dc | |||
| f011529388 | |||
| 8ffb54603a | |||
| d3ee1e84b1 | |||
| 1035f6a101 | |||
| 22792b62cb | |||
| ef6f342a54 | |||
| a888257c82 | |||
| d55f808423 | |||
| dc92e3b0e4 | |||
| 95cebffa87 | |||
| 617ebf367f | |||
| 70b4589382 | |||
| 5221584b06 | |||
| c4ace4ac95 | |||
| 2fcf4084f8 | |||
| c632417dda | |||
| d090c59939 | |||
| 94d5326411 | |||
| c49f279a27 | |||
| 0fb042b740 | |||
| a5706c2fa6 | |||
| 4278b34fe6 | |||
| 0aeee8233b | |||
| 95de28cdda | |||
| 0874a26131 | |||
| 1bfbad2420 | |||
| fe1d8d8840 | |||
| a53a533cc4 | |||
| 4725722b1c | |||
| 9ea3f45886 | |||
| e64cc73f88 | |||
| 63990d6a2d | |||
| 09723bd123 | |||
| c242533f4e | |||
| 507998a4e3 | |||
| 7939f3b009 | |||
| e1d63c0ea0 | |||
| e1a16b4ec6 | |||
| 9569b4550d |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.9",
|
||||
"version": "10.0.10",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
@@ -24,21 +24,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
|
||||
@@ -11,13 +11,13 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -40,14 +40,14 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
format-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ jobs:
|
||||
|
||||
runs-on: "${{ matrix.os }}"
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # v5.5.10
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # v5.5.11
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
reactions: '+1'
|
||||
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
@@ -40,12 +40,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
@@ -10,12 +10,12 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
repository: ${{ inputs.repository }}
|
||||
|
||||
- name: Configure .NET
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
base_ref: ${{ steps.ancestor.outputs.base_ref }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
yq-version: v4.9.8
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
- [whooo](https://github.com/whooo)
|
||||
- [WiiPlayer2](https://github.com/WiiPlayer2)
|
||||
- [WillWill56](https://github.com/WillWill56)
|
||||
- [WizardOfYendor1](https://github.com/WizardOfYendor1)
|
||||
- [wtayl0r](https://github.com/wtayl0r)
|
||||
- [Wuerfelbecher](https://github.com/Wuerfelbecher)
|
||||
- [Wunax](https://github.com/Wunax)
|
||||
@@ -233,6 +234,9 @@
|
||||
- [MSalman5230](https://github.com/MSalman5230)
|
||||
- [dwandw](https://github.com/dwandw)
|
||||
- [Lampan-git](https://github.com/Lampan-git)
|
||||
- [elio42](https://github.com/elio42)
|
||||
- [rwebster85](https://github.com/rwebster85)
|
||||
- [Florin-Popescu](https://github.com/Florin-Popescu)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
+26
-25
@@ -17,7 +17,7 @@
|
||||
<PackageVersion Include="Diacritics" Version="4.1.8" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.4" />
|
||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" />
|
||||
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
||||
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
||||
@@ -26,33 +26,34 @@
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="PDFtoImage" Version="5.2.1" />
|
||||
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||
@@ -67,7 +68,7 @@
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.49.1" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.50.4" />
|
||||
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
||||
@@ -77,7 +78,7 @@
|
||||
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.10" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
|
||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||
|
||||
@@ -57,7 +57,6 @@ namespace Emby.Naming.Common
|
||||
".nrg",
|
||||
".nsv",
|
||||
".nuv",
|
||||
".ogg",
|
||||
".ogm",
|
||||
".ogv",
|
||||
".pva",
|
||||
@@ -362,7 +361,10 @@ namespace Emby.Naming.Common
|
||||
// Not a Kodi rule as well, but the expression below also causes false positives,
|
||||
// so we make sure this one gets tested first.
|
||||
// "Foo Bar 889"
|
||||
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
|
||||
// Names carrying an SxxEyy marker are excluded because the Kodi expression above already covers them.
|
||||
// Without that guard this expression reads digits out of the title instead, turning
|
||||
// "S01E01 1-23-45 [Bluray-1080p]" into episodes 1 through 45.
|
||||
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?![^\\\/]*[Ss][0-9]+[][ ._-]*[Ee][0-9]+)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
|
||||
{
|
||||
IsNamed = true
|
||||
},
|
||||
|
||||
@@ -125,7 +125,7 @@ namespace Emby.Naming.TV
|
||||
result.Success = true;
|
||||
}
|
||||
}
|
||||
else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date))
|
||||
else if (DateTime.TryParse(match.Groups[0].ValueSpan, CultureInfo.InvariantCulture, out date))
|
||||
{
|
||||
result.Year = date.Year;
|
||||
result.Month = date.Month;
|
||||
|
||||
@@ -52,13 +52,18 @@ namespace Emby.Naming.Video
|
||||
while (path.Length > 0)
|
||||
{
|
||||
var index = path.IndexOfAny(delimiters);
|
||||
ReadOnlySpan<char> currentSlice;
|
||||
if (index == -1)
|
||||
{
|
||||
index = path.Length - 1;
|
||||
// No delimiter left, the last token is the remainder of the path
|
||||
currentSlice = path;
|
||||
path = default;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentSlice = path[..index];
|
||||
path = path[(index + 1)..];
|
||||
}
|
||||
|
||||
var currentSlice = path[..index];
|
||||
path = path[(index + 1)..];
|
||||
|
||||
if (!foundPrefix)
|
||||
{
|
||||
|
||||
@@ -39,6 +39,8 @@ using Emby.Server.Implementations.SyncPlay;
|
||||
using Emby.Server.Implementations.TV;
|
||||
using Emby.Server.Implementations.Updates;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Drawing;
|
||||
using Jellyfin.MediaEncoding.Hls.Playlist;
|
||||
using Jellyfin.Networking.Manager;
|
||||
@@ -417,6 +419,8 @@ namespace Emby.Server.Implementations
|
||||
{
|
||||
Logger.LogInformation("Running startup tasks");
|
||||
|
||||
EnsureStartupWizardIntegrity();
|
||||
|
||||
Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false));
|
||||
|
||||
ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated;
|
||||
@@ -436,6 +440,24 @@ namespace Emby.Server.Implementations
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void EnsureStartupWizardIntegrity()
|
||||
{
|
||||
if (ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hasConfiguredAdministrator = Resolve<IUserManager>().GetUsers()
|
||||
.Any(user => user.HasPermission(PermissionKind.IsAdministrator) && !string.IsNullOrEmpty(user.Password));
|
||||
|
||||
if (hasConfiguredAdministrator)
|
||||
{
|
||||
Logger.LogWarning("The startup wizard is marked incomplete but a configured administrator already exists. Marking setup as completed to prevent the unauthenticated setup endpoints from being reachable.");
|
||||
ConfigurationManager.Configuration.IsStartupWizardCompleted = true;
|
||||
ConfigurationManager.SaveConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Init(IServiceCollection serviceCollection)
|
||||
{
|
||||
@@ -965,8 +987,9 @@ namespace Emby.Server.Implementations
|
||||
/// <inheritdoc/>
|
||||
public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null)
|
||||
{
|
||||
// If the smartAPI doesn't start with http then treat it as a host or ip.
|
||||
if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
// If the smartAPI isn't already a complete URL then treat it as a host or ip.
|
||||
if (hostname.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| hostname.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return hostname.TrimEnd('/');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -10,6 +8,9 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Devices
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides the persistent unique identifier of this server installation.
|
||||
/// </summary>
|
||||
public class DeviceId
|
||||
{
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
@@ -18,12 +19,20 @@ namespace Emby.Server.Implementations.Devices
|
||||
|
||||
private string? _id;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeviceId"/> class.
|
||||
/// </summary>
|
||||
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{DeviceId}"/> interface.</param>
|
||||
public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger)
|
||||
{
|
||||
_appPaths = appPaths;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the device id, loading it from disk or generating and persisting a new one if none exists.
|
||||
/// </summary>
|
||||
public string Value => _id ??= GetDeviceId();
|
||||
|
||||
private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt");
|
||||
|
||||
@@ -71,6 +71,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
{
|
||||
BaseItemKind.Person, [
|
||||
BaseItemKind.Audio,
|
||||
BaseItemKind.AudioBook,
|
||||
BaseItemKind.Book,
|
||||
BaseItemKind.Episode,
|
||||
BaseItemKind.Movie,
|
||||
BaseItemKind.LiveTvProgram,
|
||||
@@ -167,9 +169,13 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
// Batch-fetch user data for all items
|
||||
Dictionary<Guid, UserItemData>? userDataBatch = null;
|
||||
IReadOnlyDictionary<Guid, VersionResumeData>? resumeDataBatch = null;
|
||||
if (user is not null && options.EnableUserData)
|
||||
{
|
||||
userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user);
|
||||
|
||||
// For items with alternate versions, the most recently played version drives resume.
|
||||
resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user);
|
||||
}
|
||||
|
||||
// Pre-compute collection folders once to avoid N+1 queries in CanDelete
|
||||
@@ -236,6 +242,29 @@ namespace Emby.Server.Implementations.Dto
|
||||
artistsBatch = _libraryManager.GetArtists(artistNames.ToArray());
|
||||
}
|
||||
|
||||
// Batch-fetch people across all items to avoid one GetPeople query per item.
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null;
|
||||
if (options.ContainsField(ItemFields.People))
|
||||
{
|
||||
var peopleItemIds = accessibleItems.Where(i => i.SupportsPeople).Select(i => i.Id).ToList();
|
||||
if (peopleItemIds.Count > 0)
|
||||
{
|
||||
peopleBatch = _libraryManager.GetPeopleByItems(peopleItemIds);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-detect which videos own alternate versions to avoid the per-item alternate-version
|
||||
// queries in MediaSourceCount. Videos absent from this set have a single media source.
|
||||
IReadOnlySet<Guid>? alternateVersionItemIds = null;
|
||||
if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||
{
|
||||
var versionItemIds = accessibleItems.OfType<Video>().Select(i => i.Id).ToList();
|
||||
if (versionItemIds.Count > 0)
|
||||
{
|
||||
alternateVersionItemIds = _libraryManager.GetItemIdsWithAlternateVersions(versionItemIds);
|
||||
}
|
||||
}
|
||||
|
||||
for (int index = 0; index < accessibleItems.Count; index++)
|
||||
{
|
||||
var item = accessibleItems[index];
|
||||
@@ -248,7 +277,10 @@ namespace Emby.Server.Implementations.Dto
|
||||
allCollectionFolders,
|
||||
childCountBatch,
|
||||
playedCountBatch,
|
||||
artistsBatch);
|
||||
artistsBatch,
|
||||
resumeDataBatch?.GetValueOrDefault(item.Id),
|
||||
peopleBatch,
|
||||
alternateVersionItemIds);
|
||||
|
||||
if (item is LiveTvChannel tvChannel)
|
||||
{
|
||||
@@ -309,7 +341,10 @@ namespace Emby.Server.Implementations.Dto
|
||||
List<Folder>? allCollectionFolders = null,
|
||||
Dictionary<Guid, int>? childCountBatch = null,
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
|
||||
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null,
|
||||
VersionResumeData? resumeData = null,
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null,
|
||||
IReadOnlySet<Guid>? alternateVersionItemIds = null)
|
||||
{
|
||||
var dto = new BaseItemDto
|
||||
{
|
||||
@@ -323,7 +358,15 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.People))
|
||||
{
|
||||
AttachPeople(dto, item, user);
|
||||
IReadOnlyList<PersonInfo>? prefetchedPeople = null;
|
||||
if (peopleBatch is not null)
|
||||
{
|
||||
// The batch omits items with no people, so a miss means "no people",
|
||||
// not "not fetched". Use an empty list to skip the per-item query.
|
||||
prefetchedPeople = peopleBatch.GetValueOrDefault(item.Id) ?? [];
|
||||
}
|
||||
|
||||
AttachPeople(dto, item, user, prefetchedPeople);
|
||||
}
|
||||
|
||||
if (options.ContainsField(ItemFields.PrimaryImageAspectRatio))
|
||||
@@ -353,7 +396,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
options,
|
||||
userData,
|
||||
childCountBatch,
|
||||
playedCountBatch);
|
||||
playedCountBatch,
|
||||
resumeData);
|
||||
}
|
||||
|
||||
if (item is IHasMediaSources
|
||||
@@ -369,7 +413,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
AttachStudios(dto, item);
|
||||
}
|
||||
|
||||
AttachBasicFields(dto, item, owner, options, artistsBatch);
|
||||
AttachBasicFields(dto, item, owner, options, artistsBatch, user, alternateVersionItemIds);
|
||||
|
||||
if (options.ContainsField(ItemFields.CanDelete))
|
||||
{
|
||||
@@ -538,7 +582,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
DtoOptions options,
|
||||
UserItemData? userData = null,
|
||||
Dictionary<Guid, int>? childCountBatch = null,
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||
VersionResumeData? resumeData = null)
|
||||
{
|
||||
if (item.IsFolder)
|
||||
{
|
||||
@@ -600,6 +645,9 @@ namespace Emby.Server.Implementations.Dto
|
||||
// Use pre-fetched user data
|
||||
dto.UserData = GetUserItemDataDto(userData, item.Id);
|
||||
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
|
||||
|
||||
// For items with alternate versions, the most recently played version drives resume.
|
||||
resumeData?.ApplyTo(dto.UserData);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -729,12 +777,18 @@ namespace Emby.Server.Implementations.Dto
|
||||
/// <param name="dto">The dto.</param>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="user">The requesting user.</param>
|
||||
private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null)
|
||||
/// <param name="prefetchedPeople">People fetched in batch by the caller; when null the people are queried per item.</param>
|
||||
private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null, IReadOnlyList<PersonInfo>? prefetchedPeople = null)
|
||||
{
|
||||
// When rendering a page of items the caller batch-fetches people for every item up
|
||||
// front and passes them in, avoiding one GetPeople query per item. Fall back to the
|
||||
// per-item query for the single item path where no batch is available.
|
||||
var source = prefetchedPeople ?? _libraryManager.GetPeople(item);
|
||||
|
||||
// Ordering by person type to ensure actors and artists are at the front.
|
||||
// This is taking advantage of the fact that they both begin with A
|
||||
// This should be improved in the future
|
||||
var people = _libraryManager.GetPeople(item).OrderBy(i => i.SortOrder ?? int.MaxValue)
|
||||
var people = source.OrderBy(i => i.SortOrder ?? int.MaxValue)
|
||||
.ThenBy(i =>
|
||||
{
|
||||
if (i.IsType(PersonKind.Actor))
|
||||
@@ -943,7 +997,9 @@ namespace Emby.Server.Implementations.Dto
|
||||
/// <param name="owner">The owner.</param>
|
||||
/// <param name="options">The options.</param>
|
||||
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
|
||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
|
||||
/// <param name="user">The user, for per-user values such as the accessible media source count.</param>
|
||||
/// <param name="alternateVersionItemIds">Optional pre-fetched set of item IDs that own alternate versions, shared across a batch of items.</param>
|
||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null, IReadOnlySet<Guid>? alternateVersionItemIds = null)
|
||||
{
|
||||
if (options.ContainsField(ItemFields.DateCreated))
|
||||
{
|
||||
@@ -1074,7 +1130,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
dto.ParentId = item.DisplayParentId;
|
||||
}
|
||||
|
||||
AddInheritedImages(dto, item, options, owner);
|
||||
AddInheritedImages(dto, item, options, owner, artistsBatch);
|
||||
|
||||
if (options.ContainsField(ItemFields.Path))
|
||||
{
|
||||
@@ -1257,10 +1313,27 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||
{
|
||||
var mediaSourceCount = video.MediaSourceCount;
|
||||
if (mediaSourceCount != 1)
|
||||
// A video with no primary version and no alternate versions always has a single
|
||||
// media source. Only compute the count for videos that might have more: a primary
|
||||
// version, or membership in the batch's set of items that own alternate versions.
|
||||
// Without the batch we can't rule it out, so fall back to computing (the single-item
|
||||
// path). Everything else is the common case and keeps the default count of one.
|
||||
var mayHaveAlternateVersions = alternateVersionItemIds is null
|
||||
|| video.PrimaryVersionId.HasValue
|
||||
|| alternateVersionItemIds.Contains(video.Id);
|
||||
|
||||
if (mayHaveAlternateVersions)
|
||||
{
|
||||
dto.MediaSourceCount = mediaSourceCount;
|
||||
// Match the per-user filtering of the media sources: versions the user cannot
|
||||
// access are not selectable, so they must not count towards the badge either.
|
||||
var mediaSourceCount = user is null
|
||||
|| (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
|
||||
? video.MediaSourceCount
|
||||
: video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
|
||||
if (mediaSourceCount != 1)
|
||||
{
|
||||
dto.MediaSourceCount = mediaSourceCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1366,38 +1439,22 @@ namespace Emby.Server.Implementations.Dto
|
||||
}
|
||||
}
|
||||
|
||||
if (options.PreferEpisodeParentPoster)
|
||||
if (options.GetImageLimit(ImageType.Primary) > 0)
|
||||
{
|
||||
var episodeSeason = episode.Season;
|
||||
var seasonPrimaryTag = episodeSeason is not null
|
||||
? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
|
||||
: null;
|
||||
|
||||
BaseItem? posterParent = null;
|
||||
if (seasonPrimaryTag is not null)
|
||||
{
|
||||
dto.ParentPrimaryImageItemId = episodeSeason!.Id;
|
||||
dto.ParentPrimaryImageTag = seasonPrimaryTag;
|
||||
posterParent = episodeSeason;
|
||||
}
|
||||
else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
|
||||
{
|
||||
dto.ParentPrimaryImageItemId = episodeSeries.Id;
|
||||
dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag;
|
||||
posterParent = episodeSeries;
|
||||
}
|
||||
|
||||
if (posterParent is not null)
|
||||
{
|
||||
if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag))
|
||||
{
|
||||
// Only drop the episode's own primary blurhash; keep the poster parent's.
|
||||
dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag);
|
||||
}
|
||||
|
||||
dto.SeriesPrimaryImageTag = null;
|
||||
dto.PrimaryImageAspectRatio = null;
|
||||
AttachPrimaryImageAspectRatio(dto, posterParent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1516,11 +1573,11 @@ namespace Emby.Server.Implementations.Dto
|
||||
}
|
||||
}
|
||||
|
||||
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem)
|
||||
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
{
|
||||
if (currentItem is MusicAlbum musicAlbum)
|
||||
{
|
||||
var artist = musicAlbum.GetMusicArtist(new DtoOptions(false));
|
||||
var artist = GetBatchedAlbumArtist(musicAlbum, artistsBatch) ?? musicAlbum.GetMusicArtist(new DtoOptions(false));
|
||||
if (artist is not null)
|
||||
{
|
||||
return artist;
|
||||
@@ -1537,7 +1594,20 @@ namespace Emby.Server.Implementations.Dto
|
||||
return parent;
|
||||
}
|
||||
|
||||
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
|
||||
private static MusicArtist? GetBatchedAlbumArtist(MusicAlbum album, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
{
|
||||
if (artistsBatch is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = album.AlbumArtists.Count > 0 ? album.AlbumArtists[0] : null;
|
||||
return !string.IsNullOrEmpty(name) && artistsBatch.TryGetValue(name, out var artists) && artists.Length > 0
|
||||
? artists[0]
|
||||
: null;
|
||||
}
|
||||
|
||||
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
{
|
||||
if (item is UserView { ViewType: CollectionType.playlists } playlistsView
|
||||
&& options.GetImageLimit(ImageType.Primary) > 0
|
||||
@@ -1582,7 +1652,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|| (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0)
|
||||
|| parent is Series)
|
||||
{
|
||||
parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent;
|
||||
parent ??= isFirst ? GetImageDisplayParent(item, item, artistsBatch) ?? owner : parent;
|
||||
if (parent is null)
|
||||
{
|
||||
break;
|
||||
@@ -1641,7 +1711,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
break;
|
||||
}
|
||||
|
||||
parent = GetImageDisplayParent(parent, item);
|
||||
parent = GetImageDisplayParent(parent, item, artistsBatch);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,8 +127,12 @@ namespace Emby.Server.Implementations.HttpServer
|
||||
{
|
||||
receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (WebSocketException ex)
|
||||
catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException)
|
||||
{
|
||||
// ObjectDisposedException/OperationCanceledException: the socket was torn
|
||||
// down underneath us (e.g. by the keep-alive watchdog after the connection
|
||||
// was declared lost). Fall through so Closed is still raised and the
|
||||
// session can release this connection.
|
||||
_logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -691,7 +691,7 @@ namespace Emby.Server.Implementations.IO
|
||||
}
|
||||
catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to enumerate path {Path}", path);
|
||||
_logger.LogWarning("Failed to enumerate path \"{Path}\": {Message}", path, ex.Message);
|
||||
return Enumerable.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
@@ -14,7 +15,6 @@ using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Querying;
|
||||
|
||||
namespace Emby.Server.Implementations.Images
|
||||
{
|
||||
@@ -28,51 +28,23 @@ namespace Emby.Server.Implementations.Images
|
||||
{
|
||||
var view = (CollectionFolder)item;
|
||||
var viewType = view.CollectionType;
|
||||
|
||||
BaseItemKind[] includeItemTypes;
|
||||
|
||||
switch (viewType)
|
||||
{
|
||||
case CollectionType.movies:
|
||||
includeItemTypes = new[] { BaseItemKind.Movie };
|
||||
break;
|
||||
case CollectionType.tvshows:
|
||||
includeItemTypes = new[] { BaseItemKind.Series };
|
||||
break;
|
||||
case CollectionType.music:
|
||||
includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead
|
||||
break;
|
||||
case CollectionType.musicvideos:
|
||||
includeItemTypes = new[] { BaseItemKind.MusicVideo };
|
||||
break;
|
||||
case CollectionType.books:
|
||||
includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
|
||||
break;
|
||||
case CollectionType.boxsets:
|
||||
includeItemTypes = new[] { BaseItemKind.BoxSet };
|
||||
break;
|
||||
case CollectionType.homevideos:
|
||||
case CollectionType.photos:
|
||||
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo };
|
||||
break;
|
||||
default:
|
||||
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series };
|
||||
break;
|
||||
}
|
||||
|
||||
var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
|
||||
var recursive = viewType != CollectionType.playlists;
|
||||
|
||||
if (viewType == CollectionType.music)
|
||||
{
|
||||
// Music albums usually don't have dedicated backdrops, so use artist instead
|
||||
includeItemTypes = [BaseItemKind.MusicArtist];
|
||||
}
|
||||
|
||||
return view.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
CollapseBoxSetItems = false,
|
||||
Recursive = recursive,
|
||||
DtoOptions = new DtoOptions(false),
|
||||
ImageTypes = new[] { ImageType.Primary },
|
||||
ImageTypes = [ImageType.Primary],
|
||||
Limit = 8,
|
||||
OrderBy = new[]
|
||||
{
|
||||
(ItemSortBy.Random, SortOrder.Ascending)
|
||||
},
|
||||
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)],
|
||||
IncludeItemTypes = includeItemTypes
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Library;
|
||||
using MediaBrowser.Model.Querying;
|
||||
@@ -86,6 +87,7 @@ namespace Emby.Server.Implementations.Library
|
||||
private readonly IPeopleRepository _peopleRepository;
|
||||
private readonly ExtraResolver _extraResolver;
|
||||
private readonly IPathManager _pathManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
|
||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||
private readonly IMediaStreamRepository _mediaStreamRepository;
|
||||
@@ -132,6 +134,7 @@ namespace Emby.Server.Implementations.Library
|
||||
/// <param name="peopleRepository">The people repository.</param>
|
||||
/// <param name="pathManager">The path manager.</param>
|
||||
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||
/// <param name="localization">The localization manager.</param>
|
||||
/// <param name="mediaStreamRepository">The media stream repository.</param>
|
||||
/// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param>
|
||||
public LibraryManager(
|
||||
@@ -157,6 +160,7 @@ namespace Emby.Server.Implementations.Library
|
||||
IPeopleRepository peopleRepository,
|
||||
IPathManager pathManager,
|
||||
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
|
||||
ILocalizationManager localization,
|
||||
IMediaStreamRepository mediaStreamRepository,
|
||||
Lazy<IExternalDataManager> externalDataManagerFactory)
|
||||
{
|
||||
@@ -184,6 +188,7 @@ namespace Emby.Server.Implementations.Library
|
||||
_peopleRepository = peopleRepository;
|
||||
_pathManager = pathManager;
|
||||
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||
_localization = localization;
|
||||
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
|
||||
|
||||
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
|
||||
@@ -407,6 +412,13 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
|
||||
|
||||
// Evict the deleted items from the cache and announce each removal.
|
||||
foreach (var (item, _, _) in pathMaps)
|
||||
{
|
||||
_cache.TryRemove(item.Id, out _);
|
||||
ReportItemRemoved(item, item.GetOwner() ?? item.GetParent());
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem)
|
||||
@@ -606,6 +618,12 @@ namespace Emby.Server.Implementations.Library
|
||||
folder.UserData = null;
|
||||
}
|
||||
|
||||
// Announce the descendants before the item itself.
|
||||
foreach (var child in children)
|
||||
{
|
||||
ReportItemRemoved(child, item);
|
||||
}
|
||||
|
||||
ReportItemRemoved(item, parent);
|
||||
}
|
||||
|
||||
@@ -2229,6 +2247,12 @@ namespace Emby.Server.Implementations.Library
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds)
|
||||
{
|
||||
return _linkedChildrenService.GetItemIdsWithAlternateVersions(itemIds);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType)
|
||||
{
|
||||
@@ -2315,9 +2339,13 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
var comparer = Comparers.FirstOrDefault(c => name == c.Type);
|
||||
|
||||
// If it requires a user, create a new one, and assign the user
|
||||
if (comparer is IUserBaseItemComparer)
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
throw new ArgumentException($"Sort key '{name}' requires a user, but none was provided.");
|
||||
}
|
||||
|
||||
var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable<T> instances
|
||||
|
||||
userComparer.User = user;
|
||||
@@ -2367,6 +2395,7 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
altVideo.OwnerId = video.Id;
|
||||
altVideo.SetPrimaryVersionId(video.Id);
|
||||
altVideo.IsInMixedFolder = video.IsInMixedFolder;
|
||||
// ResolveAlternateVersion only sees the alternate's primary file.
|
||||
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
|
||||
// detect its parts from sibling files so its AdditionalParts persist.
|
||||
@@ -2552,6 +2581,8 @@ namespace Emby.Server.Implementations.Library
|
||||
item.DateLastSaved = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
ForgetDroppedLocalAlternateVersions(items);
|
||||
|
||||
// Resolve and add any local alternate version items that don't exist yet
|
||||
// This ensures they exist in the database when LinkedChildren are processed
|
||||
var allItems = new List<BaseItem>(items);
|
||||
@@ -2580,6 +2611,7 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
altVideo.OwnerId = video.Id;
|
||||
altVideo.SetPrimaryVersionId(video.Id);
|
||||
altVideo.IsInMixedFolder = video.IsInMixedFolder;
|
||||
// ResolveAlternateVersion only sees the alternate's primary file.
|
||||
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
|
||||
// detect its parts from sibling files so its AdditionalParts persist.
|
||||
@@ -2640,6 +2672,30 @@ namespace Emby.Server.Implementations.Library
|
||||
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Forgets the cached local alternate versions of the supplied items that they no longer list.
|
||||
/// </summary>
|
||||
/// <param name="items">The items about to be saved.</param>
|
||||
private void ForgetDroppedLocalAlternateVersions(IReadOnlyList<BaseItem> items)
|
||||
{
|
||||
foreach (var video in items.OfType<Video>())
|
||||
{
|
||||
var videoType = video.GetType();
|
||||
var keptIds = video.LocalAlternateVersions
|
||||
.Where(path => !string.IsNullOrEmpty(path))
|
||||
.Select(path => GetNewItemId(path, videoType))
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var versionId in GetLocalAlternateVersionIds(video))
|
||||
{
|
||||
if (!keptIds.Contains(versionId))
|
||||
{
|
||||
_cache.TryRemove(versionId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -3195,11 +3251,11 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
if (!episode.ProductionYear.HasValue)
|
||||
if (episode.ProductionYear is null)
|
||||
{
|
||||
episode.ProductionYear = episodeInfo.Year;
|
||||
|
||||
if (episode.ProductionYear.HasValue)
|
||||
if (episode.ProductionYear is not null)
|
||||
{
|
||||
changed = true;
|
||||
}
|
||||
@@ -3276,9 +3332,11 @@ namespace Emby.Server.Implementations.Library
|
||||
var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath);
|
||||
if (ownerVideoInfo is null)
|
||||
{
|
||||
yield break;
|
||||
return [];
|
||||
}
|
||||
|
||||
var candidates = new List<ExtraCandidate>();
|
||||
|
||||
var count = filtered.Count;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
@@ -3292,35 +3350,50 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
foreach (var file in filesInSubFolderList)
|
||||
{
|
||||
if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType))
|
||||
if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType, out var extraRule))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var extra = GetExtra(file, extraType.Value, subFolderIsMixedFolder);
|
||||
if (extra is not null)
|
||||
{
|
||||
yield return extra;
|
||||
}
|
||||
AddCandidate(file, extraType.Value, extraRule, subFolderIsMixedFolder);
|
||||
}
|
||||
}
|
||||
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType))
|
||||
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType, out var extraRule))
|
||||
{
|
||||
var extra = GetExtra(current, extraType.Value, false);
|
||||
if (extra is not null)
|
||||
{
|
||||
yield return extra;
|
||||
}
|
||||
AddCandidate(current, extraType.Value, extraRule, false);
|
||||
}
|
||||
}
|
||||
|
||||
BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder)
|
||||
var extras = new List<BaseItem>();
|
||||
var typeCounters = new Dictionary<ExtraType, int>();
|
||||
|
||||
// Order by path so that the numbering handed out below does not depend on the
|
||||
// order the file system happened to list the folder in
|
||||
foreach (var candidate in candidates.OrderBy(c => c.Extra.Path, StringComparer.Ordinal))
|
||||
{
|
||||
var extra = PrepareExtra(candidate);
|
||||
if (extra is not null)
|
||||
{
|
||||
extras.Add(extra);
|
||||
}
|
||||
}
|
||||
|
||||
return extras;
|
||||
|
||||
void AddCandidate(FileSystemMetadata file, ExtraType extraType, ExtraRule extraRule, bool isInMixedFolder)
|
||||
{
|
||||
var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType));
|
||||
if (extra is not Video && extra is not Audio)
|
||||
if (extra is Video or Audio)
|
||||
{
|
||||
return null;
|
||||
candidates.Add(new ExtraCandidate(extra, extraType, extraRule, isInMixedFolder));
|
||||
}
|
||||
}
|
||||
|
||||
BaseItem? PrepareExtra(ExtraCandidate candidate)
|
||||
{
|
||||
var resolved = candidate.Extra;
|
||||
var extra = resolved;
|
||||
var name = GetExtraName(candidate, ownerVideoInfo, typeCounters);
|
||||
|
||||
// Try to retrieve it from the db. If we don't find it, use the resolved version
|
||||
var itemById = GetItemById(extra.Id);
|
||||
@@ -3329,10 +3402,18 @@ namespace Emby.Server.Implementations.Library
|
||||
extra = itemById;
|
||||
}
|
||||
|
||||
// Only update extra type if it is more specific then the currently known extra type
|
||||
if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown)
|
||||
// An extra is named after its file, so the file is the source of truth. Items created
|
||||
// by older versions, or renamed by a metadata provider, are corrected here;
|
||||
// RefreshExtras persists the change.
|
||||
if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true)
|
||||
{
|
||||
extra.ExtraType = extraType;
|
||||
extra.Name = name;
|
||||
}
|
||||
|
||||
// Only update extra type if it is more specific then the currently known extra type
|
||||
if (extra.ExtraType is null or ExtraType.Unknown || candidate.ExtraType != ExtraType.Unknown)
|
||||
{
|
||||
extra.ExtraType = candidate.ExtraType;
|
||||
}
|
||||
|
||||
// Only return items that are actual extras (have ExtraType set)
|
||||
@@ -3340,7 +3421,7 @@ namespace Emby.Server.Implementations.Library
|
||||
// so that RefreshExtras can detect when they need updating and set ForceSave.
|
||||
if (extra.ExtraType is not null)
|
||||
{
|
||||
extra.IsInMixedFolder = isInMixedFolder;
|
||||
extra.IsInMixedFolder = candidate.IsInMixedFolder;
|
||||
return extra;
|
||||
}
|
||||
|
||||
@@ -3348,6 +3429,57 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name to give an extra.
|
||||
/// </summary>
|
||||
/// <param name="candidate">The resolved extra.</param>
|
||||
/// <param name="ownerVideoInfo">The naming info of the owner.</param>
|
||||
/// <param name="typeCounters">Number of extras named after their type so far, per type.</param>
|
||||
/// <returns>The name.</returns>
|
||||
private string GetExtraName(ExtraCandidate candidate, VideoFileInfo ownerVideoInfo, Dictionary<ExtraType, int> typeCounters)
|
||||
{
|
||||
var isNamedAfterOwner = candidate.ExtraRule.RuleType switch
|
||||
{
|
||||
ExtraRuleType.Filename => true,
|
||||
ExtraRuleType.Suffix => string.Equals(candidate.Extra.Name, ownerVideoInfo.Name, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (!isNamedAfterOwner)
|
||||
{
|
||||
return candidate.Extra.Name;
|
||||
}
|
||||
|
||||
typeCounters.TryGetValue(candidate.ExtraType, out var seen);
|
||||
typeCounters[candidate.ExtraType] = seen + 1;
|
||||
|
||||
var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType));
|
||||
|
||||
return seen == 0
|
||||
? typeName
|
||||
: string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localization.GetServerLocalizedString("NameExtraNumbered"),
|
||||
typeName,
|
||||
seen + 1);
|
||||
}
|
||||
|
||||
private static string GetExtraTypeNameKey(ExtraType extraType) => extraType switch
|
||||
{
|
||||
ExtraType.Clip => "NameExtraClip",
|
||||
ExtraType.Trailer => "NameExtraTrailer",
|
||||
ExtraType.BehindTheScenes => "NameExtraBehindTheScenes",
|
||||
ExtraType.DeletedScene => "NameExtraDeletedScene",
|
||||
ExtraType.Interview => "NameExtraInterview",
|
||||
ExtraType.Scene => "NameExtraScene",
|
||||
ExtraType.Sample => "NameExtraSample",
|
||||
ExtraType.ThemeSong => "NameExtraThemeSong",
|
||||
ExtraType.ThemeVideo => "NameExtraThemeVideo",
|
||||
ExtraType.Featurette => "NameExtraFeaturette",
|
||||
ExtraType.Short => "NameExtraShort",
|
||||
_ => "NameExtraUnknown"
|
||||
};
|
||||
|
||||
public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem)
|
||||
{
|
||||
foreach (var map in _configurationManager.Configuration.PathSubstitutions)
|
||||
@@ -3424,6 +3556,12 @@ namespace Emby.Server.Implementations.Library
|
||||
return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds)
|
||||
{
|
||||
return _peopleRepository.GetPeopleByItems(itemIds);
|
||||
}
|
||||
|
||||
public void UpdatePeople(BaseItem item, List<PersonInfo> people)
|
||||
{
|
||||
UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult();
|
||||
@@ -3886,5 +4024,19 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query)
|
||||
{
|
||||
if (query.User is not null)
|
||||
{
|
||||
AddUserToQuery(query, query.User);
|
||||
}
|
||||
|
||||
SetTopParentOrAncestorIds(query);
|
||||
return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType);
|
||||
}
|
||||
|
||||
private sealed record ExtraCandidate(BaseItem Extra, ExtraType ExtraType, ExtraRule ExtraRule, bool IsInMixedFolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +229,11 @@ namespace Emby.Server.Implementations.Library
|
||||
list.Add(source);
|
||||
}
|
||||
|
||||
return SortMediaSources(list, item.Id).ToArray();
|
||||
var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId)
|
||||
? topSourceId
|
||||
: item.Id;
|
||||
|
||||
return SortMediaSources(list, preferredId).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />>
|
||||
@@ -406,6 +410,59 @@ namespace Emby.Server.Implementations.Library
|
||||
source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing);
|
||||
}
|
||||
}
|
||||
|
||||
sources = SetAlternateVersionResumeStates(item, sources, user);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the queried item is a primary, moves the most recently played version to the front so
|
||||
/// that resuming without an explicit source selection plays the version that was last watched.
|
||||
/// A directly queried alternate version keeps its own source first. Per-user playback position
|
||||
/// is not surfaced on the source itself; it is carried by each version's own UserData.
|
||||
/// </summary>
|
||||
/// <param name="item">The queried item.</param>
|
||||
/// <param name="sources">The item's media sources.</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>The media sources, reordered when a version drives resume.</returns>
|
||||
private IReadOnlyList<MediaSourceInfo> SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList<MediaSourceInfo> sources, User user)
|
||||
{
|
||||
// For a video, multiple sources means alternate versions.
|
||||
if (item is not Video video || sources.Count < 2)
|
||||
{
|
||||
return sources;
|
||||
}
|
||||
|
||||
var versions = video.GetAllVersions();
|
||||
if (versions.Count < 2)
|
||||
{
|
||||
return sources;
|
||||
}
|
||||
|
||||
var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
|
||||
var dataBySourceId = new Dictionary<string, UserItemData>(versions.Count, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var version in versions)
|
||||
{
|
||||
if (userDataByVersion.TryGetValue(version.Id, out var data))
|
||||
{
|
||||
dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data;
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder only for a resumable (in-progress) version;
|
||||
// a completed version has no position to resume, so it must not be pulled to the front here.
|
||||
var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||
sources,
|
||||
source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null,
|
||||
data => data.PlaybackPositionTicks > 0);
|
||||
|
||||
if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource))
|
||||
{
|
||||
var reordered = new List<MediaSourceInfo>(sources.Count) { resumeSource };
|
||||
reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource)));
|
||||
return reordered;
|
||||
}
|
||||
|
||||
return sources;
|
||||
|
||||
@@ -29,17 +29,41 @@ namespace Emby.Server.Implementations.Library
|
||||
throw new ArgumentException("String can't be empty.", nameof(attribute));
|
||||
}
|
||||
|
||||
var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Must be at least 3 characters after the attribute =, ], any character,
|
||||
// then we offset it by 1, because we want the index and not length.
|
||||
var maxIndex = str.Length - attribute.Length - 2;
|
||||
while (attributeIndex > -1 && attributeIndex < maxIndex)
|
||||
// Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc.
|
||||
// The code below only supports aliases for attributes in the form of "<alias>id".
|
||||
ReadOnlySpan<char> shortAttr = attribute switch
|
||||
{
|
||||
var attributeEnd = attributeIndex + attribute.Length;
|
||||
_ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb",
|
||||
_ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
|
||||
_ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
|
||||
_ => ReadOnlySpan<char>.Empty
|
||||
};
|
||||
|
||||
for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;)
|
||||
{
|
||||
// We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'.
|
||||
var subStr = str[strIndex..];
|
||||
int attributeEnd = 0;
|
||||
|
||||
if (shortAttr.Length > 0)
|
||||
{
|
||||
// If we are using an alias it should be shorter (and a prefix), so let's search for that.
|
||||
attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase);
|
||||
attributeEnd = attributeIndex + shortAttr.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
|
||||
attributeEnd = attributeIndex + attribute.Length;
|
||||
}
|
||||
|
||||
// The next iteration should start at the end of the attribute we just found.
|
||||
// If attributeIndex < 0, the loop will end and strIndex won't be used again.
|
||||
strIndex += attributeEnd;
|
||||
|
||||
if (attributeIndex > 0)
|
||||
{
|
||||
var attributeOpener = str[attributeIndex - 1];
|
||||
var attributeOpener = subStr[attributeIndex - 1];
|
||||
var attributeCloser = attributeOpener switch
|
||||
{
|
||||
'[' => ']',
|
||||
@@ -47,20 +71,37 @@ namespace Emby.Server.Implementations.Library
|
||||
'{' => '}',
|
||||
_ => '\0'
|
||||
};
|
||||
if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
|
||||
{
|
||||
var closingIndex = str[attributeEnd..].IndexOf(attributeCloser);
|
||||
|
||||
// Must be at least 1 character before the closing bracket.
|
||||
if (closingIndex > 1)
|
||||
if (attributeCloser != '\0')
|
||||
{
|
||||
if (shortAttr.Length > 0
|
||||
&& attributeEnd + 1 < subStr.Length
|
||||
&& (subStr[attributeEnd] is 'i' or 'I')
|
||||
&& (subStr[attributeEnd + 1] is 'd' or 'D'))
|
||||
{
|
||||
return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
|
||||
// We were searching for a shortened attribute, but it's followed by "id" - let's skip it.
|
||||
attributeEnd += 2;
|
||||
}
|
||||
|
||||
// attributeEnd points at '='.
|
||||
// We need at least 1 more character and the closing bracket after that.
|
||||
if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-'))
|
||||
{
|
||||
var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser);
|
||||
|
||||
// Must be at least 1 character before the closing bracket.
|
||||
if (closingIndex > 1)
|
||||
{
|
||||
var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim();
|
||||
|
||||
if (trimmed.Length > 0)
|
||||
{
|
||||
return trimmed.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
str = str[attributeEnd..];
|
||||
attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// for imdbid we also accept pattern matching
|
||||
@@ -70,16 +111,6 @@ namespace Emby.Server.Implementations.Library
|
||||
return match ? imdbId.ToString() : null;
|
||||
}
|
||||
|
||||
// Allow tmdb as an alias for tmdbid
|
||||
if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var tmdbValue = str.GetAttributeValue("tmdb");
|
||||
if (tmdbValue is not null)
|
||||
{
|
||||
return tmdbValue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
@@ -43,7 +44,19 @@ public class PathManager : IPathManager
|
||||
public string? GetAttachmentPath(string mediaSourceId, string fileName)
|
||||
{
|
||||
var folder = GetAttachmentFolderPath(mediaSourceId);
|
||||
return folder is null ? null : Path.Combine(folder, fileName);
|
||||
if (folder is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var safeName = PathHelper.GetSafeLeafFileName(fileName);
|
||||
if (safeName is null)
|
||||
{
|
||||
_logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.Combine(folder, safeName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -32,8 +32,8 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
: base(logger, namingOptions, directoryService)
|
||||
{
|
||||
_namingOptions = namingOptions;
|
||||
_trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService) };
|
||||
_videoResolvers = new IItemResolver[] { this };
|
||||
_trailerResolvers = [new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService, parseName: true)];
|
||||
_videoResolvers = [this];
|
||||
}
|
||||
|
||||
protected override Video Resolve(ItemResolveArgs args)
|
||||
@@ -54,12 +54,13 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
_ => _videoResolvers
|
||||
};
|
||||
|
||||
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "")
|
||||
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, [NotNullWhen(true)] out ExtraRule? extraRule, string? libraryRoot = "")
|
||||
{
|
||||
var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot);
|
||||
if (extraResult.ExtraType is null)
|
||||
if (extraResult.ExtraType is null || extraResult.Rule is null)
|
||||
{
|
||||
extraType = null;
|
||||
extraRule = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -88,6 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
}
|
||||
|
||||
extraType = extraResult.ExtraType;
|
||||
extraRule = extraResult.Rule;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using Emby.Naming.Common;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -14,15 +15,25 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
public class GenericVideoResolver<T> : BaseVideoResolver<T>
|
||||
where T : Video, new()
|
||||
{
|
||||
private readonly bool _parseName;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="namingOptions">The naming options.</param>
|
||||
/// <param name="directoryService">The directory service.</param>
|
||||
public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService)
|
||||
/// <param name="parseName">Whether to parse the file name for metadata such as the year.</param>
|
||||
public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService, bool parseName = false)
|
||||
: base(logger, namingOptions, directoryService)
|
||||
{
|
||||
_parseName = parseName;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override T Resolve(ItemResolveArgs args)
|
||||
{
|
||||
return ResolveVideo<T>(args, _parseName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,15 +376,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
||||
// 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 tmdbid = justName.GetAttributeValue("tmdbid");
|
||||
// The fallback filename is only used when the item isn't in a mixed folder
|
||||
var fileName = item.IsInMixedFolder ? ReadOnlySpan<char>.Empty : Path.GetFileName(item.Path.AsSpan());
|
||||
|
||||
// If not in a mixed folder and ID not found in folder path, check filename
|
||||
if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid"));
|
||||
item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid"));
|
||||
|
||||
string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute)
|
||||
{
|
||||
tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
|
||||
}
|
||||
var id = name.GetAttributeValue(attribute);
|
||||
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
|
||||
// If not in a mixed folder and ID not found in folder path, check filename
|
||||
if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder)
|
||||
{
|
||||
id = fallbackName.GetAttributeValue(attribute);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(item.Path))
|
||||
{
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Emby.Server.Implementations.Playlists;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -45,8 +47,30 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
};
|
||||
}
|
||||
|
||||
// Anything directly inside the internal playlists folder is a playlist, even when its
|
||||
// playlist.xml is missing: failing to resolve here makes the library scan treat the
|
||||
// playlist as deleted from disk and remove it, taking its items with it.
|
||||
if (args.Parent is PlaylistsFolder)
|
||||
{
|
||||
return new Playlist
|
||||
{
|
||||
Path = args.Path,
|
||||
Name = filename,
|
||||
OpenAccess = true
|
||||
};
|
||||
}
|
||||
|
||||
// It's a directory-based playlist if the directory contains a playlist file
|
||||
var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
|
||||
IEnumerable<string> filePaths;
|
||||
try
|
||||
{
|
||||
filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return new Playlist
|
||||
|
||||
@@ -92,49 +92,46 @@ public class SearchManager : ISearchManager
|
||||
await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
|
||||
|
||||
var externalResults = await externalTask.ConfigureAwait(false);
|
||||
var fromExternal = externalResults.Count > 0;
|
||||
IReadOnlyList<SearchResult> results;
|
||||
if (fromExternal)
|
||||
{
|
||||
results = externalResults;
|
||||
}
|
||||
else
|
||||
{
|
||||
results = await internalTask.ConfigureAwait(false);
|
||||
if (_internalProviders.Length > 0)
|
||||
{
|
||||
_logger.LogDebug("No results from external providers, using internal provider results");
|
||||
}
|
||||
}
|
||||
|
||||
// Internal providers apply user-access filtering inline in their queries. External
|
||||
// providers don't know about user permissions, so they may return IDs from hidden
|
||||
// libraries or items the user is otherwise blocked from. Run the post-filter only
|
||||
// when results came from externals to close that gap. The Items controller's second
|
||||
// roundtrip via folder.GetItems applies most of these again, but it does not restrict
|
||||
// by TopParentIds when ItemIds is set.
|
||||
if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
|
||||
// libraries or items the user is otherwise blocked from. Filter them here to close
|
||||
// that gap. The Items controller's second roundtrip via folder.GetItems applies most
|
||||
// of these again, but it does not restrict by TopParentIds when ItemIds is set.
|
||||
if (externalResults.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(query.UserId.Value);
|
||||
if (user is not null)
|
||||
{
|
||||
results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
|
||||
externalResults = await FilterByUserAccessAsync(externalResults, user, query, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
if (externalResults.Count > 0)
|
||||
{
|
||||
return externalResults;
|
||||
}
|
||||
|
||||
var internalResults = await internalTask.ConfigureAwait(false);
|
||||
if (_internalProviders.Length > 0)
|
||||
{
|
||||
_logger.LogDebug("No results from external providers, using internal provider results");
|
||||
}
|
||||
|
||||
return internalResults;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
||||
IReadOnlyList<SearchResult> candidates,
|
||||
User user,
|
||||
SearchProviderQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
|
||||
// TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
|
||||
// because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
|
||||
var accessFilter = new InternalItemsQuery(user);
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
// SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds
|
||||
// for the user's accessible libraries. The candidate ids are applied to the query below
|
||||
// rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when
|
||||
// ItemIds is non-empty.
|
||||
var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager);
|
||||
|
||||
Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the access filter that decides which items a search may return for a user.
|
||||
/// </summary>
|
||||
internal static class SearchQueryAccessFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds an access filter carrying the search's library access and type filters.
|
||||
/// </summary>
|
||||
/// <param name="user">The user the search runs for.</param>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <returns>The access filter.</returns>
|
||||
public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager)
|
||||
{
|
||||
// The type filters have to travel with the access filter: a by-name item belongs to no
|
||||
// library, so it carries no TopParentId to match, and the library filter only knows to
|
||||
// exempt it when the query says those types are wanted. A search scoped to a parent gets
|
||||
// no exemption because a by-name item has no parent to descend from either.
|
||||
var accessFilter = new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = query.IncludeItemTypes,
|
||||
ExcludeItemTypes = query.ExcludeItemTypes,
|
||||
IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty()
|
||||
};
|
||||
|
||||
// ConfigureUserAccess populates TopParentIds for the libraries the user may open.
|
||||
libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
|
||||
return accessFilter;
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ public class SqlSearchProvider : IInternalSearchProvider
|
||||
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
|
||||
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
|
||||
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
|
||||
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);
|
||||
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query);
|
||||
|
||||
// Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
|
||||
// the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
|
||||
@@ -196,8 +196,9 @@ public class SqlSearchProvider : IInternalSearchProvider
|
||||
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
|
||||
JellyfinDbContext dbContext,
|
||||
IQueryable<BaseItemEntity> query,
|
||||
Guid? userId)
|
||||
SearchProviderQuery searchQuery)
|
||||
{
|
||||
var userId = searchQuery.UserId;
|
||||
if (!userId.HasValue || userId.Value.IsEmpty())
|
||||
{
|
||||
return query;
|
||||
@@ -209,8 +210,7 @@ public class SqlSearchProvider : IInternalSearchProvider
|
||||
return query;
|
||||
}
|
||||
|
||||
var accessFilter = new InternalItemsQuery(user);
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager);
|
||||
return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
|
||||
@@ -60,14 +61,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="queryHelpers">The shared query helpers.</param>
|
||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
public MovieSimilarItemsProvider(
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemQueryHelpers queryHelpers,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_dbProvider = dbProvider;
|
||||
_queryHelpers = queryHelpers;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -156,6 +160,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
||||
IsPlayed = false
|
||||
};
|
||||
|
||||
if (query.User is not null)
|
||||
{
|
||||
_libraryManager.ConfigureUserAccess(filter, query.User);
|
||||
}
|
||||
|
||||
_queryHelpers.PrepareFilterQuery(filter);
|
||||
var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
|
||||
baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
|
||||
|
||||
@@ -183,6 +183,7 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
// Collect references in batches and resolve against local library.
|
||||
// Stop fetching once we have enough resolved local items.
|
||||
const int BatchSize = 20;
|
||||
const int MaxRemoteReferenceFetchLimit = 500;
|
||||
var remaining = requestedLimit - allResults.Count;
|
||||
var collectedReferences = new List<SimilarItemReference>();
|
||||
var pendingBatch = new List<SimilarItemReference>();
|
||||
@@ -199,7 +200,7 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
remaining -= resolvedItems.Count;
|
||||
pendingBatch.Clear();
|
||||
|
||||
if (remaining <= 0)
|
||||
if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
else
|
||||
{
|
||||
var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault();
|
||||
var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
||||
var userData = userDataRow is not null ? Map(userDataRow) : null;
|
||||
if (userData is not null)
|
||||
{
|
||||
result[item.Id] = userData;
|
||||
@@ -211,36 +212,128 @@ namespace Emby.Server.Implementations.Library
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build a single query for all missing items
|
||||
// Build a single query for all missing items. Fetch rows by item alone so rows kept
|
||||
// under keys from older metadata resolve the same way as the in-memory path.
|
||||
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
|
||||
var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList();
|
||||
if (allKeys.Count > 0)
|
||||
using var context = _repository.CreateDbContext();
|
||||
var userDataArray = context.UserData
|
||||
.AsNoTracking()
|
||||
.Where(e => e.UserId.Equals(user.Id))
|
||||
.WhereOneOrMany(allItemIds, e => e.ItemId)
|
||||
.ToArray();
|
||||
|
||||
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
|
||||
foreach (var (item, keys) in itemsNeedingQuery)
|
||||
{
|
||||
using var context = _repository.CreateDbContext();
|
||||
var userDataArray = context.UserData
|
||||
.AsNoTracking()
|
||||
.Where(e => e.UserId.Equals(user.Id))
|
||||
.WhereOneOrMany(allItemIds, e => e.ItemId)
|
||||
.WhereOneOrMany(allKeys, e => e.CustomDataKey)
|
||||
.ToArray();
|
||||
|
||||
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
|
||||
foreach (var (item, keys) in itemsNeedingQuery)
|
||||
UserItemData userData;
|
||||
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
|
||||
{
|
||||
UserItemData userData;
|
||||
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
|
||||
{
|
||||
var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N"));
|
||||
userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First());
|
||||
}
|
||||
else
|
||||
{
|
||||
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
|
||||
}
|
||||
userData = Map(ResolveUserDataRow(item, itemUserData)!);
|
||||
}
|
||||
else
|
||||
{
|
||||
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
|
||||
}
|
||||
|
||||
result[item.Id] = userData;
|
||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||
_cache.AddOrUpdate(cacheKey, userData);
|
||||
result[item.Id] = userData;
|
||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||
_cache.AddOrUpdate(cacheKey, userData);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public VersionResumeData? GetResumeUserData(User user, BaseItem item)
|
||||
{
|
||||
return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
var result = new Dictionary<Guid, VersionResumeData>();
|
||||
|
||||
// Candidate primaries: a directly queried version (PrimaryVersionId set) keeps its own data.
|
||||
// Linked alternates are already known in memory; only the local-alternate existence check
|
||||
// would otherwise hit the database (one query per item via Video.HasLocalAlternateVersions),
|
||||
// so collect those ids and resolve them all in a single query below.
|
||||
List<Video>? candidates = null;
|
||||
List<Guid>? localProbeIds = null;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is not Video video || video.PrimaryVersionId.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(candidates ??= []).Add(video);
|
||||
|
||||
if (video.LinkedAlternateVersions.Length == 0)
|
||||
{
|
||||
(localProbeIds ??= []).Add(video.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
HashSet<Guid>? withLocalAlternates = null;
|
||||
if (localProbeIds is not null)
|
||||
{
|
||||
using var dbContext = _repository.CreateDbContext();
|
||||
withLocalAlternates = dbContext.LinkedChildren
|
||||
.Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion)
|
||||
.WhereOneOrMany(localProbeIds, lc => lc.ParentId)
|
||||
.Select(lc => lc.ParentId)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
List<(Guid PrimaryId, IReadOnlyList<Video> Versions)>? versionGroups = null;
|
||||
List<BaseItem>? allVersions = null;
|
||||
|
||||
foreach (var video in candidates)
|
||||
{
|
||||
// Only items that actually have alternate versions aggregate over them.
|
||||
if (video.LinkedAlternateVersions.Length == 0
|
||||
&& (withLocalAlternates is null || !withLocalAlternates.Contains(video.Id)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var versions = video.GetAllVersions();
|
||||
if (versions.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(versionGroups ??= []).Add((video.Id, versions));
|
||||
(allVersions ??= []).AddRange(versions);
|
||||
}
|
||||
|
||||
if (versionGroups is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var userDataByVersion = GetUserDataBatch(allVersions!.DistinctBy(i => i.Id).ToList(), user);
|
||||
|
||||
foreach (var (primaryId, versions) in versionGroups)
|
||||
{
|
||||
// Consider both in-progress and completed versions so a finished alternate still marks the primary as played.
|
||||
var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||
versions,
|
||||
version => userDataByVersion.GetValueOrDefault(version.Id),
|
||||
data => data.PlaybackPositionTicks > 0 || data.Played);
|
||||
|
||||
if (resumeVersion is not null)
|
||||
{
|
||||
result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,12 +352,41 @@ namespace Emby.Server.Implementations.Library
|
||||
/// <inheritdoc />
|
||||
public UserItemData? GetUserData(User user, BaseItem item)
|
||||
{
|
||||
return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
||||
return row is not null ? Map(row) : new UserItemData()
|
||||
{
|
||||
Key = item.GetUserDataKeys()[0],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the row matching the item's current user data keys, in key order, so rows left behind
|
||||
/// under keys from older metadata don't take priority over the rows the write path updates.
|
||||
/// </summary>
|
||||
/// <param name="item">The item whose keys to match.</param>
|
||||
/// <param name="rows">The candidate user data rows for a single user.</param>
|
||||
/// <returns>The best matching row, or <c>null</c> when there are none.</returns>
|
||||
private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows)
|
||||
{
|
||||
var candidates = rows?.ToList();
|
||||
if (candidates is null || candidates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in item.GetUserDataKeys())
|
||||
{
|
||||
var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal));
|
||||
if (match is not null)
|
||||
{
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
|
||||
=> GetUserDataDto(item, null, user, new DtoOptions());
|
||||
@@ -281,6 +403,10 @@ namespace Emby.Server.Implementations.Library
|
||||
var dto = GetUserItemDataDto(userData, item.Id);
|
||||
|
||||
item.FillUserDataDtoValues(dto, userData, itemDto, user, options);
|
||||
|
||||
// For an item with alternate versions, surface the most recently played version's resume point.
|
||||
GetResumeUserData(user, item)?.ApplyTo(dto);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -385,5 +511,41 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
return playedToCompletion;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetPlaybackStreamSelections(User user, BaseItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
|
||||
using var dbContext = _repository.CreateDbContext();
|
||||
var rows = dbContext.UserData
|
||||
.Where(e => e.ItemId == item.Id && e.UserId == user.Id
|
||||
&& (e.AudioStreamIndex != null || e.SubtitleStreamIndex != null))
|
||||
.ToList();
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
row.AudioStreamIndex = null;
|
||||
row.SubtitleStreamIndex = null;
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
|
||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||
if (_cache.TryGet(cacheKey, out var cached))
|
||||
{
|
||||
cached.AudioStreamIndex = null;
|
||||
cached.SubtitleStreamIndex = null;
|
||||
_cache.AddOrUpdate(cacheKey, cached);
|
||||
}
|
||||
|
||||
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures top-level library folders have a primary poster after scans.
|
||||
/// Poster extraction is attempted before library scanning. When a library is
|
||||
/// empty at that point, no poster can be extracted. This post-scan task reruns
|
||||
/// metadata extraction for top-level folders that are still missing images.
|
||||
/// </summary>
|
||||
public class CollectionPosterVerifyPostScanTask : ILibraryPostScanTask
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<CollectionPosterVerifyPostScanTask> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CollectionPosterVerifyPostScanTask" /> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public CollectionPosterVerifyPostScanTask(
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<CollectionPosterVerifyPostScanTask> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the specified progress.
|
||||
/// </summary>
|
||||
/// <param name="progress">The progress.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var libraries = _libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().ToList();
|
||||
var totalLibraries = libraries.Count;
|
||||
var processedLibraries = 0;
|
||||
|
||||
foreach (var library in libraries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!library.HasImage(ImageType.Primary))
|
||||
{
|
||||
_logger.LogDebug("Library {LibraryName} is missing a primary image. Refreshing metadata.", library.Name);
|
||||
await library.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
processedLibraries++;
|
||||
progress.Report((double)processedLibraries / totalLibraries * 100);
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@
|
||||
"TaskAudioNormalization": "تطبيع الصوت",
|
||||
"TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.",
|
||||
"TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة",
|
||||
"TaskDownloadMissingLyricsDescription": "ينزّل الكلمات للأغاني.",
|
||||
"TaskDownloadMissingLyricsDescription": "تحميل كلمات الأغاني",
|
||||
"TaskExtractMediaSegments": "فحص مقاطع المحتوى",
|
||||
"TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).",
|
||||
"TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل",
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
|
||||
"CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.",
|
||||
"Original": "فريد",
|
||||
"LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}"
|
||||
"LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}",
|
||||
"NameExtraBehindTheScenes": "خلف المشاهد",
|
||||
"NameExtraClip": "مقطع",
|
||||
"NameExtraDeletedScene": "المشهد المحذوف",
|
||||
"NameExtraInterview": "مقابلة",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "عيّنة",
|
||||
"NameExtraScene": "مشهد",
|
||||
"NameExtraShort": "قصير",
|
||||
"NameExtraThemeSong": "الاغنية السمة",
|
||||
"NameExtraThemeVideo": "الفيديو السمة",
|
||||
"NameExtraFeaturette": "فيلم قصير إضافي",
|
||||
"NameExtraTrailer": "إعلان ترويجي",
|
||||
"NameExtraUnknown": "إضافي"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.",
|
||||
"CleanupUserDataTask": "Tasca de neteja de dades d'usuari",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}"
|
||||
"LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}",
|
||||
"NameExtraBehindTheScenes": "Rere les càmeres",
|
||||
"NameExtraClip": "Tall",
|
||||
"NameExtraDeletedScene": "Escena eliminada",
|
||||
"NameExtraFeaturette": "Migmetratge",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Mostra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Curt",
|
||||
"NameExtraThemeSong": "Tema musical",
|
||||
"NameExtraThemeVideo": "Vídeo temàtic",
|
||||
"NameExtraTrailer": "Tràiler",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.",
|
||||
"CleanupUserDataTask": "Pročistit uživatelská data",
|
||||
"Original": "Originál",
|
||||
"LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}"
|
||||
"LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}",
|
||||
"NameExtraBehindTheScenes": "Zákulisí",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Vymazaná scéna",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Rozhovor",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Ukázka",
|
||||
"NameExtraScene": "Scéna",
|
||||
"NameExtraShort": "Krátké",
|
||||
"NameExtraThemeSong": "Úvodní píseň",
|
||||
"NameExtraThemeVideo": "Úvodní video",
|
||||
"NameExtraTrailer": "Upoutávka",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"Favorites": "Favoritter",
|
||||
"Folders": "Mapper",
|
||||
"Genres": "Genrer",
|
||||
"HeaderContinueWatching": "Fortsæt afspilning",
|
||||
"HeaderContinueWatching": "Fortsæt med at se",
|
||||
"HeaderFavoriteEpisodes": "Yndlingsafsnit",
|
||||
"HeaderFavoriteShows": "Yndlingsserier",
|
||||
"HeaderLiveTV": "Live-TV",
|
||||
@@ -65,7 +65,7 @@
|
||||
"TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.",
|
||||
"TaskDownloadMissingSubtitles": "Hent manglende undertekster",
|
||||
"TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.",
|
||||
"TaskUpdatePlugins": "Opdater plugins",
|
||||
"TaskUpdatePlugins": "Opdatér plugins",
|
||||
"TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.",
|
||||
"TaskCleanLogs": "Ryd log-mappe",
|
||||
"TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.",
|
||||
@@ -79,10 +79,10 @@
|
||||
"TaskRefreshChapterImages": "Udtræk kapitelbilleder",
|
||||
"TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.",
|
||||
"TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.",
|
||||
"TaskRefreshChannels": "Opdater kanaler",
|
||||
"TaskRefreshChannels": "Opdatér kanaler",
|
||||
"TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.",
|
||||
"TaskCleanTranscode": "Tøm omkodningsmappen",
|
||||
"TaskRefreshPeople": "Opdater personer",
|
||||
"TaskRefreshPeople": "Opdatér personer",
|
||||
"TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.",
|
||||
"TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.",
|
||||
"TaskCleanActivityLog": "Ryd aktivitetslog",
|
||||
@@ -90,7 +90,7 @@
|
||||
"Forced": "Tvunget",
|
||||
"Default": "Standard",
|
||||
"TaskOptimizeDatabaseDescription": "Komprimerer databasen for at frigøre plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen.",
|
||||
"TaskOptimizeDatabase": "Optimer database",
|
||||
"TaskOptimizeDatabase": "Optimér database",
|
||||
"TaskKeyframeExtractorDescription": "Udtrækker rammer fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.",
|
||||
"TaskKeyframeExtractor": "Udtræk nøglerammer",
|
||||
"External": "Ekstern",
|
||||
@@ -99,7 +99,7 @@
|
||||
"TaskRefreshTrickplayImagesDescription": "Laver trickplay-billeder for videoer i aktiverede biblioteker.",
|
||||
"TaskAudioNormalizationDescription": "Skanner filer for data vedrørende lydnormalisering.",
|
||||
"TaskAudioNormalization": "Lydnormalisering",
|
||||
"TaskDownloadMissingLyricsDescription": "Søger på internettet efter manglende sangtekster baseret på metadata-konfigurationen",
|
||||
"TaskDownloadMissingLyricsDescription": "Download sangtekster",
|
||||
"TaskDownloadMissingLyrics": "Hent manglende sangtekster",
|
||||
"TaskExtractMediaSegments": "Scan for mediesegmenter",
|
||||
"TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder",
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Brugerdata oprydningsopgave",
|
||||
"CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage.",
|
||||
"LyricDownloadFailureFromForItem": "Sangtekster kunne ikke downloades fra {0} til {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Bag Scenerne",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Slettet Scene",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Interview",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Smagsprøve",
|
||||
"NameExtraScene": "Scene",
|
||||
"NameExtraShort": "Kort",
|
||||
"NameExtraThemeSong": "Tema Sang",
|
||||
"NameExtraThemeVideo": "Tema Video",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Ekstra"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten",
|
||||
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}"
|
||||
"LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}",
|
||||
"NameExtraBehindTheScenes": "Behind The Scenes",
|
||||
"NameExtraDeletedScene": "Entfernte Szene",
|
||||
"NameExtraInterview": "Interview",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Ausschnitt",
|
||||
"NameExtraScene": "Szene",
|
||||
"NameExtraShort": "Kurzfilm",
|
||||
"NameExtraThemeSong": "Titellied",
|
||||
"NameExtraThemeVideo": "Titelvideo",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraFeaturette": "Hinter den Kulissen"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,19 @@
|
||||
"Movies": "Movies",
|
||||
"Music": "Music",
|
||||
"MusicVideos": "Music Videos",
|
||||
"NameExtraBehindTheScenes": "Behind The Scenes",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Deleted Scene",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Interview",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Sample",
|
||||
"NameExtraScene": "Scene",
|
||||
"NameExtraShort": "Short",
|
||||
"NameExtraThemeSong": "Theme Song",
|
||||
"NameExtraThemeVideo": "Theme Video",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameInstallFailed": "{0} installation failed",
|
||||
"NameSeasonNumber": "Season {0}",
|
||||
"NameSeasonUnknown": "Season Unknown",
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"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",
|
||||
"LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Detrás de cámaras",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Escena eliminada",
|
||||
"NameExtraFeaturette": "Minidocumental",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Muestra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Cortometraje",
|
||||
"NameExtraThemeSong": "Música de presentación",
|
||||
"NameExtraThemeVideo": "Video de presentación",
|
||||
"NameExtraTrailer": "Tráiler",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,20 @@
|
||||
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.",
|
||||
"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."
|
||||
"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.",
|
||||
"LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
|
||||
"NameExtraBehindTheScenes": "Detrás de cámaras",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Escena eliminada",
|
||||
"NameExtraFeaturette": "Minidocumental",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Muestra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Cortometraje",
|
||||
"NameExtraThemeSong": "Música de presentación",
|
||||
"NameExtraThemeVideo": "Video de presentación",
|
||||
"NameExtraTrailer": "Tráiler",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Tarea de limpieza de datos del usuario",
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}"
|
||||
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}",
|
||||
"NameExtraBehindTheScenes": "Detrás de Cámaras",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Escena eliminada",
|
||||
"NameExtraFeaturette": "Reportaje especial",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraSample": "Muestra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Cortometraje",
|
||||
"NameExtraThemeSong": "Tema principal",
|
||||
"NameExtraThemeVideo": "Vídeo del tema principal",
|
||||
"NameExtraTrailer": "Tráiler",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraNumbered": "{0} {1}"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,20 @@
|
||||
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
|
||||
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
||||
"CleanupUserDataTask": "Tarea de limpieza de datos de usuario",
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días."
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
|
||||
"LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}",
|
||||
"Original": "Original",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraBehindTheScenes": "Detrás de cámaras",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Escena eliminada",
|
||||
"NameExtraFeaturette": "Minidocumental",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Muestra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Cortometraje",
|
||||
"NameExtraThemeSong": "Música de presentación",
|
||||
"NameExtraThemeVideo": "Video de presentación",
|
||||
"NameExtraTrailer": "Tráiler"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Puhasta kasutajaandmed",
|
||||
"CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud.",
|
||||
"LyricDownloadFailureFromForItem": "Laulusõnade hankimine teenusest {0} loole {1} nurjus",
|
||||
"Original": "Algne"
|
||||
"Original": "Algne",
|
||||
"NameExtraBehindTheScenes": "Kulisside taga",
|
||||
"NameExtraDeletedScene": "Väljajäetud stseen",
|
||||
"NameExtraFeaturette": "Lisalõik",
|
||||
"NameExtraInterview": "Intervjuu",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Näidis",
|
||||
"NameExtraScene": "Stseen",
|
||||
"NameExtraShort": "Lühifilm",
|
||||
"NameExtraThemeSong": "Tunnusmeloodia",
|
||||
"NameExtraThemeVideo": "Tunnusvideo",
|
||||
"NameExtraTrailer": "Treiler",
|
||||
"NameExtraUnknown": "Lisamaterjal",
|
||||
"NameExtraClip": "Videoklipp"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,11 @@
|
||||
"CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.",
|
||||
"CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina",
|
||||
"LyricDownloadFailureFromForItem": "Ezin izan dira {1}-ren letrak deskargatu {0}-tik",
|
||||
"Original": "Jatorrizkoa"
|
||||
"Original": "Jatorrizkoa",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Lagina",
|
||||
"NameExtraScene": "Eszena",
|
||||
"NameExtraShort": "Laburra",
|
||||
"NameExtraThemeSong": "Gai-abestia",
|
||||
"NameExtraThemeVideo": "Gai-bideoa"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Artists": "Listafólk",
|
||||
"Artists": "Tónlistafólk",
|
||||
"Collections": "Søvn",
|
||||
"Default": "Sjálvgildi",
|
||||
"Default": "Forsett",
|
||||
"External": "Ytri",
|
||||
"Genres": "Greinar",
|
||||
"AppDeviceValues": "App: {0}, Eind: {1}",
|
||||
@@ -12,5 +12,104 @@
|
||||
"Forced": "Kravt",
|
||||
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
|
||||
"HeaderFavoriteEpisodes": "Yndispartar",
|
||||
"LabelIpAddressValue": "IP atsetur: {0}"
|
||||
"LabelIpAddressValue": "IP-atsetur: {0}",
|
||||
"AuthenticationSucceededWithUserName": "{0} varð samgildur",
|
||||
"HeaderFavoriteShows": "Yndisrøðir",
|
||||
"HeaderLiveTV": "Beinleiðis sjónvarp",
|
||||
"HearingImpaired": "Hoyrnarveik",
|
||||
"Inherit": "Arvar",
|
||||
"LabelRunningTimeValue": "Spælitíð: {0}",
|
||||
"Latest": "Seinastu",
|
||||
"LyricDownloadFailureFromForItem": "Miseydnaðist at niðurtakað sangtekst fyri {1} frá {0}",
|
||||
"NameInstallFailed": "{0} innlegging miseydnaðist",
|
||||
"NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.",
|
||||
"NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt",
|
||||
"NotificationOptionPluginInstalled": "Ískoytisforrit innlagt",
|
||||
"NotificationOptionPluginUninstalled": "Ískoytisforrit strikað",
|
||||
"NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført",
|
||||
"NotificationOptionUserLockedOut": "Brúkari útihýstur",
|
||||
"Photos": "Ljósmyndir",
|
||||
"PluginInstalledWithName": "{0} innlagt",
|
||||
"PluginUninstalledWithName": "{0} strikað",
|
||||
"PluginUpdatedWithName": "{0} dagført",
|
||||
"Shows": "Røðir",
|
||||
"SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}",
|
||||
"TvShows": "Sjónvarpsrøðir",
|
||||
"UserCreatedWithName": "Brúkari {0} er stovnaður",
|
||||
"UserDeletedWithName": "Brúkari {0} er strikaður",
|
||||
"UserDownloadingItemWithValues": "{0} niðurtekur {1}",
|
||||
"UserLockedOutWithName": "Brúkari {0} er útihýstur",
|
||||
"VersionNumber": "Útgáva {0}",
|
||||
"TasksLibraryCategory": "Savn",
|
||||
"TaskRefreshLibrary": "Skanna miðlasavn",
|
||||
"TaskCleanLogsDescription": "Strikar gerðalistafílur eldri enn {0} dagar.",
|
||||
"TaskUpdatePlugins": "Dagfør ískoytisforrit",
|
||||
"TaskRefreshChannels": "Endurinnles rásir",
|
||||
"TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir",
|
||||
"Movies": "Filmar",
|
||||
"MixedContent": "Blandað innihald",
|
||||
"Music": "Tónleikur",
|
||||
"UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}",
|
||||
"HeaderContinueWatching": "Hald áfram at hyggja",
|
||||
"MusicVideos": "Sjónbandaløg",
|
||||
"TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.",
|
||||
"TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.",
|
||||
"TaskOptimizeDatabase": "Albøt dátugrunn",
|
||||
"NameSeasonNumber": "Sesong {0}",
|
||||
"NameSeasonUnknown": "Ókend sesong",
|
||||
"ScheduledTaskFailedWithName": "{0} miseydnaðist",
|
||||
"Undefined": "Óskilmarkað",
|
||||
"TasksMaintenanceCategory": "Viðlíkahald",
|
||||
"TaskCleanLogs": "Reinsa gerðalistaskjáttu",
|
||||
"UserOnlineFromDevice": "{0} er íbundin frá {1}",
|
||||
"HeaderNextUp": "Næst á skránni",
|
||||
"NotificationOptionPluginError": "Brek í ískoytisforriti",
|
||||
"NotificationOptionInstallationFailed": "Innleggingarbrek",
|
||||
"NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan",
|
||||
"TasksApplicationCategory": "Nýtsluskipan",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Skipanardagføring varð innløgd",
|
||||
"UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}",
|
||||
"HomeVideos": "Heimaupptøkur",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.",
|
||||
"UserOfflineFromDevice": "{0} breyt av á {1}",
|
||||
"UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt",
|
||||
"TasksChannelsCategory": "Alnetsrásir",
|
||||
"TaskCleanActivityLog": "Reinsa virksemisskrá",
|
||||
"TaskCleanActivityLogDescription": "Strikar skrásetingar eldri enn ásetta aldur.",
|
||||
"TaskCleanCache": "Reinsa kovaskjáttu",
|
||||
"TaskCleanCacheDescription": "Strikar kovafílar ið kervið ikki hevur tørv á longur.",
|
||||
"TaskCleanTranscode": "Reinsa umkotuskjáttu",
|
||||
"TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir",
|
||||
"TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir",
|
||||
"CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.",
|
||||
"CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur",
|
||||
"TaskRefreshPeople": "Dagfør persónsupplýsingar",
|
||||
"TaskRefreshPeopleDescription": "Dagførur metadátur um leikarar og leikstjórar í tínum margmiðlasavni.",
|
||||
"TaskRefreshChannelsDescription": "Dagførur upplýsingar um alnetsrásir.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Leitar á alnótini eftir vantandi undirtekstum grundað á metadátauppsetan.",
|
||||
"NotificationOptionTaskFailed": "Brek undir fyriskipaðari koyrslu",
|
||||
"TaskRefreshLibraryDescription": "Skannar títt miðlasavn fyri nýggjum fílum og dagførur metadátur.",
|
||||
"TaskKeyframeExtractor": "Lyklamyndaúttøka",
|
||||
"TaskKeyframeExtractorDescription": "Úttekur lyklamyndir frá kykmynda-fílum til tess at byggja nágreiniligari HLS-spælilistar. Koyrslan kann taka langa tíð.",
|
||||
"TaskOptimizeDatabaseDescription": "Trýstur dátugrunninin saman og loysur tóma goymslu. Koyrslan kann bøta um avrikið, eftir skanning ella aðrar broytingar í savninum ið elva til dátugrunnsbroytingar.",
|
||||
"TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.",
|
||||
"TaskRefreshChapterImages": "Kapitlamyndaúttøkur",
|
||||
"NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað",
|
||||
"NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað",
|
||||
"NotificationOptionAudioPlayback": "Ljóðspæl byrjað",
|
||||
"NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað",
|
||||
"TaskExtractMediaSegments": "Leita eftir margmiðlabrotum",
|
||||
"TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.",
|
||||
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
|
||||
"NameExtraShort": "Stuttfilmur",
|
||||
"NameExtraThemeSong": "Eyðkennislag",
|
||||
"NameExtraTrailer": "Forfilmur",
|
||||
"NameExtraInterview": "Samrøða",
|
||||
"NameExtraBehindTheScenes": "Aftanfyri leiktjøldini",
|
||||
"NameExtraClip": "Klipp",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraFeaturette": "Stuttur heimildarfilmur",
|
||||
"TaskAudioNormalization": "Ljóðjavnan",
|
||||
"TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan."
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
|
||||
"CleanupUserDataTask": "Tâche de nettoyage des données utilisateur",
|
||||
"LyricDownloadFailureFromForItem": "Le téléchargement des paroles a échoué de {0} pour {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Dans Les Coulisses",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Scène supprimée",
|
||||
"NameExtraFeaturette": "Court-métrage",
|
||||
"NameExtraInterview": "Entrevue",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Échantillon",
|
||||
"NameExtraScene": "Scène",
|
||||
"NameExtraShort": "Court-métrage",
|
||||
"NameExtraThemeSong": "Chanson thème",
|
||||
"NameExtraThemeVideo": "Générique",
|
||||
"NameExtraTrailer": "Bande-annonce",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
|
||||
"CleanupUserDataTask": "Tâche de nettoyage des données utilisateur",
|
||||
"LyricDownloadFailureFromForItem": "Le téléchargement des paroles à échoué de {0} pour {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Dans Les Coulisses",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Scène supprimée",
|
||||
"NameExtraFeaturette": "Court-métrage",
|
||||
"NameExtraInterview": "Entrevue",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Échantillon",
|
||||
"NameExtraScene": "Scène",
|
||||
"NameExtraShort": "Court-métrage",
|
||||
"NameExtraThemeSong": "Thème musical",
|
||||
"NameExtraThemeVideo": "Générique",
|
||||
"NameExtraTrailer": "Bande-annonce",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.",
|
||||
"TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.",
|
||||
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
|
||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש"
|
||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש",
|
||||
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
|
||||
"Original": "מקור"
|
||||
}
|
||||
|
||||
@@ -105,5 +105,6 @@
|
||||
"TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।",
|
||||
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
|
||||
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
|
||||
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य"
|
||||
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य",
|
||||
"Original": "असली"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke 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.",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,17 @@
|
||||
"CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.",
|
||||
"CleanupUserDataTask": "Felhasználói adatok tisztítása feladat",
|
||||
"Original": "Eredeti",
|
||||
"LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen"
|
||||
"LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen",
|
||||
"NameExtraBehindTheScenes": "Színfalak mögött",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Törölt jelenet",
|
||||
"NameExtraFeaturette": "Kísérő film",
|
||||
"NameExtraInterview": "Interjú",
|
||||
"NameExtraSample": "Minta",
|
||||
"NameExtraScene": "Jelenet",
|
||||
"NameExtraShort": "Rövidfilm",
|
||||
"NameExtraThemeSong": "Főcímdal",
|
||||
"NameExtraThemeVideo": "Főcímvideó",
|
||||
"NameExtraTrailer": "Előzetes",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"UserDownloadingItemWithValues": "{0} hleður niður {1}",
|
||||
"SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}",
|
||||
"Shows": "Þættir",
|
||||
"TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.",
|
||||
"TaskRefreshChannelsDescription": "Endurhleður upplýsingum netrása.",
|
||||
"TaskRefreshChannels": "Endurhlaða Rásir",
|
||||
"TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.",
|
||||
"TaskCleanTranscode": "Hreinsa Umkóðunarmöppu",
|
||||
@@ -80,7 +80,7 @@
|
||||
"TasksMaintenanceCategory": "Viðhald",
|
||||
"Default": "Sjálfgefið",
|
||||
"TaskCleanActivityLog": "Hreinsa athafnaskrá",
|
||||
"TaskRefreshPeople": "Endurnýja fólk",
|
||||
"TaskRefreshPeople": "Endurnýja upplýsingar um fólk",
|
||||
"TaskDownloadMissingSubtitles": "Sækja texta sem vantar",
|
||||
"TaskOptimizeDatabase": "Fínstilla gagnagrunn",
|
||||
"Undefined": "Óskilgreint",
|
||||
@@ -95,13 +95,31 @@
|
||||
"TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.",
|
||||
"Forced": "Þvingað",
|
||||
"External": "Útvær",
|
||||
"TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir fyrir myndbönd í virkum söfnum.",
|
||||
"TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir",
|
||||
"TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir (Trickplay) fyrir myndbönd í virkum söfnum.",
|
||||
"TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir (Trickplay)",
|
||||
"TaskAudioNormalization": "Hljóðstöðlun",
|
||||
"TaskAudioNormalizationDescription": "Leitar að hljóðstöðlunargögnum í skrám.",
|
||||
"TaskDownloadMissingLyricsDescription": "Sækja söngtexta fyrir lög",
|
||||
"TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar",
|
||||
"TaskExtractMediaSegments": "Skönnun efnishluta",
|
||||
"CleanupUserDataTask": "Hreinsun notendagagna",
|
||||
"CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga."
|
||||
"CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga.",
|
||||
"LyricDownloadFailureFromForItem": "Ekki tókst að niðurhala texta frá {0} fyrir {1}",
|
||||
"Original": "Upprunaleg",
|
||||
"TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt.",
|
||||
"TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir",
|
||||
"TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins.",
|
||||
"NameExtraBehindTheScenes": "Bak við tjöldin",
|
||||
"NameExtraClip": "Brot",
|
||||
"NameExtraDeletedScene": "Eydd atriði",
|
||||
"NameExtraFeaturette": "Stutt heimildarmynd",
|
||||
"NameExtraInterview": "Viðtal",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Sýnishorn",
|
||||
"NameExtraScene": "Sena",
|
||||
"NameExtraShort": "Stuttmynd",
|
||||
"NameExtraThemeSong": "Þema lag",
|
||||
"NameExtraThemeVideo": "Þema myndband",
|
||||
"NameExtraTrailer": "Stikla",
|
||||
"NameExtraUnknown": "Aukaefni"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,15 @@
|
||||
"CleanupUserDataTask": "Task di pulizia dei dati utente",
|
||||
"CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.",
|
||||
"Original": "Originale",
|
||||
"LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}"
|
||||
"LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}",
|
||||
"NameExtraBehindTheScenes": "Dietro le scene",
|
||||
"NameExtraClip": "Filmato",
|
||||
"NameExtraDeletedScene": "Scena eliminata",
|
||||
"NameExtraInterview": "Intervista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraScene": "Scena",
|
||||
"NameExtraSample": "Campione",
|
||||
"NameExtraShort": "Corto",
|
||||
"NameExtraThemeSong": "Sigla musicale",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskDownloadMissingLyrics": "失われた歌詞をダウンロード",
|
||||
"TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。",
|
||||
"CleanupUserDataTask": "ユーザーデータのクリーンアップタスク",
|
||||
"CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。"
|
||||
"CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。",
|
||||
"LyricDownloadFailureFromForItem": "歌詞",
|
||||
"Original": "オリジナル"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,8 @@
|
||||
"CleanupUserDataTask": "사용자 데이터 정리 작업",
|
||||
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.",
|
||||
"LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
|
||||
"Original": "원본"
|
||||
"Original": "원본",
|
||||
"NameExtraClip": "클립",
|
||||
"NameExtraDeletedScene": "삭제된 장면",
|
||||
"NameExtraInterview": "인터뷰"
|
||||
}
|
||||
|
||||
@@ -104,5 +104,9 @@
|
||||
"TaskKeyframeExtractorDescription": "Extrahéiert Schlësselbiller aus Videodateien, fir méi präzis HLS-Playlisten ze erstellen. Dës Aufgab kann eng längere Zäit daueren.",
|
||||
"TaskRefreshChannelsDescription": "Aktualiséiert Informatiounen iwwer Internetkanäl.",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrahéiert oder kritt Mediesegmenter aus Plugins, déi MediaSegment ënnerstëtzen.",
|
||||
"TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren."
|
||||
"TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren.",
|
||||
"LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}",
|
||||
"Original": "Original",
|
||||
"CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten",
|
||||
"CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn."
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
"Artists": "Atlikėjai",
|
||||
"AuthenticationSucceededWithUserName": "{0} sėkmingai autentifikuota",
|
||||
"Books": "Knygos",
|
||||
"ChapterNameValue": "Scena{0}",
|
||||
"Collections": "Rinkiniai",
|
||||
"ChapterNameValue": "Skyrius{0}",
|
||||
"Collections": "Kolekcijos",
|
||||
"FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti",
|
||||
"Favorites": "Mėgstami",
|
||||
"Folders": "Katalogai",
|
||||
"Folders": "Aplankai",
|
||||
"Genres": "Žanrai",
|
||||
"HeaderContinueWatching": "Žiūrėti toliau",
|
||||
"HeaderFavoriteEpisodes": "Mėgstamiausios serijos",
|
||||
"HeaderFavoriteShows": "Mėgstamiausios TV Laidos",
|
||||
"HeaderFavoriteEpisodes": "Mėgstami Epizodai",
|
||||
"HeaderFavoriteShows": "Mėgstamos TV Laidos",
|
||||
"HeaderLiveTV": "Tiesioginė TV",
|
||||
"HeaderNextUp": "Toliau",
|
||||
"HomeVideos": "Namų vaizdo įrašai",
|
||||
@@ -26,14 +26,14 @@
|
||||
"NameInstallFailed": "{0} diegimo klaida",
|
||||
"NameSeasonNumber": "Sezonas {0}",
|
||||
"NameSeasonUnknown": "Sezonas neatpažintas",
|
||||
"NewVersionIsAvailable": "Nauja \"Jellyfin Server\" versija yra prieinama atsisiuntimui.",
|
||||
"NewVersionIsAvailable": "Nauja Jellyfin Server versija yra prieinama atsisiuntimui.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Galimi programos atnaujinimai",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Programos atnaujinimai įdiegti",
|
||||
"NotificationOptionAudioPlayback": "Garso atkūrimas pradėtas",
|
||||
"NotificationOptionAudioPlaybackStopped": "Garso atkūrimas sustabdytas",
|
||||
"NotificationOptionCameraImageUploaded": "Kameros vaizdai įkelti",
|
||||
"NotificationOptionCameraImageUploaded": "Kameros atvaizdai įkelti",
|
||||
"NotificationOptionInstallationFailed": "Diegimo klaida",
|
||||
"NotificationOptionNewLibraryContent": "Naujas turinys įkeltas",
|
||||
"NotificationOptionNewLibraryContent": "Pridėtas naujas turinys",
|
||||
"NotificationOptionPluginError": "Įskiepio klaida",
|
||||
"NotificationOptionPluginInstalled": "Įskiepis įdiegtas",
|
||||
"NotificationOptionPluginUninstalled": "Įskiepis išdiegtas",
|
||||
@@ -51,7 +51,7 @@
|
||||
"Shows": "Laidos",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.",
|
||||
"SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}",
|
||||
"TvShows": "TV laidos",
|
||||
"TvShows": "TV Laidos",
|
||||
"UserCreatedWithName": "Buvo sukurtas {0} naudotojas",
|
||||
"UserDeletedWithName": "Naudotojas {0} ištrintas",
|
||||
"UserDownloadingItemWithValues": "{0} siunčiasi {1}",
|
||||
@@ -65,8 +65,8 @@
|
||||
"TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.",
|
||||
"TaskUpdatePlugins": "Atnaujinti įskieius",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.",
|
||||
"TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.",
|
||||
"TaskCleanTranscode": "Išvalyti perkodavimo katalogą",
|
||||
"TaskCleanTranscodeDescription": "Ištrina dienos senumo transkodavimo failus.",
|
||||
"TaskCleanTranscode": "Išvalyti transkodavimo katalogą",
|
||||
"TaskRefreshLibraryDescription": "Skenuoja medijos biblioteką, ieškodamas naujų failų, ir atnaujina metaduomenis.",
|
||||
"TaskRefreshLibrary": "Skenuoti medijos biblioteką",
|
||||
"TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus",
|
||||
@@ -76,8 +76,8 @@
|
||||
"TaskRefreshPeople": "Atnaujinti žmones",
|
||||
"TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.",
|
||||
"TaskCleanLogs": "Išvalyti žurnalą",
|
||||
"TaskRefreshChapterImagesDescription": "Sukuria vaizdo įrašų, kuriuose yra skyrių, miniatiūras.",
|
||||
"TaskRefreshChapterImages": "Ištraukti skyrių vaizdus",
|
||||
"TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašams, kuriuose yra skyriai.",
|
||||
"TaskRefreshChapterImages": "Ištraukti skyrių atvaizdus",
|
||||
"TaskCleanCache": "Išvalyti talpyklą",
|
||||
"TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.",
|
||||
"TasksChannelsCategory": "Internetiniai kanalai",
|
||||
@@ -96,15 +96,30 @@
|
||||
"External": "Išorinis",
|
||||
"HearingImpaired": "Su klausos sutrikimais",
|
||||
"TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus",
|
||||
"TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.",
|
||||
"TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įgalintose bibliotekose, Trickplay peržiūras.",
|
||||
"TaskAudioNormalization": "Garso normalizavimas",
|
||||
"TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
|
||||
"TaskExtractMediaSegments": "Medijos segmentų nuskaitymas",
|
||||
"TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus",
|
||||
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
|
||||
"TaskMoveTrickplayImages": "Pakeisti Trickplay vaizdų vietą",
|
||||
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius trickplay failus pagal bibliotekos nustatymus.",
|
||||
"TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą",
|
||||
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.",
|
||||
"TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius",
|
||||
"CleanupUserDataTask": "Naudotojo duomenų valymo užduotis",
|
||||
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamiausią būseną ir t. t.)."
|
||||
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).",
|
||||
"LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}",
|
||||
"NameExtraBehindTheScenes": "Užkulisiuose",
|
||||
"NameExtraClip": "Klipas",
|
||||
"NameExtraDeletedScene": "Ištrinta scena",
|
||||
"NameExtraInterview": "Interviu",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Pavyzdys",
|
||||
"NameExtraScene": "Scena",
|
||||
"NameExtraThemeSong": "Teminė daina",
|
||||
"NameExtraThemeVideo": "Teminis vaizdo įrašas",
|
||||
"NameExtraTrailer": "Anonsas",
|
||||
"NameExtraUnknown": "Papildomas",
|
||||
"Original": "Originalus",
|
||||
"NameExtraFeaturette": "Trumpametražis filmas",
|
||||
"NameExtraShort": "Trumpas filmukas"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām",
|
||||
"CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums",
|
||||
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.",
|
||||
"Original": "Oriģināls"
|
||||
"Original": "Oriģināls",
|
||||
"LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Opruimtaak gebruikersdata",
|
||||
"Genres": "Genres",
|
||||
"Original": "Oorspronkelijk",
|
||||
"LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt"
|
||||
"LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt",
|
||||
"NameExtraBehindTheScenes": "Achter de schermen",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Geschrapte scène",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Vraaggesprek",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Voorbeeldfragment",
|
||||
"NameExtraScene": "Scène",
|
||||
"NameExtraShort": "Korte film",
|
||||
"NameExtraThemeSong": "Themamuziek",
|
||||
"NameExtraThemeVideo": "Themavideo",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra inhoud"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
{
|
||||
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}"
|
||||
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}",
|
||||
"Books": "Libres",
|
||||
"Artists": "Artistas",
|
||||
"Collections": "Collecciones",
|
||||
"ChapterNameValue": "Capitol {0}",
|
||||
"External": "Extèrn",
|
||||
"Folders": "Dorsièrs",
|
||||
"Favorites": "Favorits",
|
||||
"HeaderContinueWatching": "Contunhar de regardar",
|
||||
"HeaderFavoriteEpisodes": "Episòdis Favorits",
|
||||
"AuthenticationSucceededWithUserName": "{0} autentificat amb succès",
|
||||
"HeaderFavoriteShows": "Serias Favoritas",
|
||||
"HeaderLiveTV": "TV en dirècte",
|
||||
"HeaderNextUp": "Seguent",
|
||||
"HearingImpaired": "Amb de deficiéncias auditivas",
|
||||
"Movies": "Filmes",
|
||||
"Music": "Musica",
|
||||
"Latest": "Darrièr",
|
||||
"Forced": "Forçat",
|
||||
"Default": "Defaut",
|
||||
"Genres": "Genres",
|
||||
"HomeVideos": "Vidèos d'Acuèlh",
|
||||
"Inherit": "Eiretar",
|
||||
"LabelIpAddressValue": "Adreça IP: {0}",
|
||||
"LabelRunningTimeValue": "Temps d'execucion : {0}"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.",
|
||||
"CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika",
|
||||
"Original": "Oryginalny",
|
||||
"LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}"
|
||||
"LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}",
|
||||
"NameExtraBehindTheScenes": "Za kulisami",
|
||||
"NameExtraClip": "Urywek",
|
||||
"NameExtraDeletedScene": "Usunięta scena",
|
||||
"NameExtraFeaturette": "Film średniometrażowy",
|
||||
"NameExtraInterview": "Wywiad",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Fragment",
|
||||
"NameExtraScene": "Scena",
|
||||
"NameExtraShort": "Film krótkometrażowy",
|
||||
"NameExtraThemeSong": "Czołówka",
|
||||
"NameExtraThemeVideo": "Wideo wprowadzające",
|
||||
"NameExtraTrailer": "Zwiastun",
|
||||
"NameExtraUnknown": "Dodatek"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,17 @@
|
||||
"CleanupUserDataTask": "Tarefa de limpeza de dados do usuário",
|
||||
"CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias.",
|
||||
"LyricDownloadFailureFromForItem": "Download das Letras falharam em {0} para o item {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Nos Bastidores",
|
||||
"NameExtraClip": "Clipe",
|
||||
"NameExtraDeletedScene": "cena Extra",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Trecho",
|
||||
"NameExtraScene": "Cena",
|
||||
"NameExtraShort": "Curta-metragem",
|
||||
"NameExtraThemeSong": "Música Tema",
|
||||
"NameExtraThemeVideo": "Vídeo de Abertura",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraFeaturette": "Nos Bastidores"
|
||||
}
|
||||
|
||||
@@ -95,18 +95,30 @@
|
||||
"HearingImpaired": "Problemas auditivos",
|
||||
"TaskKeyframeExtractor": "Extrator de quadro-chave",
|
||||
"TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.",
|
||||
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
|
||||
"TaskRefreshTrickplayImages": "Gerar imagens de Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria miniaturas de pré-visualização (Trickplay) para vídeos nas bibliotecas ativadas.",
|
||||
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
|
||||
"TaskAudioNormalization": "Normalização de áudio",
|
||||
"TaskDownloadMissingLyrics": "Transferir letra em falta",
|
||||
"TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros Trickplay existentes de acordo com as definições da mediateca.",
|
||||
"TaskExtractMediaSegments": "Analisar segmentos de multimédia",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
|
||||
"TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
|
||||
"TaskMoveTrickplayImages": "Migrar a localização das imagens de Trickplay",
|
||||
"CleanupUserDataTask": "Task de limpeza de dados do usuário",
|
||||
"CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias.",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
|
||||
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}",
|
||||
"NameExtraBehindTheScenes": "Bastidores",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Cena Eliminada",
|
||||
"NameExtraFeaturette": "Média-metragem",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraSample": "Amostra",
|
||||
"NameExtraShort": "Curta-metragem",
|
||||
"NameExtraThemeSong": "Tema Principal",
|
||||
"NameExtraThemeVideo": "Vídeo de Abertura",
|
||||
"NameExtraScene": "Cena",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,16 @@
|
||||
"CleanupUserDataTask": "Задача очистки пользовательских данных",
|
||||
"CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.",
|
||||
"Original": "Оригинальный",
|
||||
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}"
|
||||
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}",
|
||||
"NameExtraBehindTheScenes": "За кулисами",
|
||||
"NameExtraClip": "Отрывок",
|
||||
"NameExtraDeletedScene": "Удалённая сцена",
|
||||
"NameExtraFeaturette": "Короткометражка",
|
||||
"NameExtraInterview": "Интервью",
|
||||
"NameExtraSample": "Образец",
|
||||
"NameExtraScene": "Сцена",
|
||||
"NameExtraThemeSong": "Заглавная песня",
|
||||
"NameExtraThemeVideo": "Заглавное видео",
|
||||
"NameExtraTrailer": "Трейлер",
|
||||
"NameExtraUnknown": "Дополнительный материал"
|
||||
}
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
{}
|
||||
{
|
||||
"AppDeviceValues": "ඇප්: {0}, උපාංග: {1}",
|
||||
"Artists": "කලාකරුවන්",
|
||||
"AuthenticationSucceededWithUserName": "{0} සාර්ථකව තහවුරු කරන ලදී",
|
||||
"Books": "පොත්",
|
||||
"ChapterNameValue": "{0} වෙනි පරිච්ඡේදය",
|
||||
"Collections": "සංහිතා",
|
||||
"Default": "පෙරනිමි",
|
||||
"External": "බාහිර",
|
||||
"FailedLoginAttemptWithUserName": "{0} වෙතින් සිදුකළ පිවිසීමේ උත්සාහය අසාර්ථක විය",
|
||||
"Favorites": "ප්රියතමයන්",
|
||||
"Folders": "ෆෝල්ඩර",
|
||||
"Forced": "නියමිත",
|
||||
"Genres": "ප්රභේද"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Prečistiť používateľské dáta",
|
||||
"CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.",
|
||||
"LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}",
|
||||
"Original": "Originál"
|
||||
"Original": "Originál",
|
||||
"NameExtraBehindTheScenes": "Zo zákulisia",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Vystrihnutá scéna",
|
||||
"NameExtraFeaturette": "Bonus",
|
||||
"NameExtraInterview": "Rozhovor",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Ukážka",
|
||||
"NameExtraScene": "Scéna",
|
||||
"NameExtraShort": "Krátky film",
|
||||
"NameExtraThemeSong": "Úvodná pieseň",
|
||||
"NameExtraThemeVideo": "Úvodné video",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskAudioNormalization": "Normalizimi i 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"
|
||||
"CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve",
|
||||
"LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}",
|
||||
"Original": "Origjinal"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,13 @@
|
||||
"CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.",
|
||||
"CleanupUserDataTask": "Uppgift för rensning av användardata",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}"
|
||||
"LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}",
|
||||
"NameExtraBehindTheScenes": "Bakom kulisserna",
|
||||
"NameExtraDeletedScene": "Borttagen scen",
|
||||
"NameExtraInterview": "Intervju",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraScene": "Scen",
|
||||
"NameExtraShort": "Kortfilm",
|
||||
"NameExtraThemeSong": "Signaturmelodi",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment",
|
||||
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
|
||||
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
|
||||
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน"
|
||||
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน",
|
||||
"LyricDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1}",
|
||||
"Original": "ต้นฉบับ"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Kullanıcı verisi temizleme görevi",
|
||||
"CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler.",
|
||||
"LyricDownloadFailureFromForItem": "{1} şarkı sözleri {0} adresinden indirilemedi",
|
||||
"Original": "Orijinal"
|
||||
"Original": "Orijinal",
|
||||
"NameExtraBehindTheScenes": "Kamera Arkası",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Silinmiş Sahne",
|
||||
"NameExtraFeaturette": "Kısa film",
|
||||
"NameExtraInterview": "Röportaj",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Örnek",
|
||||
"NameExtraScene": "Sahne",
|
||||
"NameExtraShort": "Kısa",
|
||||
"NameExtraThemeSong": "Tanıtım Müziği",
|
||||
"NameExtraThemeVideo": "Tanıtım Videosu",
|
||||
"NameExtraTrailer": "Fragman",
|
||||
"NameExtraUnknown": "Fazladan"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Завдання очищення даних користувача",
|
||||
"CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому.",
|
||||
"Original": "Оригінал",
|
||||
"LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}"
|
||||
"LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}",
|
||||
"NameExtraBehindTheScenes": "За лаштунками",
|
||||
"NameExtraClip": "Кліп",
|
||||
"NameExtraDeletedScene": "Видалена сцена",
|
||||
"NameExtraFeaturette": "Фічуретка",
|
||||
"NameExtraInterview": "Інтерв’ю",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Приклад",
|
||||
"NameExtraScene": "Сцена",
|
||||
"NameExtraShort": "Коротко",
|
||||
"NameExtraThemeSong": "Тематична пісня",
|
||||
"NameExtraThemeVideo": "Тематичне вiдео",
|
||||
"NameExtraTrailer": "Трейлер",
|
||||
"NameExtraUnknown": "Додатково"
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"TasksLibraryCategory": "Thư Viện",
|
||||
"TasksMaintenanceCategory": "Bảo Trì",
|
||||
"VersionNumber": "Phiên Bản {0}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} đã kết thúc phát {1} trên {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}",
|
||||
"UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}",
|
||||
"UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}",
|
||||
"UserOnlineFromDevice": "{0} trực tuyến từ {1}",
|
||||
@@ -79,7 +79,7 @@
|
||||
"HeaderNextUp": "Tiếp Theo",
|
||||
"HeaderFavoriteShows": "Chương Trình Yêu Thích",
|
||||
"HeaderFavoriteEpisodes": "Tập Phim Yêu Thích",
|
||||
"FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập không thành công từ {0}",
|
||||
"FailedLoginAttemptWithUserName": "Cố gắng đăng nhập thất bại từ {0}",
|
||||
"ChapterNameValue": "Phân Cảnh {0}",
|
||||
"Books": "Sách",
|
||||
"AuthenticationSucceededWithUserName": "{0} xác thực thành công",
|
||||
@@ -95,18 +95,31 @@
|
||||
"TaskKeyframeExtractorDescription": "Trích xuất khung hình chính từ các tệp video để tạo danh sách phát HLS chính xác hơn. Tác vụ này có thể chạy trong một thời gian dài.",
|
||||
"External": "Bên ngoài",
|
||||
"HearingImpaired": "Khiếm Thính",
|
||||
"TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật.",
|
||||
"TaskRefreshTrickplayImages": "Tạo Ảnh Tua Nhanh (Trickplay)",
|
||||
"TaskRefreshTrickplayImagesDescription": "Tạo ảnh tua nhanh (trịckplay) xem thử cho các video trong các thư viện được kích hoạt.",
|
||||
"TaskAudioNormalization": "Chuẩn Hóa Âm Thanh",
|
||||
"TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh.",
|
||||
"TaskDownloadMissingLyricsDescription": "Tải xuống lời cho bài hát",
|
||||
"TaskDownloadMissingLyrics": "Tải xuống lời bị thiếu",
|
||||
"TaskExtractMediaSegmentsDescription": "Trích xuất hoặc lấy các phân đoạn phương tiện từ các plugin hỗ trợ MediaSegment.",
|
||||
"TaskMoveTrickplayImages": "Di chuyển vị trí hình ảnh Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.",
|
||||
"TaskMoveTrickplayImages": "Di Chuyển Vị Trí Ảnh Tua Nhanh (Trickplay)",
|
||||
"TaskMoveTrickplayImagesDescription": "Di chuyển các tệp ảnh tua nhanh (trickplay) hiện có theo cài đặt thư viện.",
|
||||
"TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện",
|
||||
"CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng",
|
||||
"CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày.",
|
||||
"Original": "Gốc",
|
||||
"LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}"
|
||||
"LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}",
|
||||
"NameExtraBehindTheScenes": "Hậu Trường",
|
||||
"NameExtraDeletedScene": "Cảnh Bị Xóa",
|
||||
"NameExtraInterview": "Phỏng vấn",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Mẫu",
|
||||
"NameExtraScene": "Cảnh",
|
||||
"NameExtraShort": "Ngắn",
|
||||
"NameExtraThemeSong": "Bài Hát Chủ Đề",
|
||||
"NameExtraThemeVideo": "Video Chủ Đề",
|
||||
"NameExtraFeaturette": "Nội dung phụ",
|
||||
"NameExtraClip": "Clip ngắn",
|
||||
"NameExtraTrailer": "Đoạn giới thiệu",
|
||||
"NameExtraUnknown": "Nội dung bổ sung"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "用户数据清理任务",
|
||||
"CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。",
|
||||
"LyricDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的歌词",
|
||||
"Original": "原始"
|
||||
"Original": "原始",
|
||||
"NameExtraBehindTheScenes": "幕后花絮",
|
||||
"NameExtraClip": "片段",
|
||||
"NameExtraDeletedScene": "删减场景",
|
||||
"NameExtraFeaturette": "花絮",
|
||||
"NameExtraInterview": "采访",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "样本",
|
||||
"NameExtraScene": "场景",
|
||||
"NameExtraShort": "短片",
|
||||
"NameExtraThemeSong": "主题曲",
|
||||
"NameExtraThemeVideo": "主题视频",
|
||||
"NameExtraTrailer": "预告片",
|
||||
"NameExtraUnknown": "额外"
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
"TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。",
|
||||
"TaskCleanLogs": "清理日誌資料夾",
|
||||
"TaskRefreshLibrary": "掃描媒體櫃",
|
||||
"TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節縮圖。",
|
||||
"TaskRefreshChapterImages": "擷取章節圖片",
|
||||
"TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。",
|
||||
"TaskRefreshChapterImages": "擷取章節圖像",
|
||||
"TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。",
|
||||
"TaskCleanCache": "清理快取(Cache)資料夾",
|
||||
"TasksChannelsCategory": "網路頻道",
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskMoveTrickplayImages": "遷移快轉縮圖位置",
|
||||
"TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。",
|
||||
"CleanupUserDataTask": "用戶資料清理工作",
|
||||
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。"
|
||||
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
|
||||
"Original": "原作",
|
||||
"LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞"
|
||||
}
|
||||
|
||||
@@ -261,6 +261,24 @@ namespace Emby.Server.Implementations.Localization
|
||||
_cultures);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetLanguageDisplayName(string language)
|
||||
{
|
||||
if (string.IsNullOrEmpty(language))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var displayName = FindLanguageInfo(language)?.DisplayName;
|
||||
if (displayName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Truncate at the first delimiter to avoid cluttered display names
|
||||
return displayName.Split([';', ','], StringSplitOptions.None)[0].Trim();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CountryInfo> GetCountries()
|
||||
{
|
||||
@@ -356,6 +374,27 @@ namespace Emby.Server.Implementations.Localization
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(rating);
|
||||
|
||||
// Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år").
|
||||
// Try each one in order and use the first that resolves.
|
||||
var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
foreach (var ratingValue in ratingValues)
|
||||
{
|
||||
var score = GetSingleRatingScore(ratingValue, countryCode);
|
||||
if (score is not null)
|
||||
{
|
||||
return score;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a single rating value to a score.
|
||||
/// </summary>
|
||||
private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode)
|
||||
{
|
||||
// Handle unrated content
|
||||
if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -364,7 +403,7 @@ namespace Emby.Server.Implementations.Localization
|
||||
|
||||
// Convert ints directly
|
||||
// This may override some of the locale specific age ratings (but those always map to the same age)
|
||||
if (int.TryParse(rating, out var ratingAge))
|
||||
if (TryParseRatingAsScore(rating, out var ratingAge))
|
||||
{
|
||||
return new(ratingAge, null);
|
||||
}
|
||||
@@ -466,6 +505,13 @@ namespace Emby.Server.Implementations.Localization
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's not a recognized rating string, fall back to using the number as the score
|
||||
if (TryParseRatingAsScore(ratingPart, out var numericScore))
|
||||
{
|
||||
result = new ParentalRatingScore(numericScore, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
|
||||
rating,
|
||||
@@ -480,6 +526,18 @@ namespace Emby.Server.Implementations.Localization
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+").
|
||||
/// </summary>
|
||||
/// <param name="ratingValue">Rating value to parse.</param>
|
||||
/// <param name="score">Parsed score.</param>
|
||||
/// <returns>Returns true if parsing was successful.</returns>
|
||||
private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score)
|
||||
{
|
||||
var trimmed = ratingValue.TrimEnd('+');
|
||||
return int.TryParse(trimmed, out score);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetLocalizedString(string phrase)
|
||||
{
|
||||
|
||||
@@ -10,21 +10,42 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K12"],
|
||||
"ratingStrings": ["K12", "K-12", "12"],
|
||||
"ratingScore": {
|
||||
"score": 12,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K13", "K-13", "13"],
|
||||
"ratingScore": {
|
||||
"score": 13,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K15"],
|
||||
"ratingStrings": ["K15", "K-15", "15"],
|
||||
"ratingScore": {
|
||||
"score": 15,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K18"],
|
||||
"ratingStrings": ["K16", "K-16", "16"],
|
||||
"ratingScore": {
|
||||
"score": 16,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K17", "K-17", "17"],
|
||||
"ratingScore": {
|
||||
"score": 17,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K18", "K-18", "18", "18+"],
|
||||
"ratingScore": {
|
||||
"score": 18,
|
||||
"subScore": null
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["7"],
|
||||
"ratingStrings": ["7", "7+", "7 År", "Från 7 år"],
|
||||
"ratingScore": {
|
||||
"score": 7,
|
||||
"subScore": null
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["11"],
|
||||
"ratingStrings": ["11", "11+", "11 År", "Från 11 år"],
|
||||
"ratingScore": {
|
||||
"score": 11,
|
||||
"subScore": null
|
||||
@@ -45,11 +45,18 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["15"],
|
||||
"ratingStrings": ["15", "15+", "15 År", "Från 15 år"],
|
||||
"ratingScore": {
|
||||
"score": 15,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["18", "18+", "Barnförbjuden", "Bfj"],
|
||||
"ratingScore": {
|
||||
"score": 18,
|
||||
"subScore": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -219,28 +219,15 @@ namespace Emby.Server.Implementations.Playlists
|
||||
var playlist = _libraryManager.GetItemById(playlistId) as Playlist
|
||||
?? throw new ArgumentException("No Playlist exists with Id " + playlistId);
|
||||
|
||||
// Retrieve all the items to be added to the playlist
|
||||
// Retrieve all the items to be added to the playlist.
|
||||
var newItems = GetPlaylistItems(newItemIds, user, options)
|
||||
.Where(i => i.SupportsAddingToPlaylist);
|
||||
|
||||
// Filter out duplicate items
|
||||
var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet();
|
||||
newItems = newItems
|
||||
.Where(i => !existingIds.Contains(i.Id))
|
||||
.Distinct();
|
||||
|
||||
// Create a list of the new linked children to add to the playlist
|
||||
var childrenToAdd = newItems
|
||||
.Select(LinkedChild.Create)
|
||||
.ToList();
|
||||
|
||||
// Log duplicates that have been ignored, if any
|
||||
int numDuplicates = newItemIds.Count - childrenToAdd.Count;
|
||||
if (numDuplicates > 0)
|
||||
{
|
||||
_logger.LogWarning("Ignored adding {DuplicateCount} duplicate items to playlist {PlaylistName}.", numDuplicates, playlist.Name);
|
||||
}
|
||||
|
||||
// Do nothing else if there are no items to add to the playlist
|
||||
if (childrenToAdd.Count == 0)
|
||||
{
|
||||
|
||||
@@ -255,6 +255,14 @@ namespace Emby.Server.Implementations.Plugins
|
||||
}
|
||||
|
||||
_plugins.Add(plugin);
|
||||
|
||||
// Updating a disabled plugin must not enable it again.
|
||||
if (plugin.Manifest.Status == PluginStatus.Disabled)
|
||||
{
|
||||
ProcessAlternative(plugin);
|
||||
return;
|
||||
}
|
||||
|
||||
EnablePlugin(plugin);
|
||||
}
|
||||
|
||||
@@ -632,9 +640,10 @@ namespace Emby.Server.Implementations.Plugins
|
||||
return;
|
||||
}
|
||||
|
||||
var predecessor = _plugins.OrderByDescending(p => p.Version)
|
||||
.FirstOrDefault(p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version);
|
||||
if (predecessor is not null)
|
||||
var successor = _plugins.FirstOrDefault(p => p.Id.Equals(plugin.Id)
|
||||
&& p.Version > plugin.Version
|
||||
&& (p.IsEnabledAndSupported || p.Manifest.Status == PluginStatus.Disabled));
|
||||
if (successor is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -763,6 +772,8 @@ namespace Emby.Server.Implementations.Plugins
|
||||
var entry = versions[x];
|
||||
if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
lastName = string.Empty;
|
||||
|
||||
if (!TryGetPluginDlls(entry, out var allowedDlls))
|
||||
{
|
||||
_logger.LogError("One or more assembly paths was invalid. Marking plugin {Plugin} as \"Malfunctioned\".", entry.Name);
|
||||
@@ -772,15 +783,18 @@ namespace Emby.Server.Implementations.Plugins
|
||||
|
||||
entry.DllFiles = allowedDlls;
|
||||
|
||||
// Only clean up older versions when this version will actually be loaded.
|
||||
if (entry.IsEnabledAndSupported)
|
||||
{
|
||||
lastName = entry.Name;
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(lastName))
|
||||
{
|
||||
// Unnamed plugin, so there is nothing to match older versions against.
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -891,9 +905,9 @@ namespace Emby.Server.Implementations.Plugins
|
||||
|
||||
if (previousVersion is null)
|
||||
{
|
||||
// This value is memory only - so that the web will show restart required.
|
||||
plugin.Manifest.Status = PluginStatus.Restart;
|
||||
plugin.Manifest.AutoUpdate = false;
|
||||
// Memory only, so that the web will show restart required. The manifest must keep
|
||||
// holding the persisted state, or a later save would write the wrong state to disk.
|
||||
plugin.RestartRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -906,9 +920,7 @@ namespace Emby.Server.Implementations.Plugins
|
||||
_logger.LogError("Unable to supercede version {Version} of {Name}", previousVersion.Version, previousVersion.Name);
|
||||
}
|
||||
|
||||
// This value is memory only - so that the web will show restart required.
|
||||
plugin.Manifest.Status = PluginStatus.Restart;
|
||||
plugin.Manifest.AutoUpdate = false;
|
||||
plugin.RestartRequired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -234,7 +235,7 @@ public partial class AudioNormalizationTask : IScheduledTask
|
||||
{
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = false,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
RedirectStandardError = true
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,9 +4,15 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -21,7 +27,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<PeopleValidationTask> _logger;
|
||||
private readonly IItemTypeLookup _itemTypeLookup;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
|
||||
@@ -29,13 +37,23 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
|
||||
public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory, ILogger<PeopleValidationTask> logger)
|
||||
/// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
|
||||
public PeopleValidationTask(
|
||||
ILibraryManager libraryManager,
|
||||
ILocalizationManager localization,
|
||||
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILogger<PeopleValidationTask> logger,
|
||||
IItemTypeLookup itemTypeLookup)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_localization = localization;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_fileSystem = fileSystem;
|
||||
_logger = logger;
|
||||
_itemTypeLookup = itemTypeLookup;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -83,10 +101,11 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 1: Deduplicate and remove orphaned people (0-33%)
|
||||
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
||||
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 3));
|
||||
var dupQuery = context.Peoples
|
||||
.GroupBy(e => new { e.Name, e.PersonType })
|
||||
.Where(e => e.Count() > 1)
|
||||
@@ -141,9 +160,104 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
subProgress.Report(100);
|
||||
}
|
||||
|
||||
IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
|
||||
// Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are
|
||||
// cleaned up above, so dead people are removed in a single pass instead of requiring a second run.
|
||||
IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33));
|
||||
await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Phase 3: Refresh images for people missing them (66-100%)
|
||||
IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66));
|
||||
await RefreshPeopleImagesAsync(refreshProgress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private async Task RefreshPeopleImagesAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
|
||||
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
||||
|
||||
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
const int PartitionSize = 100;
|
||||
|
||||
var numPeople = await context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Type == personTypeName)
|
||||
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
|
||||
.Where(b =>
|
||||
!b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
|
||||
string.IsNullOrEmpty(b.Overview))
|
||||
.CountAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople);
|
||||
|
||||
if (numPeople == 0)
|
||||
{
|
||||
progress.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
var numComplete = 0;
|
||||
var numRefreshed = 0;
|
||||
|
||||
await foreach (var entry in context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Type == personTypeName)
|
||||
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
|
||||
.Where(b =>
|
||||
!b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
|
||||
string.IsNullOrEmpty(b.Overview))
|
||||
.OrderBy(b => b.Id)
|
||||
.WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition))
|
||||
.PartitionEagerAsync(PartitionSize, cancellationToken)
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
numRefreshed++;
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
progress.Report(100.0 * numComplete / numPeople);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_libraryManager.GetItemById(personId) is not Person item)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasImage = item.HasImage(MediaBrowser.Model.Entities.ImageType.Primary);
|
||||
var hasOverview = !string.IsNullOrEmpty(item.Overview);
|
||||
|
||||
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default,
|
||||
MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default
|
||||
};
|
||||
|
||||
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error refreshing images for person {PersonId}", personId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,8 +641,7 @@ namespace Emby.Server.Implementations.Session
|
||||
if (playingSessions.Count > 0)
|
||||
{
|
||||
var idle = playingSessions
|
||||
.Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5)
|
||||
.ToList();
|
||||
.Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5);
|
||||
|
||||
foreach (var session in idle)
|
||||
{
|
||||
@@ -729,6 +728,31 @@ namespace Emby.Server.Implementations.Session
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the item whose user data (playback position, played status) should be updated
|
||||
/// for a playback report. When an alternate version is played the client reports the displayed
|
||||
/// item as <c>ItemId</c> and the played version as <c>MediaSourceId</c>.
|
||||
/// </summary>
|
||||
/// <param name="libraryItem">The now playing (displayed) item.</param>
|
||||
/// <param name="mediaSourceId">The reported media source id.</param>
|
||||
/// <returns>The item to track progress against.</returns>
|
||||
private BaseItem GetProgressItem(BaseItem libraryItem, string mediaSourceId)
|
||||
{
|
||||
if (libraryItem is Video libraryVideo
|
||||
&& !string.IsNullOrEmpty(mediaSourceId)
|
||||
&& Guid.TryParse(mediaSourceId, out var mediaSourceItemId)
|
||||
&& !mediaSourceItemId.Equals(libraryVideo.Id))
|
||||
{
|
||||
var versionItem = libraryVideo.GetAlternateVersion(mediaSourceItemId);
|
||||
if (versionItem is not null)
|
||||
{
|
||||
return versionItem;
|
||||
}
|
||||
}
|
||||
|
||||
return libraryItem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used to report that playback has started for an item.
|
||||
/// </summary>
|
||||
@@ -760,9 +784,10 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (libraryItem is not null)
|
||||
{
|
||||
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
|
||||
foreach (var user in users)
|
||||
{
|
||||
OnPlaybackStart(user, libraryItem);
|
||||
OnPlaybackStart(user, progressItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,9 +919,10 @@ namespace Emby.Server.Implementations.Session
|
||||
// only update saved user data on actual check-ins, not automated ones
|
||||
if (libraryItem is not null && !isAutomated)
|
||||
{
|
||||
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
|
||||
foreach (var user in users)
|
||||
{
|
||||
OnPlaybackProgress(user, libraryItem, info);
|
||||
OnPlaybackProgress(user, progressItem, info);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -956,6 +982,20 @@ namespace Emby.Server.Implementations.Session
|
||||
if (changed)
|
||||
{
|
||||
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None);
|
||||
|
||||
// A completed version marks every alternate version played and clears their resume points, so the
|
||||
// whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
|
||||
// only persist while nothing has been completed yet.)
|
||||
if (data.Played == true && item is Video playedVideo)
|
||||
{
|
||||
playedVideo.PropagatePlayedState(user, true);
|
||||
}
|
||||
}
|
||||
|
||||
if ((!user.RememberAudioSelections && info.AudioStreamIndex.HasValue)
|
||||
|| (!user.RememberSubtitleSelections && info.SubtitleStreamIndex.HasValue))
|
||||
{
|
||||
_userDataManager.ResetPlaybackStreamSelections(user, item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,9 +1127,10 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (libraryItem is not null)
|
||||
{
|
||||
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
|
||||
foreach (var user in users)
|
||||
{
|
||||
playedToCompletion = OnPlaybackStopped(user, libraryItem, info.PositionTicks, info.Failed);
|
||||
playedToCompletion = OnPlaybackStopped(user, progressItem, info.PositionTicks, info.Failed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1142,6 +1183,14 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None);
|
||||
|
||||
// A completed version marks every alternate version played and clears their resume points, so the
|
||||
// whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
|
||||
// only persist while nothing has been completed yet.)
|
||||
if (data.Played == true && item is Video playedVideo)
|
||||
{
|
||||
playedVideo.PropagatePlayedState(user, true);
|
||||
}
|
||||
|
||||
return playedToCompletion;
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (inactive.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
|
||||
_logger.LogDebug("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
|
||||
}
|
||||
|
||||
foreach (var webSocket in inactive)
|
||||
@@ -246,8 +246,21 @@ namespace Emby.Server.Implementations.Session
|
||||
_logger.LogInformation("Lost {0} WebSockets.", lost.Count);
|
||||
foreach (var webSocket in lost)
|
||||
{
|
||||
// TODO: handle session relative to the lost webSocket
|
||||
RemoveWebSocket(webSocket);
|
||||
|
||||
// The connection stopped answering keep-alives, so a close frame will
|
||||
// never arrive and the pending receive loop would hang forever, keeping
|
||||
// the session (and e.g. its SyncPlay group membership) alive. Disposing
|
||||
// the connection aborts the receive loop, which raises Closed and lets
|
||||
// the session end normally.
|
||||
try
|
||||
{
|
||||
webSocket.Dispose();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Error disposing lost WebSocket from {RemoteEndPoint}.", webSocket.RemoteEndPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,34 +3,14 @@
|
||||
|
||||
using System;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Sorting;
|
||||
using MediaBrowser.Model.Querying;
|
||||
|
||||
namespace Emby.Server.Implementations.Sorting
|
||||
{
|
||||
public class DateLastMediaAddedComparer : IUserBaseItemComparer
|
||||
public class DateLastMediaAddedComparer : IBaseItemComparer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the user.
|
||||
/// </summary>
|
||||
/// <value>The user.</value>
|
||||
public User User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user manager.
|
||||
/// </summary>
|
||||
/// <value>The user manager.</value>
|
||||
public IUserManager UserManager { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user data manager.
|
||||
/// </summary>
|
||||
/// <value>The user data manager.</value>
|
||||
public IUserDataManager UserDataManager { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name.
|
||||
/// </summary>
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace Emby.Server.Implementations.Sorting
|
||||
return x.PremiereDate.Value;
|
||||
}
|
||||
|
||||
if (x.ProductionYear.HasValue)
|
||||
if (x.ProductionYear is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -39,7 +39,7 @@ namespace Emby.Server.Implementations.Sorting
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x.ProductionYear.HasValue)
|
||||
if (x.ProductionYear is not null)
|
||||
{
|
||||
return x.ProductionYear.Value;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
@@ -136,11 +137,15 @@ namespace Emby.Server.Implementations.TV
|
||||
|
||||
if (nextEpisode is not null)
|
||||
{
|
||||
// The last played date and the version that was actually played live on the version item's user data
|
||||
// The played state propagated to the sibling versions carries no date
|
||||
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user);
|
||||
nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion);
|
||||
|
||||
DateTime lastWatchedDate = DateTime.MinValue;
|
||||
if (result.LastWatched is not null)
|
||||
{
|
||||
var userData = _userDataManager.GetUserData(user, result.LastWatched);
|
||||
lastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||
lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||
}
|
||||
|
||||
nextUpList.Add((lastWatchedDate, nextEpisode));
|
||||
@@ -152,11 +157,13 @@ namespace Emby.Server.Implementations.TV
|
||||
|
||||
if (nextPlayedEpisode is not null)
|
||||
{
|
||||
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user);
|
||||
nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion);
|
||||
|
||||
DateTime rewatchLastWatchedDate = DateTime.MinValue;
|
||||
if (result.LastWatchedForRewatching is not null)
|
||||
{
|
||||
var userData = _userDataManager.GetUserData(user, result.LastWatchedForRewatching);
|
||||
rewatchLastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||
rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||
}
|
||||
|
||||
nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode));
|
||||
@@ -219,10 +226,13 @@ namespace Emby.Server.Implementations.TV
|
||||
|
||||
if (nextEpisode is not null && !includeResumable)
|
||||
{
|
||||
var userData = _userDataManager.GetUserData(user, nextEpisode);
|
||||
if (userData?.PlaybackPositionTicks > 0)
|
||||
// The resume progress may live on an alternate version
|
||||
foreach (var version in nextEpisode.GetAllVersions())
|
||||
{
|
||||
return null;
|
||||
if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +247,74 @@ namespace Emby.Server.Implementations.TV
|
||||
return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the version of the last watched episode that was actually played, together with its last played date.
|
||||
/// The version that was played carries the most recent LastPlayedDate.
|
||||
/// dates.
|
||||
/// </summary>
|
||||
/// <param name="lastWatched">The last watched episode (any version).</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>The played version and its last played date.</returns>
|
||||
private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user)
|
||||
{
|
||||
if (lastWatched is not Video lastWatchedVideo)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var versions = lastWatchedVideo.GetAllVersions();
|
||||
var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
|
||||
|
||||
var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||
versions,
|
||||
version => userDataByVersion.GetValueOrDefault(version.Id),
|
||||
data => data.LastPlayedDate.HasValue);
|
||||
|
||||
return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the last watched episode was played as an alternate version, prefer the next episode's version with the matching name,
|
||||
/// so Next Up continues in the version the user has been watching instead of falling back to the primary.
|
||||
/// </summary>
|
||||
/// <param name="nextEpisode">The determined next episode (a primary).</param>
|
||||
/// <param name="lastWatched">The last watched episode.</param>
|
||||
/// <param name="playedVersion">The version of the last watched episode that was played.</param>
|
||||
/// <returns>The matching version of the next episode, or the episode itself.</returns>
|
||||
private Episode GetPreferredVersion(Episode nextEpisode, BaseItem? lastWatched, Video? playedVersion)
|
||||
{
|
||||
// No version preference, or the primary was played
|
||||
if (lastWatched is not Video lastWatchedVideo
|
||||
|| playedVersion is null
|
||||
|| !playedVersion.PrimaryVersionId.HasValue)
|
||||
{
|
||||
return nextEpisode;
|
||||
}
|
||||
|
||||
// Match by version name
|
||||
var playedVersionId = playedVersion.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
var playedVersionName = lastWatchedVideo.GetMediaSources(false)
|
||||
.FirstOrDefault(source => string.Equals(source.Id, playedVersionId, StringComparison.OrdinalIgnoreCase))?.Name;
|
||||
|
||||
if (string.IsNullOrEmpty(playedVersionName))
|
||||
{
|
||||
return nextEpisode;
|
||||
}
|
||||
|
||||
var matchingSource = nextEpisode.GetMediaSources(false)
|
||||
.FirstOrDefault(source => string.Equals(source.Name, playedVersionName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingSource is not null
|
||||
&& Guid.TryParse(matchingSource.Id, out var matchingId)
|
||||
&& !matchingId.Equals(nextEpisode.Id)
|
||||
&& _libraryManager.GetItemById<Episode>(matchingId) is { } matchingVersion)
|
||||
{
|
||||
return matchingVersion;
|
||||
}
|
||||
|
||||
return nextEpisode;
|
||||
}
|
||||
|
||||
private static string GetUniqueSeriesKey(Series series)
|
||||
{
|
||||
return series.GetPresentationUniqueKey();
|
||||
|
||||
@@ -500,8 +500,9 @@ namespace Emby.Server.Implementations.Updates
|
||||
var plugins = _pluginManager.Plugins;
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
// Don't auto update when plugin marked not to, or when it's disabled.
|
||||
if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled)
|
||||
// Don't auto update when plugin marked not to, or when it's disabled or pending removal.
|
||||
if (plugin.Manifest?.AutoUpdate == false
|
||||
|| plugin.Manifest?.Status is PluginStatus.Disabled or PluginStatus.Deleted)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Data.Queries;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
@@ -84,36 +84,9 @@ public class ActivityLogController : BaseJellyfinApiController
|
||||
ItemId = itemId,
|
||||
Username = username,
|
||||
Severity = severity,
|
||||
OrderBy = GetOrderBy(sortBy ?? [], sortOrder ?? []),
|
||||
OrderBy = RequestHelpers.GetOrderBy(sortBy ?? [], sortOrder ?? []),
|
||||
};
|
||||
|
||||
return await _activityManager.GetPagedResultAsync(query).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static (ActivityLogSortBy SortBy, SortOrder SortOrder)[] GetOrderBy(
|
||||
IReadOnlyList<ActivityLogSortBy> sortBy,
|
||||
IReadOnlyList<SortOrder> requestedSortOrder)
|
||||
{
|
||||
if (sortBy.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new (ActivityLogSortBy, SortOrder)[sortBy.Count];
|
||||
var i = 0;
|
||||
for (; i < requestedSortOrder.Count; i++)
|
||||
{
|
||||
result[i] = (sortBy[i], requestedSortOrder[i]);
|
||||
}
|
||||
|
||||
// Add remaining elements with the first specified SortOrder
|
||||
// or the default one if no SortOrders are specified
|
||||
var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending;
|
||||
for (; i < sortBy.Count; i++)
|
||||
{
|
||||
result[i] = (sortBy[i], order);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,22 +1456,16 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
|
||||
var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||
|
||||
TranscodingJob? job;
|
||||
|
||||
if (System.IO.File.Exists(segmentPath))
|
||||
{
|
||||
job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
_logger.LogDebug("returning {0} [it exists, try 1]", segmentPath);
|
||||
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Keep segment selection and transcoding replacement under the same playlist lock.
|
||||
// An out-of-order request must not replace a job while another request is using its output.
|
||||
using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
TranscodingJob? job;
|
||||
var startTranscoding = false;
|
||||
if (System.IO.File.Exists(segmentPath))
|
||||
{
|
||||
job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
_logger.LogDebug("returning {0} [it exists, try 2]", segmentPath);
|
||||
_logger.LogDebug("returning {0} [it exists]", segmentPath);
|
||||
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -1505,6 +1499,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
// If the playlist doesn't already exist, startup ffmpeg
|
||||
try
|
||||
{
|
||||
var currentJob = _transcodeManager.GetTranscodingJob(playlistPath, TranscodingJobType);
|
||||
await WaitForActiveTranscodingRequests(currentJob, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -1540,11 +1537,19 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("returning {0} [general case]", segmentPath);
|
||||
job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("returning {0} [general case]", segmentPath);
|
||||
job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task WaitForActiveTranscodingRequests(TranscodingJob? job, CancellationToken cancellationToken)
|
||||
{
|
||||
while (job?.ActiveRequestCount > 0)
|
||||
{
|
||||
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static double[] GetSegmentLengths(StreamState state)
|
||||
@@ -1607,8 +1612,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
|
||||
if (state.VideoStream is not null && state.IsOutputVideo)
|
||||
{
|
||||
// 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";
|
||||
// fMP4 needs frag_discont to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
|
||||
// HLS does not use SIDX, and skipping it avoids FFmpeg rewriting open-GOP boundary packet PTS
|
||||
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont+skip_sidx";
|
||||
}
|
||||
|
||||
segmentFormat = "fmp4" + outputFmp4HeaderArg;
|
||||
|
||||
@@ -158,13 +158,43 @@ public class FilterController : BaseJellyfinApiController
|
||||
IsSeries = isSeries
|
||||
};
|
||||
|
||||
var streamLanguageQuery = new InternalItemsQuery(user)
|
||||
{
|
||||
// It's possible that different langauges are only available on alternative versions.
|
||||
// To fetch them all, owned items are included.
|
||||
IncludeOwnedItems = true,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
DtoOptions = new DtoOptions
|
||||
{
|
||||
Fields = Array.Empty<ItemFields>(),
|
||||
EnableImages = false,
|
||||
EnableUserData = false
|
||||
},
|
||||
IsAiring = isAiring,
|
||||
IsMovie = isMovie,
|
||||
IsSports = isSports,
|
||||
IsKids = isKids,
|
||||
IsNews = isNews,
|
||||
IsSeries = isSeries
|
||||
};
|
||||
|
||||
if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder)
|
||||
{
|
||||
genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
|
||||
var ancestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
|
||||
genreQuery.AncestorIds = ancestorIds;
|
||||
streamLanguageQuery.AncestorIds = ancestorIds;
|
||||
}
|
||||
else
|
||||
{
|
||||
genreQuery.Parent = parentItem;
|
||||
streamLanguageQuery.Parent = parentItem;
|
||||
}
|
||||
|
||||
if ((includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season))
|
||||
&& !includeItemTypes.Contains(BaseItemKind.Episode))
|
||||
{
|
||||
// streams are joined on epsiodes not shows or seasons
|
||||
streamLanguageQuery.IncludeItemTypes = [.. includeItemTypes, BaseItemKind.Episode];
|
||||
}
|
||||
|
||||
if (includeItemTypes.Length == 1
|
||||
@@ -188,10 +218,13 @@ public class FilterController : BaseJellyfinApiController
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series))
|
||||
if (includeItemTypes.Contains(BaseItemKind.Movie)
|
||||
|| includeItemTypes.Contains(BaseItemKind.Series)
|
||||
|| includeItemTypes.Contains(BaseItemKind.Season)
|
||||
|| includeItemTypes.Contains(BaseItemKind.Episode))
|
||||
{
|
||||
filters.AudioLanguages = _libraryManager
|
||||
.GetMediaStreamLanguages(MediaStreamType.Audio)
|
||||
.GetMediaStreamLanguages(MediaStreamType.Audio, streamLanguageQuery)
|
||||
.Select(language =>
|
||||
{
|
||||
var culture = _localization.FindLanguageInfo(language);
|
||||
@@ -204,7 +237,7 @@ public class FilterController : BaseJellyfinApiController
|
||||
.OrderBy(l => l.Name)
|
||||
.ToArray();
|
||||
filters.SubtitleLanguages = _libraryManager
|
||||
.GetMediaStreamLanguages(MediaStreamType.Subtitle)
|
||||
.GetMediaStreamLanguages(MediaStreamType.Subtitle, streamLanguageQuery)
|
||||
.Select(language =>
|
||||
{
|
||||
var culture = _localization.FindLanguageInfo(language);
|
||||
|
||||
@@ -60,11 +60,8 @@ public class HlsSegmentController : BaseJellyfinApiController
|
||||
public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
|
||||
{
|
||||
// TODO: Deprecate with new iOS app
|
||||
var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
|
||||
var transcodePath = _serverConfigurationManager.GetTranscodePath();
|
||||
file = Path.GetFullPath(Path.Combine(transcodePath, file));
|
||||
var fileDir = Path.GetDirectoryName(file);
|
||||
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture))
|
||||
var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())));
|
||||
if (file is null)
|
||||
{
|
||||
return BadRequest("Invalid segment.");
|
||||
}
|
||||
@@ -86,12 +83,9 @@ public class HlsSegmentController : BaseJellyfinApiController
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
|
||||
public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
|
||||
{
|
||||
var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()));
|
||||
var transcodePath = _serverConfigurationManager.GetTranscodePath();
|
||||
file = Path.GetFullPath(Path.Combine(transcodePath, file));
|
||||
var fileDir = Path.GetDirectoryName(file);
|
||||
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)
|
||||
|| Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||
var file = ValidateTranscodePath(string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan())));
|
||||
if (file is null
|
||||
|| !Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest("Invalid segment.");
|
||||
}
|
||||
@@ -140,18 +134,13 @@ public class HlsSegmentController : BaseJellyfinApiController
|
||||
[FromRoute, Required] string segmentId,
|
||||
[FromRoute, Required] string segmentContainer)
|
||||
{
|
||||
var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
|
||||
var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
|
||||
|
||||
file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));
|
||||
var fileDir = Path.GetDirectoryName(file);
|
||||
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture))
|
||||
var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())));
|
||||
if (file is null)
|
||||
{
|
||||
return BadRequest("Invalid segment.");
|
||||
}
|
||||
|
||||
var normalizedPlaylistId = playlistId;
|
||||
|
||||
var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
|
||||
var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
|
||||
// Add . to start of segment container for future use.
|
||||
segmentContainer = segmentContainer.Insert(0, ".");
|
||||
@@ -161,7 +150,7 @@ public class HlsSegmentController : BaseJellyfinApiController
|
||||
var pathExtension = Path.GetExtension(path);
|
||||
if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||
&& path.Contains(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase))
|
||||
&& path.Contains(playlistId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
playlistPath = path;
|
||||
break;
|
||||
@@ -173,6 +162,19 @@ public class HlsSegmentController : BaseJellyfinApiController
|
||||
: GetFileResult(file, playlistPath);
|
||||
}
|
||||
|
||||
private string? ValidateTranscodePath(string filename)
|
||||
{
|
||||
var transcodePath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_serverConfigurationManager.GetTranscodePath()));
|
||||
var file = Path.GetFullPath(filename, transcodePath);
|
||||
// Require a separator after the transcode path so a sibling like "<transcodePath>-evil" can't pass.
|
||||
if (!file.StartsWith(transcodePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private ActionResult GetFileResult(string path, string playlistPath)
|
||||
{
|
||||
var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);
|
||||
|
||||
@@ -125,7 +125,13 @@ public class ImageController : BaseJellyfinApiController
|
||||
{
|
||||
// Handle image/png; charset=utf-8
|
||||
var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
|
||||
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
|
||||
var userConfigurationDirectoryPath = _serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath;
|
||||
var userDataPath = Path.Combine(userConfigurationDirectoryPath, user.Username);
|
||||
if (!PathHelper.IsContainedIn(userConfigurationDirectoryPath, userDataPath))
|
||||
{
|
||||
return BadRequest("Invalid user.");
|
||||
}
|
||||
|
||||
if (user.ProfileImage is not null)
|
||||
{
|
||||
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
|
||||
@@ -2049,7 +2055,7 @@ public class ImageController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
// Check If-Modified-Since header for time-based validation
|
||||
if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader))
|
||||
if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], CultureInfo.InvariantCulture, out var ifModifiedSinceHeader))
|
||||
{
|
||||
// Return 304 if the image has not been modified since the client's cached version
|
||||
if (dateImageModified <= ifModifiedSinceHeader)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user