Compare commits
589 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec581b5e5f | |||
| b77702c851 | |||
| 2370787471 | |||
| a652d718ca | |||
| 0c707c7989 | |||
| 91655fcb0a | |||
| 6e405af1dc | |||
| 95220e2ed6 | |||
| 56919b9565 | |||
| 25269263ce | |||
| b183bec94b | |||
| bc42ff4b28 | |||
| b501ba9620 | |||
| acab85ada3 | |||
| ad834167d5 | |||
| f59ab11c43 | |||
| 10fed9409d | |||
| f2166df463 | |||
| 3b60289502 | |||
| 2f9b8888e9 | |||
| 65af2bcbd7 | |||
| 2e3d24cc05 | |||
| dcb6ae396c | |||
| c438ec17ed | |||
| f54bc76767 | |||
| baa16b6586 | |||
| d825f8ac81 | |||
| 0148f374ee | |||
| d8c7eb5d3d | |||
| 03ac3bbc00 | |||
| e235673f12 | |||
| b6f7732f13 | |||
| 43b4f143eb | |||
| 9b8748c778 | |||
| 673d186de8 | |||
| 14d843897c | |||
| 3b416c7fb2 | |||
| e3571eb2a4 | |||
| 6ccabd4c72 | |||
| c43992630e | |||
| e23d1a23be | |||
| c8a693ae17 | |||
| e68455f0ed | |||
| 6c073e19dd | |||
| 71787c46d1 | |||
| 35a391e99d | |||
| 85ebd83bdd | |||
| 882faf2b2d | |||
| d7d6cc44de | |||
| 42f7a6e720 | |||
| 96f81b0c1b | |||
| 7d9bd1d3e4 | |||
| 2ffa74e7ea | |||
| 837c34aee2 | |||
| 0fc380b4a9 | |||
| 2644163f57 | |||
| dfe57e484f | |||
| 647c4ad090 | |||
| 6de9d1508f | |||
| b2eb088e0f | |||
| e1e319cb5c | |||
| 59cd5f983b | |||
| 04a7568902 | |||
| 3e7d1158da | |||
| fd016cfb13 | |||
| f898c35b96 | |||
| bbb3a48963 | |||
| 34feb3c04a | |||
| 8d108d2870 | |||
| 2555ab7539 | |||
| edb6cd11da | |||
| a0a42c630e | |||
| 63553803b1 | |||
| 7b638a22bb | |||
| 011eed7622 | |||
| 344a6dcd2c | |||
| 79a6ac6d82 | |||
| ca90347dc2 | |||
| 66d038c403 | |||
| 2ac4dd9980 | |||
| a1910a7b2e | |||
| 9221e22498 | |||
| 006809f02a | |||
| 93345f812e | |||
| 7c463f5fba | |||
| 74ce774eff | |||
| 9528d0e601 | |||
| 84589380e5 | |||
| ffd61de4f3 | |||
| 441a05ac88 | |||
| 44ecc909ff | |||
| 2b0ccf2a30 | |||
| d4a23cd438 | |||
| 7ce911a401 | |||
| e356fe9146 | |||
| 0c05d9d1a9 | |||
| e5bfe562bc | |||
| 51a7d5d08a | |||
| 5acb200c02 | |||
| f8470630be | |||
| bf5fb593e4 | |||
| 9c259027df | |||
| c622a16261 | |||
| 5465e0c694 | |||
| e5cd3381ac | |||
| c80f05fad1 | |||
| 46dd7d8e99 | |||
| 0d9c9c9ecc | |||
| 0b5bbb528a | |||
| f10c84465a | |||
| 610887dc2c | |||
| 5edb369e3b | |||
| fc37151fc4 | |||
| c96ff18e5b | |||
| 3f8315df4e | |||
| 1ccec11b91 | |||
| d18d4f9cd2 | |||
| 978552b1c5 | |||
| f53bb0aae9 | |||
| 24d8290462 | |||
| 849437991a | |||
| 1b184b2b35 | |||
| fb6e075e40 | |||
| 3682840d54 | |||
| a6c0fe57c5 | |||
| f4d2da180d | |||
| b561d6aa59 | |||
| b724e57458 | |||
| 6e4d98e6db | |||
| 4f2f781e68 | |||
| 064de2172e | |||
| c3a7de54f3 | |||
| 5adafb446f | |||
| 3df58ab775 | |||
| 8eb4964599 | |||
| f0297ee9ce | |||
| d73e3d964e | |||
| b3766b00d4 | |||
| 3bb8611995 | |||
| e5dc3b8a54 | |||
| 0e6c52f431 | |||
| ccdc69e3b0 | |||
| c56e14d8fb | |||
| c5f8a93513 | |||
| ea89b6ebc5 | |||
| 76418ec530 | |||
| 50f08d41a4 | |||
| 20678c9a8c | |||
| 145258865a | |||
| 792ce4a391 | |||
| 4910aafa1a | |||
| 726ae93a92 | |||
| 0cec5661e0 | |||
| fe73b1a949 | |||
| 80324b19fb | |||
| 05844d60c1 | |||
| eafe5ba6ed | |||
| 7ccce8e0e7 | |||
| 40ab2c4284 | |||
| d1dda7f6c5 | |||
| ff36560575 | |||
| e9abaa519c | |||
| 359e8069d0 | |||
| 39c2885fd6 | |||
| 7d6633ad1e | |||
| 11adad0e67 | |||
| 420d44f638 | |||
| 9fe5a53e47 | |||
| 2996f726c1 | |||
| 37db4bda53 | |||
| 0d78e44633 | |||
| 4962e4ec33 | |||
| 07e97c0ee9 | |||
| 24288e79a9 | |||
| 27d898e59e | |||
| 95281a2205 | |||
| 5a2f45ab4b | |||
| 4207e89a7d | |||
| 163895b99f | |||
| fbb0f1afbc | |||
| ea5f83328d | |||
| a45e66d43c | |||
| 6ad1e341b1 | |||
| b46e66627c | |||
| 7116d3bb72 | |||
| 99f21f1662 | |||
| 9126de26c0 | |||
| 75df54611a | |||
| ceeeaaab8e | |||
| 6da85a0aaa | |||
| 2aad6047c8 | |||
| 1cc490fb19 | |||
| 904699ba21 | |||
| f682c22b08 | |||
| a68793d08a | |||
| e79e2cba27 | |||
| f3298eee69 | |||
| 88df882061 | |||
| 118940fff8 | |||
| 79c37bfcd5 | |||
| a81b90f570 | |||
| 1d24c1df17 | |||
| 4147a83b93 | |||
| 6978dfc294 | |||
| 8c0775e941 | |||
| 5e621d0e3f | |||
| cefa78fc1d | |||
| 0c560b22ce | |||
| 38093e2952 | |||
| 3fafdbc281 | |||
| 9cc47c4fd6 | |||
| 4c524f033f | |||
| 422b2bb3d9 | |||
| 911ac3769c | |||
| 971be1b658 | |||
| 1cc698a30b | |||
| 4cd24a19d4 | |||
| af8e19b169 | |||
| 75ae31895f | |||
| 484291b0c1 | |||
| e8927bc300 | |||
| 7758beba99 | |||
| 090b610eb1 | |||
| 19235909fe | |||
| c3ed1407ca | |||
| 0b7b53a9fd | |||
| 284c6f00db | |||
| ea2c794cb2 | |||
| e1af8dce05 | |||
| 0d02638e82 | |||
| 682c45090c | |||
| b2ba726635 | |||
| 49112163eb | |||
| 0d4cbb999b | |||
| c04f11bd76 | |||
| 6617a27de3 | |||
| 4477f41857 | |||
| 20b4a59281 | |||
| 42c70fba63 | |||
| 8e8cf57025 | |||
| 587f06dccc | |||
| 7ad5ff6aa7 | |||
| a5880c8622 | |||
| 335d97d868 | |||
| 8e80677bdd | |||
| 4df580f0e8 | |||
| 9fa0533506 | |||
| 9ba91d4583 | |||
| a8da0664a3 | |||
| d6bcad3b59 | |||
| 40fbe4a771 | |||
| 678975fbd7 | |||
| fb50b4df8b | |||
| c15656280e | |||
| c70933a23f | |||
| ad9056c8fc | |||
| 77988e51b6 | |||
| 80fe8c67e9 | |||
| 4029de92a1 | |||
| 19d919f8be | |||
| ef01d88912 | |||
| bd085665d6 | |||
| f586fc063f | |||
| 0b20d7a05b | |||
| 17221057bc | |||
| 04b77ca801 | |||
| b11734d955 | |||
| 4a659fb30c | |||
| c203da245d | |||
| 0f36c4a200 | |||
| c4dec76b9c | |||
| 46be43ad24 | |||
| 3fe35fcbc1 | |||
| 43cdbe856a | |||
| 72405bb146 | |||
| 2bb281ad4c | |||
| a2ab2fe340 | |||
| 7df3c09760 | |||
| d26d3f4967 | |||
| 8718c00a5d | |||
| 0c68b190bc | |||
| 2c26ae6724 | |||
| 300c277816 | |||
| 69b21ddc0e | |||
| 90a0e9d962 | |||
| c539ee7e81 | |||
| f97b7b120d | |||
| 7c148c3c7c | |||
| 4fe980f25b | |||
| f0c5370e27 | |||
| 5f14c12c89 | |||
| 49c78d4624 | |||
| 1b698db422 | |||
| 97d3e2cba6 | |||
| 4680d00d2e | |||
| 30c2603911 | |||
| a74246a000 | |||
| 1e04187b52 | |||
| 499e64b1c3 | |||
| 916c3c9cc3 | |||
| f70e0563ee | |||
| d2f517a858 | |||
| 0ae4a70e1c | |||
| 4de43d36dd | |||
| b1e3cf1341 | |||
| dd7de41878 | |||
| e80fc120bf | |||
| 6982ba99ce | |||
| 8cb7297e27 | |||
| 95d95de6f1 | |||
| 7704346e9c | |||
| 6a021d5dae | |||
| 6ebe66c19d | |||
| 40a449c6f2 | |||
| 06e37a7bf6 | |||
| c77649d21e | |||
| 8f97e690c8 | |||
| b557dcfa45 | |||
| 7b66b32cdf | |||
| 7f3bbd5654 | |||
| a7c0c92019 | |||
| e1a619b79e | |||
| 12226d2212 | |||
| ae8723026d | |||
| a730d8a7ba | |||
| 21fec95b07 | |||
| 7e6709f023 | |||
| a4ff630d1f | |||
| 9a07d4336b | |||
| 2b5625d1c4 | |||
| 9ae6ffe441 | |||
| 36fccdf6d4 | |||
| 4e2fc33a61 | |||
| fa7fdf5884 | |||
| 2deb97bac3 | |||
| 9220c2bd63 | |||
| 2e1e445e47 | |||
| 4920aa871a | |||
| a06b11980e | |||
| 4e9713a032 | |||
| f82101332d | |||
| 7b6ae06f3b | |||
| 0008bde28e | |||
| 8c4dfc0b71 | |||
| d4f9c12c22 | |||
| d46539b718 | |||
| 13f49305da | |||
| 7d4cef51f5 | |||
| a9aa2d53ed | |||
| 08b87504b4 | |||
| df27faaa41 | |||
| fe9ea18918 | |||
| e71efc3f97 | |||
| 9817185fa3 | |||
| c2a11f3e68 | |||
| d60ae43b59 | |||
| a187ab18b8 | |||
| cf5268c1e4 | |||
| f405e17c96 | |||
| 67300ec17f | |||
| 2b4a0a2314 | |||
| d84a37e1d2 | |||
| 465b6c5d9e | |||
| b975195252 | |||
| 1c28df61df | |||
| c69b8ccc53 | |||
| 138081bd8b | |||
| ea732b62d8 | |||
| e7c1451b6c | |||
| a49a766424 | |||
| ddf3b9f609 | |||
| 9c6fab8f12 | |||
| 992e591938 | |||
| 365036f9f2 | |||
| 93af601a5f | |||
| 894d837578 | |||
| e79caccab1 | |||
| 25b137e0ab | |||
| 75ef410437 | |||
| 849f0a06c8 | |||
| 2e5fb57052 | |||
| e72bde9c02 | |||
| 35a1ec8a5d | |||
| a0c38131c8 | |||
| 3bc45ff92d | |||
| 2d4d6d8c38 | |||
| 9a99572354 | |||
| b2aa80ce5c | |||
| ff365dae34 | |||
| 52aebfb7d3 | |||
| 66ea1b50e6 | |||
| 3f656ade7a | |||
| 8bf0d372c6 | |||
| 202d7b5829 | |||
| 352e4f3aba | |||
| c5f6d00c94 | |||
| e8d1d94436 | |||
| 50dc37065b | |||
| 7e88b18192 | |||
| 89e914c7f1 | |||
| 1932ac4765 | |||
| ec33c74ec4 | |||
| 2184ed1b16 | |||
| d3907afde7 | |||
| e12d933531 | |||
| c0ba29d917 | |||
| d1fd81c382 | |||
| e038045494 | |||
| e1691e649e | |||
| 8d28497d29 | |||
| fddd4e7e6b | |||
| 0581cd6610 | |||
| 0f1732e5f5 | |||
| 41c2d51d8c | |||
| 29b2361857 | |||
| ce867f9834 | |||
| 4034bf9d7e | |||
| 3d2658fa43 | |||
| 61b19688ff | |||
| e8d72bf6a3 | |||
| 348b14f7b7 | |||
| fda49a5a49 | |||
| 55c00d76bb | |||
| 519d2113eb | |||
| f34f6b6941 | |||
| 6864e108b8 | |||
| 09ba04662a | |||
| 9cd2418095 | |||
| b6a96513de | |||
| ca57166e95 | |||
| 33496c1693 | |||
| b65daeca0b | |||
| 286cc6d720 | |||
| aa4f09c799 | |||
| afd3c0d9f3 | |||
| 5597d8e1a7 | |||
| 0166362258 | |||
| 58c330b63d | |||
| be71295693 | |||
| 8cd3090cee | |||
| 7bf08daeec | |||
| 290463fe7b | |||
| 1b2d9c100a | |||
| caa05c1bf2 | |||
| a37ead86df | |||
| e65aff8bc6 | |||
| 9734494eb6 | |||
| d41e302418 | |||
| 80ba517294 | |||
| 95d08b264f | |||
| 893a849f28 | |||
| 673f617994 | |||
| 644327eb76 | |||
| 10662e75e4 | |||
| a2b1936e73 | |||
| 2df546af6d | |||
| 338b480217 | |||
| 2943bb6fdd | |||
| 94edcbd2d1 | |||
| a8d1cdefac | |||
| a518160a6f | |||
| b56de6493f | |||
| 093cfc3f3b | |||
| 49775b1f6a | |||
| 22d593b8e9 | |||
| 2cb7fb52d2 | |||
| 8433b6d8a4 | |||
| 32d2414de0 | |||
| 317a3a47c3 | |||
| 845b8cdc8f | |||
| c86f6439c5 | |||
| 559e0088e5 | |||
| adaca95590 | |||
| 09a1c31fa3 | |||
| e4b82025b8 | |||
| 78e3702cb0 | |||
| 01b20d3b75 | |||
| 156761405e | |||
| 1805f2259f | |||
| 4c587776d6 | |||
| 8379b4634a | |||
| 9470439cfa | |||
| 18096e48e0 | |||
| f2d0ac7b28 | |||
| 2ccf08f547 | |||
| 1e27f460fe | |||
| 4cdd8c8233 | |||
| 6e60634c9f | |||
| 12c5d6b636 | |||
| b617c62f8e | |||
| 035b5895b0 | |||
| 22da5187c8 | |||
| 5804d6840c | |||
| b50ce1ad6b | |||
| 481ee03f35 | |||
| d91adb5d54 | |||
| ef7f138a4e | |||
| 2e8d9a311b | |||
| 4c5a3fbff3 | |||
| 636908fc4d | |||
| 997362fc97 | |||
| c5147341e3 | |||
| ca33bcebf0 | |||
| d32f487e8e | |||
| fb65f8f853 | |||
| 2a0b90e385 | |||
| dde70fd8a2 | |||
| 98d1d0cb35 | |||
| ba76a8f3ad | |||
| 8cd5652157 | |||
| 8aff4227d9 | |||
| 026f7472cb | |||
| daca285568 | |||
| fbb9a0b2c7 | |||
| 29b3aa8543 | |||
| 94f3725208 | |||
| 0ee81e87be | |||
| c491a918c2 | |||
| 1e7e46cb82 | |||
| 5ae444d96d | |||
| ee7ad83427 | |||
| 921d7d3364 | |||
| f8e012582a | |||
| def5956cd1 | |||
| abfbaca336 | |||
| 6566188e45 | |||
| 078f9584ed | |||
| ee34c75386 | |||
| e8150428b6 | |||
| 4b38e35bbb | |||
| 435bb14bb2 | |||
| 2e5ced5098 | |||
| f4a846aa4d | |||
| 7c1063177f | |||
| 5878b1ffc5 | |||
| 3c3c2aee0d | |||
| 511223aac4 | |||
| 3b2d64995a | |||
| 13c4517a66 | |||
| 177b6464ca | |||
| 5a9a8363f4 | |||
| 49efd68fc7 | |||
| 90a8a26c6e | |||
| 002c83e6f5 | |||
| 7222910b05 | |||
| 097cb87f6f | |||
| 91c3b1617e | |||
| 8f71922734 | |||
| d140630208 | |||
| 63a3e55297 | |||
| c2e5081d64 | |||
| 4187c6f620 | |||
| e7dbb3afec | |||
| f994dd6211 | |||
| da254ee968 | |||
| 4ad3141875 | |||
| b5f0199a25 | |||
| 6bf88c049e | |||
| 40a33da2a5 | |||
| 3596fc0693 | |||
| 93824dad97 | |||
| e5656af1f2 | |||
| c127c10458 | |||
| 7d1824ea27 | |||
| 2966d27c97 | |||
| 618ec4543e | |||
| 0e4031ae52 | |||
| 442af96ed9 | |||
| a305204cfa | |||
| 75f472e6a7 | |||
| cc32e8f7cb | |||
| 14b3085ff1 | |||
| 5691eee4f1 | |||
| 1520a697ad | |||
| 81b8b0ca4a | |||
| ac3fa3c376 | |||
| 7a1c1cd342 | |||
| 70c32a26fa | |||
| 2b94bb54aa | |||
| 0a6e8146be | |||
| 305b0fdca3 | |||
| d738386fe2 | |||
| ca830d5be7 | |||
| a5bc4524d8 | |||
| 175ee12bbc | |||
| a725220c21 | |||
| a245605152 | |||
| f4a53209f4 | |||
| 877251bcae |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.10",
|
||||
"version": "10.0.11",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '24 2 * * 4'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'csharp' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
@@ -1,159 +0,0 @@
|
||||
name: ABI Compatibility
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
abi-head:
|
||||
name: ABI - HEAD
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: abi-head
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: out/
|
||||
|
||||
abi-base:
|
||||
name: ABI - BASE
|
||||
if: ${{ github.base_ref != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Checkout common ancestor
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
|
||||
git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
||||
git checkout --progress --force $ANCESTOR_REF
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: abi-base
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: out/
|
||||
|
||||
abi-diff:
|
||||
permissions:
|
||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||
|
||||
name: ABI - Difference
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- abi-head
|
||||
- abi-base
|
||||
|
||||
steps:
|
||||
- name: Download abi-head
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: abi-head
|
||||
path: abi-head
|
||||
|
||||
- name: Download abi-base
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: abi-base
|
||||
path: abi-base
|
||||
|
||||
- name: Setup ApiCompat
|
||||
run: |
|
||||
dotnet tool install --global Microsoft.DotNet.ApiCompat.Tool
|
||||
|
||||
- name: Run ApiCompat
|
||||
id: diff
|
||||
run: |
|
||||
{
|
||||
echo 'body<<EOF'
|
||||
for file in Jellyfin.Data.dll MediaBrowser.Common.dll MediaBrowser.Controller.dll MediaBrowser.Model.dll Emby.Naming.dll Jellyfin.Extensions.dll Jellyfin.MediaEncoding.Keyframes.dll Jellyfin.Database.Implementations.dll; do
|
||||
COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 || true )"
|
||||
if [ "APICompat ran successfully without finding any breaking changes." != "${COMPAT_OUTPUT}" ]; then
|
||||
printf "\n${file}\n${COMPAT_OUTPUT}\n"
|
||||
fi
|
||||
done
|
||||
echo EOF
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find difference comment
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
direction: last
|
||||
body-includes: abi-diff-workflow-comment
|
||||
|
||||
- name: Reply or edit difference comment (changed)
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
if: ${{ steps.diff.outputs.body != '' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
body: |
|
||||
<!--abi-diff-workflow-comment-->
|
||||
<details>
|
||||
<summary>ABI Difference</summary>
|
||||
|
||||
```
|
||||
${{ steps.diff.outputs.body }}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
- name: Reply or edit difference comment (unchanged)
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
if: ${{ steps.diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
body: |
|
||||
<!--abi-diff-workflow-comment-->
|
||||
<details>
|
||||
<summary>ABI Difference</summary>
|
||||
|
||||
No changes to the ABI found. See history of this comment for previous changes.
|
||||
|
||||
</details>
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Format
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# Run formatter against the forked branch, but
|
||||
# do not allow access to secrets
|
||||
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflows-in-forked-repositories
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
SDK_VERSION: "10.0.x"
|
||||
|
||||
jobs:
|
||||
format-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
- name: Run DotNet Format
|
||||
run: dotnet format --verify-no-changes --verbosity minimal
|
||||
@@ -1,45 +0,0 @@
|
||||
name: Tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# Run tests against the forked branch, but
|
||||
# do not allow access to secrets
|
||||
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflows-in-forked-repositories
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
SDK_VERSION: "10.0.x"
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
strategy:
|
||||
matrix:
|
||||
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
|
||||
fail-fast: false
|
||||
|
||||
runs-on: "${{ matrix.os }}"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
- name: Run DotNet CLI Tests
|
||||
run: >
|
||||
dotnet test Jellyfin.sln
|
||||
--configuration Release
|
||||
--collect:"XPlat Code Coverage"
|
||||
--settings tests/coverletArgs.runsettings
|
||||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # v5.5.11
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
reporttypes: "Cobertura"
|
||||
|
||||
# TODO - which action / tool to use to publish code coverage results?
|
||||
# - name: Publish code coverage results
|
||||
@@ -1,63 +0,0 @@
|
||||
name: Commands
|
||||
on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
- synchronize
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
rebase:
|
||||
name: Rebase
|
||||
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '@jellyfin-bot rebase') && github.event.comment.author_association == 'MEMBER'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify as seen
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
reactions: '+1'
|
||||
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Automatic Rebase
|
||||
uses: cirrus-actions/rebase@b87d48154a87a85666003575337e27b8cd65f691 # 1.8
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
rename:
|
||||
name: Rename
|
||||
if: contains(github.event.comment.body, '@jellyfin-bot rename')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
- name: install python packages
|
||||
run: pip install -r rename/requirements.txt
|
||||
|
||||
- name: run rename script
|
||||
run: python3 rename.py
|
||||
working-directory: ./rename
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Stale Issue Labeler
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
issues:
|
||||
name: Check for stale issues
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
days-before-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 21
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 500
|
||||
exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed
|
||||
stale-issue-label: stale
|
||||
stale-issue-message: |-
|
||||
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
|
||||
|
||||
If you have any questions you can use one of several ways to [contact us](https://jellyfin.org/contact).
|
||||
close-issue-message: |-
|
||||
This issue was closed due to inactivity.
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Check Issue Template
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
jobs:
|
||||
check_issue:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
- name: install python packages
|
||||
run: pip install -r main-repo-triage/requirements.txt
|
||||
|
||||
- name: check and comment issue
|
||||
working-directory: ./main-repo-triage
|
||||
run: python3 single_issue_gha.py
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
@@ -1,44 +0,0 @@
|
||||
name: OpenAPI Generate
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
repository:
|
||||
required: true
|
||||
type: string
|
||||
artifact:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Main
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
repository: ${{ inputs.repository }}
|
||||
|
||||
- name: Configure .NET
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Create File
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter Jellyfin.Server.Integration.Tests.OpenApiSpecTests
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ inputs.artifact }}
|
||||
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
@@ -1,140 +0,0 @@
|
||||
name: OpenAPI Publish
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish-openapi:
|
||||
name: OpenAPI - Publish Artifact
|
||||
uses: ./.github/workflows/openapi-generate.yml
|
||||
permissions:
|
||||
contents: read
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
repository: ${{ github.repository }}
|
||||
artifact: openapi-head
|
||||
|
||||
publish-unstable:
|
||||
name: OpenAPI - Publish Unstable Spec
|
||||
if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- publish-openapi
|
||||
steps:
|
||||
- name: Set unstable dated version
|
||||
id: version
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Upload openapi.json (unstable) to repository server
|
||||
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
source: openapi-head/openapi.json
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (unstable) into place
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
debug: false
|
||||
script: |
|
||||
if ! test -d /run/workflows; then
|
||||
sudo mkdir -p /run/workflows
|
||||
sudo chown ${{ secrets.REPO_USER }} /run/workflows
|
||||
fi
|
||||
(
|
||||
flock -x -w 300 200 || exit 1
|
||||
TGT_DIR="/srv/repository/main/openapi"
|
||||
LAST_SPEC="$( ls -lt ${TGT_DIR}/unstable/ | grep 'jellyfin-openapi' | head -1 | awk '{ print $NF }' )"
|
||||
# If new and previous spec don't differ (diff retcode 0), remove incoming and finish
|
||||
if diff /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/unstable/${LAST_SPEC} &>/dev/null; then
|
||||
rm -r /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}
|
||||
exit 0
|
||||
fi
|
||||
# Move new spec into place
|
||||
sudo mv /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json
|
||||
# Delete previous jellyfin-openapi-unstable_previous.json
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
# Move current jellyfin-openapi-unstable.json symlink to jellyfin-openapi-unstable_previous.json
|
||||
sudo mv ${TGT_DIR}/jellyfin-openapi-unstable.json ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
# Create new jellyfin-openapi-unstable.json symlink
|
||||
sudo ln -s unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json ${TGT_DIR}/jellyfin-openapi-unstable.json
|
||||
# Check that the previous openapi unstable spec link is correct
|
||||
if [[ "$( readlink ${TGT_DIR}/jellyfin-openapi-unstable_previous.json )" != "unstable/${LAST_SPEC}" ]]; then
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
sudo ln -s unstable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
fi
|
||||
) 200>/run/workflows/openapi-unstable.lock
|
||||
|
||||
publish-stable:
|
||||
name: OpenAPI - Publish Stable Spec
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- publish-openapi
|
||||
steps:
|
||||
- name: Set version number
|
||||
id: version
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Upload openapi.json (stable) to repository server
|
||||
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
source: openapi-head/openapi.json
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (stable) into place
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
debug: false
|
||||
script: |
|
||||
if ! test -d /run/workflows; then
|
||||
sudo mkdir -p /run/workflows
|
||||
sudo chown ${{ secrets.REPO_USER }} /run/workflows
|
||||
fi
|
||||
(
|
||||
flock -x -w 300 200 || exit 1
|
||||
TGT_DIR="/srv/repository/main/openapi"
|
||||
LAST_SPEC="$( ls -lt ${TGT_DIR}/stable/ | grep 'jellyfin-openapi' | head -1 | awk '{ print $NF }' )"
|
||||
# If new and previous spec don't differ (diff retcode 0), remove incoming and finish
|
||||
if diff /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/${LAST_SPEC} &>/dev/null; then
|
||||
rm -r /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}
|
||||
exit 0
|
||||
fi
|
||||
# Move new spec into place
|
||||
sudo mv /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json
|
||||
# Delete previous jellyfin-openapi-stable_previous.json
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
# Move current jellyfin-openapi-stable.json symlink to jellyfin-openapi-stable_previous.json
|
||||
sudo mv ${TGT_DIR}/jellyfin-openapi-stable.json ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
# Create new jellyfin-openapi-stable.json symlink
|
||||
sudo ln -s stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json ${TGT_DIR}/jellyfin-openapi-stable.json
|
||||
# Check that the previous openapi stable spec link is correct
|
||||
if [[ "$( readlink ${TGT_DIR}/jellyfin-openapi-stable_previous.json )" != "stable/${LAST_SPEC}" ]]; then
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
sudo ln -s stable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
fi
|
||||
) 200>/run/workflows/openapi-stable.lock
|
||||
@@ -1,80 +0,0 @@
|
||||
name: OpenAPI Check
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
ancestor:
|
||||
name: Common Ancestor
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
base_ref: ${{ steps.ancestor.outputs.base_ref }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
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: Search History
|
||||
id: ancestor
|
||||
run: |
|
||||
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
|
||||
git fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
|
||||
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} HEAD)
|
||||
|
||||
echo "ref: ${ANCESTOR_REF}"
|
||||
|
||||
echo "base_ref=${ANCESTOR_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
head:
|
||||
name: Head Artifact
|
||||
uses: ./.github/workflows/openapi-generate.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
artifact: openapi-head
|
||||
|
||||
base:
|
||||
name: Base Artifact
|
||||
uses: ./.github/workflows/openapi-generate.yml
|
||||
needs:
|
||||
- ancestor
|
||||
with:
|
||||
ref: ${{ needs.ancestor.outputs.base_ref }}
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
artifact: openapi-base
|
||||
|
||||
diff:
|
||||
name: Generate Report
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- head
|
||||
- base
|
||||
steps:
|
||||
- name: Download Head
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Download Base
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openapi-base
|
||||
path: openapi-base
|
||||
- name: Detect Changes
|
||||
id: openapi-diff
|
||||
run: |
|
||||
sed -i 's:allOf:oneOf:g' openapi-head/openapi.json
|
||||
sed -i 's:allOf:oneOf:g' openapi-base/openapi.json
|
||||
|
||||
mkdir -p /tmp/openapi-report
|
||||
mv openapi-head/openapi.json /tmp/openapi-report/head.json
|
||||
mv openapi-base/openapi.json /tmp/openapi-report/base.json
|
||||
|
||||
docker run -v /tmp/openapi-report:/data openapitools/openapi-diff:2.1.6 /data/base.json /data/head.json --state -l ERROR --markdown /data/openapi-report.md
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: openapi-report
|
||||
path: /tmp/openapi-report/openapi-report.md
|
||||
@@ -1,59 +0,0 @@
|
||||
name: OpenAPI Report
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- OpenAPI Check
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
name: Generate Metadata
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
outputs:
|
||||
pr_number: ${{ steps.pr_number.outputs.pr_number }}
|
||||
steps:
|
||||
- name: Get Pull Request Number
|
||||
id: pr_number
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
API_RESPONSE=$(gh pr list --repo "${GITHUB_REPOSITORY}" --search "${HEAD_SHA}" --state open --json number)
|
||||
PR_NUMBER=$(echo "${API_RESPONSE}" | jq '.[0].number')
|
||||
|
||||
echo "repository: ${GITHUB_REPOSITORY}"
|
||||
echo "sha: ${HEAD_SHA}"
|
||||
echo "response: ${API_RESPONSE}"
|
||||
echo "pr: ${PR_NUMBER}"
|
||||
|
||||
echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
comment:
|
||||
name: Pull Request Comment
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
needs:
|
||||
- metadata
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Download OpenAPI Report
|
||||
id: download_report
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openapi-report
|
||||
path: openapi-report
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Push Comment
|
||||
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
file-path: ${{ steps.download_report.outputs.download-path }}/openapi-report.md
|
||||
pr-number: ${{ needs.metadata.outputs.pr_number }}
|
||||
comment-tag: openapi-report
|
||||
@@ -1,66 +0,0 @@
|
||||
name: Project Automation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
issue_comment:
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
project:
|
||||
name: Project board
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin' }}
|
||||
steps:
|
||||
- name: Remove from 'Current Release' project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Current Release
|
||||
action: delete
|
||||
column: In progress
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Add to 'Release Next' project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened'
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Release Next
|
||||
column: In progress
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Add to 'Current Release' project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Current Release
|
||||
column: In progress
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Check number of comments from the team member
|
||||
if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER'
|
||||
id: member_comments
|
||||
run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)"
|
||||
|
||||
- name: Move issue to needs triage
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Issue Triage for Main Repo
|
||||
column: Needs triage
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Add issue to triage project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: github.event.issue.pull_request == '' && github.event.action == 'opened'
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Issue Triage for Main Repo
|
||||
column: Pending response
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
@@ -1,24 +0,0 @@
|
||||
name: Merge Conflict Labeler
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
types: [synchronize]
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin' }}
|
||||
steps:
|
||||
- name: Apply label
|
||||
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||
with:
|
||||
dirtyLabel: 'merge conflict'
|
||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Stale PR Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 */12 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
prs-stale-conflicts:
|
||||
name: Check PRs with merge conflicts
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
operations-per-run: 150
|
||||
# The merge conflict action will remove the label when updated
|
||||
remove-stale-when-updated: false
|
||||
days-before-stale: -1
|
||||
days-before-close: 90
|
||||
days-before-issue-close: -1
|
||||
stale-pr-label: merge conflict
|
||||
close-pr-message: |-
|
||||
This PR has been closed due to having unresolved merge conflicts.
|
||||
@@ -1,82 +0,0 @@
|
||||
name: '🆙 Auto bump_version'
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
TAG_BRANCH:
|
||||
required: true
|
||||
description: release-x.y.z
|
||||
NEXT_VERSION:
|
||||
required: true
|
||||
description: x.y.z
|
||||
|
||||
jobs:
|
||||
auto_bump_version:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' && !contains(github.event.release.tag_name, 'rc') }}
|
||||
env:
|
||||
TAG_BRANCH: ${{ github.event.release.target_commitish }}
|
||||
steps:
|
||||
- name: Wait for deploy checks to finish
|
||||
uses: jitterbit/await-check-suites@292a541bb7618078395b2ce711a0d89cfb8a568a # v1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
intervalSeconds: 60
|
||||
timeoutSeconds: 3600
|
||||
|
||||
- name: Setup YQ
|
||||
uses: chrisdickinson/setup-yq@fa3192edd79d6eb0e4e12de8dde3a0c26f2b853b # latest
|
||||
with:
|
||||
yq-version: v4.9.8
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
- name: Setup EnvVars
|
||||
run: |-
|
||||
CURRENT_VERSION=$(yq e '.version' build.yaml)
|
||||
CURRENT_MAJOR_MINOR=${CURRENT_VERSION%.*}
|
||||
CURRENT_PATCH=${CURRENT_VERSION##*.}
|
||||
echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_ENV
|
||||
echo "CURRENT_MAJOR_MINOR=${CURRENT_MAJOR_MINOR}" >> $GITHUB_ENV
|
||||
echo "CURRENT_PATCH=${CURRENT_PATCH}" >> $GITHUB_ENV
|
||||
echo "NEXT_VERSION=${CURRENT_MAJOR_MINOR}.$(($CURRENT_PATCH + 1))" >> $GITHUB_ENV
|
||||
|
||||
- name: Run bump_version
|
||||
run: ./bump_version ${{ env.NEXT_VERSION }}
|
||||
|
||||
- name: Commit Changes
|
||||
run: |-
|
||||
git config user.name "jellyfin-bot"
|
||||
git config user.email "team@jellyfin.org"
|
||||
git checkout ${{ env.TAG_BRANCH }}
|
||||
git commit -am "Bump version to ${{ env.NEXT_VERSION }}"
|
||||
git push origin ${{ env.TAG_BRANCH }}
|
||||
|
||||
manual_bump_version:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
env:
|
||||
TAG_BRANCH: ${{ github.event.inputs.TAG_BRANCH }}
|
||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
- name: Run bump_version
|
||||
run: ./bump_version ${{ env.NEXT_VERSION }}
|
||||
|
||||
- name: Commit Changes
|
||||
run: |-
|
||||
git config user.name "jellyfin-bot"
|
||||
git config user.email "team@jellyfin.org"
|
||||
git checkout ${{ env.TAG_BRANCH }}
|
||||
git commit -am "Bump version to ${{ env.NEXT_VERSION }}"
|
||||
git push origin ${{ env.TAG_BRANCH }}
|
||||
@@ -0,0 +1,79 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
|
||||
steps:
|
||||
# Restore, build and test in one step so the NuGet cache stays on the step's
|
||||
# own ephemeral storage instead of the 10Gi workspace volume.
|
||||
# global.json pins the .NET 10 SDK (10.0.0, rollForward latestMinor).
|
||||
- name: build-test
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
environment:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
commands:
|
||||
- dotnet --info
|
||||
- dotnet restore Jellyfin.sln
|
||||
- dotnet build Jellyfin.sln -c Release --no-restore
|
||||
# libSkiaSharp needs fontconfig to load and the SDK image does not ship it.
|
||||
# The mirror occasionally serves a half-synced index, so update is retried.
|
||||
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
|
||||
- apt-get install -y --no-install-recommends libfontconfig1
|
||||
- ldconfig -p | grep -q libfontconfig
|
||||
# BackupServiceTests is excluded because BackupService refuses to write a
|
||||
# backup with less than 5GiB free on its target path. The clone and build
|
||||
# output fill 5.7G of the 9.8G workspace volume, leaving 4.1G. Raising the
|
||||
# agent's workspace volume size is the only way to run it here.
|
||||
- df -h .
|
||||
- dotnet test Jellyfin.sln -c Release --no-build --verbosity minimal --filter "Category!=RequiresDocker&FullyQualifiedName!~Integration&FullyQualifiedName!~BackupServiceTests"
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: jellyfin-ha-src
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
ephemeral-storage: 10Gi
|
||||
limits:
|
||||
memory: 8Gi
|
||||
cpu: 4
|
||||
ephemeral-storage: 20Gi
|
||||
|
||||
# The PostgreSQL migration tests are the only ones that run the startup migration chain against
|
||||
# the provider production uses, and the filter above has always skipped them.
|
||||
# The server runs inside this step: the kubernetes backend has no docker daemon for
|
||||
# testcontainers, and a postgres service container deadlocks the step because the backend mounts
|
||||
# the ReadWriteOnce workspace volume into service pods and schedules them on another node.
|
||||
# Its data directory lives on the step's ephemeral storage, not on the workspace volume.
|
||||
# Scoped to Jellyfin.Server.Tests: the three classes in Jellyfin.Database.Tests.PostgreSQL still
|
||||
# start their own container, and PostgreSqlProviderTests already fails on main - on an EF 10
|
||||
# scalar query and on its own data - which a third test in the class then inherits.
|
||||
- name: postgres-migration-chain
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
depends_on:
|
||||
- build-test
|
||||
environment:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
JELLYFIN_TEST_POSTGRES: "Host=127.0.0.1;Port=5432;Database=postgres;Username=postgres"
|
||||
commands:
|
||||
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
|
||||
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql
|
||||
- install -d -o postgres -g postgres /tmp/pgdata /tmp/pgrun
|
||||
- PGBIN=$(ls -d /usr/lib/postgresql/*/bin | tail -1)
|
||||
- su postgres -c "$PGBIN/initdb -D /tmp/pgdata -A trust -U postgres"
|
||||
- su postgres -c "$PGBIN/pg_ctl -D /tmp/pgdata -o \"-c listen_addresses=127.0.0.1 -k /tmp/pgrun\" -l /tmp/pg.log -w start"
|
||||
- dotnet build tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release
|
||||
- dotnet test tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker"
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: jellyfin-ha-src
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
ephemeral-storage: 6Gi
|
||||
limits:
|
||||
memory: 6Gi
|
||||
cpu: 4
|
||||
ephemeral-storage: 12Gi
|
||||
+5
-1
@@ -143,6 +143,7 @@
|
||||
- [Smith00101010](https://github.com/Smith00101010)
|
||||
- [sorinyo2004](https://github.com/sorinyo2004)
|
||||
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
|
||||
- [st7105](https://github.com/st7105)
|
||||
- [sparky8251](https://github.com/sparky8251)
|
||||
- [spookbits](https://github.com/spookbits)
|
||||
- [ssenart](https://github.com/ssenart)
|
||||
@@ -237,6 +238,9 @@
|
||||
- [elio42](https://github.com/elio42)
|
||||
- [rwebster85](https://github.com/rwebster85)
|
||||
- [Florin-Popescu](https://github.com/Florin-Popescu)
|
||||
- [m0g3r](https://github.com/m0g3r)
|
||||
- [martin-77](https://github.com/martin-77)
|
||||
- [Oggeb1](https://github.com/Oggeb1)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
@@ -247,7 +251,7 @@
|
||||
- [Mark2xv](https://github.com/Mark2xv)
|
||||
- [ScottRapsey](https://github.com/ScottRapsey)
|
||||
- [skynet600](https://github.com/skynet600)
|
||||
- [Cheesegeezer](https://githum.com/Cheesegeezer)
|
||||
- [Cheesegeezer](https://github.com/Cheesegeezer)
|
||||
- [Radeon](https://github.com/radeonorama)
|
||||
- [gcw07](https://github.com/gcw07)
|
||||
- [SivaramAdhiappan](https://github.com/shivaram1190)
|
||||
|
||||
+32
-25
@@ -8,8 +8,9 @@
|
||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
|
||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||
<PackageVersion Include="AWSSDK.S3" Version="4.0.103.1" />
|
||||
<PackageVersion Include="BDInfo" Version="0.8.0" />
|
||||
<PackageVersion Include="BitFaster.Caching" Version="2.6.0" />
|
||||
<PackageVersion Include="BitFaster.Caching" Version="2.6.1" />
|
||||
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
|
||||
@@ -26,33 +27,36 @@
|
||||
<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.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
|
||||
<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="Microsoft.CodeAnalysis.Common" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<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="Npgsql" Version="10.0.3" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageVersion Include="PDFtoImage" Version="5.2.1" />
|
||||
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
@@ -74,15 +78,18 @@
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||
<PackageVersion Include="StackExchange.Redis" Version="2.13.17" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<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.10" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
|
||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
|
||||
<PackageVersion Include="Testcontainers.Redis" Version="4.15.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.16.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.7.0" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageVersion Include="Xunit.v3.Priority" Version="1.1.18" />
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Restore dependencies first (layer-cache friendly)
|
||||
COPY ["Jellyfin.sln", "global.json", "nuget.config", "Directory.Build.props", "Directory.Packages.props", "./"]
|
||||
COPY ["SharedVersion.cs", "BannedSymbols.txt", "stylecop.json", "./"]
|
||||
|
||||
# Copy all project files so dotnet restore can resolve the full dependency graph
|
||||
COPY Emby.Naming/ Emby.Naming/
|
||||
COPY Emby.Photos/ Emby.Photos/
|
||||
COPY Emby.Server.Implementations/ Emby.Server.Implementations/
|
||||
COPY Jellyfin.Api/ Jellyfin.Api/
|
||||
COPY Jellyfin.Data/ Jellyfin.Data/
|
||||
COPY Jellyfin.Server/ Jellyfin.Server/
|
||||
COPY Jellyfin.Server.Implementations/ Jellyfin.Server.Implementations/
|
||||
COPY MediaBrowser.Common/ MediaBrowser.Common/
|
||||
COPY MediaBrowser.Controller/ MediaBrowser.Controller/
|
||||
COPY MediaBrowser.LocalMetadata/ MediaBrowser.LocalMetadata/
|
||||
COPY MediaBrowser.MediaEncoding/ MediaBrowser.MediaEncoding/
|
||||
COPY MediaBrowser.Model/ MediaBrowser.Model/
|
||||
COPY MediaBrowser.Providers/ MediaBrowser.Providers/
|
||||
COPY MediaBrowser.XbmcMetadata/ MediaBrowser.XbmcMetadata/
|
||||
COPY src/ src/
|
||||
|
||||
RUN dotnet restore Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
--runtime linux-x64
|
||||
|
||||
# Publish the server (and all transitive dependencies, including the
|
||||
# PostgreSQL provider assembly added by this fork).
|
||||
# Note: TreatWarningsAsErrors is disabled for the Docker build — StyleCop
|
||||
# analyzer violations in upstream src/ projects would otherwise block the
|
||||
# image build. StyleCop is enforced in the CI pipeline, not the Dockerfile.
|
||||
RUN dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
--configuration Release \
|
||||
--runtime linux-x64 \
|
||||
--self-contained false \
|
||||
--no-restore \
|
||||
-p:TreatWarningsAsErrors=false \
|
||||
--output /app
|
||||
|
||||
# ── Web client stage ──────────────────────────────────────────────────────────
|
||||
# Install jellyfin-web via the official Jellyfin apt repo.
|
||||
# Package suffix in the bookworm repo is +deb12 (e.g. 12.0+deb12).
|
||||
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
|
||||
# The web client version must match the server version this fork is based on.
|
||||
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
|
||||
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
|
||||
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
|
||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
|
||||
> /etc/apt/sources.list.d/jellyfin.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends "jellyfin-web=12.0+deb12" \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& echo "Web client files:" && ls /usr/share/jellyfin/web/ | head -10
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
|
||||
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
|
||||
# libicu, libssl, and liblttng-ust are already present in the dotnet/aspnet base image.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
fontconfig \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /jellyfin
|
||||
|
||||
COPY --from=build /app .
|
||||
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
|
||||
|
||||
# Jellyfin default ports
|
||||
EXPOSE 8096
|
||||
EXPOSE 8920
|
||||
|
||||
# Data / config volumes
|
||||
VOLUME ["/config", "/cache", "/media"]
|
||||
|
||||
ENV JELLYFIN_DATA_DIR=/config \
|
||||
JELLYFIN_CACHE_DIR=/cache \
|
||||
JELLYFIN_LOG_DIR=/config/log \
|
||||
JELLYFIN_CONFIG_DIR=/config
|
||||
|
||||
ENTRYPOINT ["./jellyfin", \
|
||||
"--datadir", "/config", \
|
||||
"--cachedir", "/cache", \
|
||||
"--webdir", "/jellyfin/jellyfin-web"]
|
||||
@@ -0,0 +1,55 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Runtime-only image — the .NET publish step runs on the CI host (runner),
|
||||
# not inside this Dockerfile.
|
||||
|
||||
# ── Web client stage ──────────────────────────────────────────────────────────
|
||||
# Install jellyfin-web via the official Jellyfin apt repo.
|
||||
# Package suffix in the bookworm repo is +deb12 (e.g. 12.0+deb12).
|
||||
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
|
||||
# The web client version must match the server version this fork is based on.
|
||||
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
|
||||
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
|
||||
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
|
||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
|
||||
> /etc/apt/sources.list.d/jellyfin.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends "jellyfin-web=12.0+deb12" \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
|
||||
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
fontconfig \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /jellyfin
|
||||
|
||||
# Copy the pre-built publish output produced by `dotnet publish` on the CI host.
|
||||
COPY publish-output/ .
|
||||
# Copy the jellyfin-web client assets from the webclient stage.
|
||||
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
|
||||
|
||||
# Jellyfin default ports
|
||||
EXPOSE 8096
|
||||
EXPOSE 8920
|
||||
|
||||
# Data / config volumes
|
||||
VOLUME ["/config", "/cache", "/media"]
|
||||
|
||||
ENV JELLYFIN_DATA_DIR=/config \
|
||||
JELLYFIN_CACHE_DIR=/cache \
|
||||
JELLYFIN_LOG_DIR=/config/log
|
||||
|
||||
ENTRYPOINT ["./jellyfin", \
|
||||
"--datadir", "/config", \
|
||||
"--cachedir", "/cache", \
|
||||
"--webdir", "/jellyfin/jellyfin-web"]
|
||||
@@ -151,7 +151,7 @@ namespace Emby.Naming.Common
|
||||
|
||||
CleanStrings =
|
||||
[
|
||||
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS)(?=[ _\,\.\(\)\[\]\-]|$)",
|
||||
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|web-dl|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS)(?=[ _\,\.\(\)\[\]\-]|$)",
|
||||
@"^\s*(?<cleaned>.+?)((\s*\[[^\]]+\]\s*)+)(\.[^\s]+)?$",
|
||||
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
|
||||
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
|
||||
|
||||
@@ -44,7 +44,14 @@ namespace Emby.Naming.ExternalFiles
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path.AsSpan());
|
||||
if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
// .idx carries VobSub per-track language metadata. Recognize it here rather
|
||||
// than adding it to NamingOptions.SubtitleFileExtensions, which also gates
|
||||
// subtitle uploads/saves.
|
||||
var isVobSubIndex = _type == DlnaProfileType.Subtitle && extension.Equals(".idx", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!isVobSubIndex
|
||||
&& !(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||
&& !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||
&& !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
|
||||
@@ -158,7 +158,9 @@ namespace Emby.Naming.TV
|
||||
if (nextIndex >= name.Length
|
||||
|| !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal))
|
||||
{
|
||||
if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
|
||||
// A range cannot end before it starts, so a lower number belongs to the episode title rather than to a range.
|
||||
if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)
|
||||
&& num >= result.EpisodeNumber)
|
||||
{
|
||||
result.EndingEpisodeNumber = num;
|
||||
}
|
||||
@@ -226,7 +228,7 @@ namespace Emby.Naming.TV
|
||||
info.SeriesName = result.SeriesName;
|
||||
}
|
||||
|
||||
if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue)
|
||||
if (!info.EndingEpisodeNumber.HasValue && result.EndingEpisodeNumber >= info.EpisodeNumber)
|
||||
{
|
||||
info.EndingEpisodeNumber = result.EndingEpisodeNumber;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,8 @@ namespace Emby.Server.Implementations.Collections
|
||||
SaveLocalMetadata = true
|
||||
};
|
||||
|
||||
var name = _localizationManager.GetLocalizedString("Collections");
|
||||
// This names a library for the whole server, so ignore the requesting client's language.
|
||||
var name = _localizationManager.GetServerLocalizedString("Collections");
|
||||
|
||||
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -185,6 +185,13 @@ namespace Emby.Server.Implementations.Dto
|
||||
allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList();
|
||||
}
|
||||
|
||||
// Batch-fetch by-name item counts to avoid N+1 queries
|
||||
Dictionary<Guid, ItemCounts>? itemCountsBatch = null;
|
||||
if (options.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
itemCountsBatch = GetItemCountsBatch(accessibleItems, user);
|
||||
}
|
||||
|
||||
// Batch-fetch child counts for all folders to avoid N+1 queries
|
||||
Dictionary<Guid, int>? childCountBatch = null;
|
||||
if (options.ContainsField(ItemFields.ChildCount))
|
||||
@@ -192,7 +199,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
|
||||
if (folderIds.Count > 0)
|
||||
{
|
||||
childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id);
|
||||
childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +300,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
SetItemByNameInfo(dto, user);
|
||||
SetItemByNameInfo(dto, user, itemCountsBatch);
|
||||
}
|
||||
|
||||
returnItems[index] = dto;
|
||||
@@ -518,14 +525,36 @@ namespace Emby.Server.Implementations.Dto
|
||||
return dto;
|
||||
}
|
||||
|
||||
private void SetItemByNameInfo(BaseItemDto dto, User? user)
|
||||
private Dictionary<Guid, ItemCounts> GetItemCountsBatch(IReadOnlyList<BaseItem> items, User? user)
|
||||
{
|
||||
var result = new Dictionary<Guid, ItemCounts>();
|
||||
|
||||
foreach (var group in items.GroupBy(item => item.GetBaseItemKind()))
|
||||
{
|
||||
if (!_relatedItemKinds.TryGetValue(group.Key, out var relatedItemKinds))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var ids = group.Select(item => item.Id).ToArray();
|
||||
foreach (var (id, counts) in _libraryManager.GetItemCountsForNameItems(group.Key, ids, relatedItemKinds, user))
|
||||
{
|
||||
result[id] = counts;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void SetItemByNameInfo(BaseItemDto dto, User? user, IReadOnlyDictionary<Guid, ItemCounts>? prefetchedCounts = null)
|
||||
{
|
||||
if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var counts = _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user);
|
||||
var counts = prefetchedCounts?.GetValueOrDefault(dto.Id)
|
||||
?? _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user);
|
||||
|
||||
dto.AlbumCount = counts.AlbumCount;
|
||||
dto.ArtistCount = counts.ArtistCount;
|
||||
@@ -611,7 +640,11 @@ namespace Emby.Server.Implementations.Dto
|
||||
// For these types we can try to optimize and assume these values will be equal
|
||||
if (item is MusicAlbum || item is Season || item is Playlist)
|
||||
{
|
||||
dto.ChildCount = dto.RecursiveItemCount;
|
||||
if (dto.RecursiveItemCount > 0)
|
||||
{
|
||||
dto.ChildCount = dto.RecursiveItemCount;
|
||||
}
|
||||
|
||||
var folderChildCount = folder.LinkedChildren.Length;
|
||||
// The default is an empty array, so we can't reliably use the count when it's empty
|
||||
if (folderChildCount > 0)
|
||||
@@ -696,7 +729,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
return count;
|
||||
}
|
||||
|
||||
// Fall back to individual query for special cases (Series, Season, etc.)
|
||||
// Only reached when no batch was computed: the batch holds an entry for every folder it
|
||||
// was asked about, zero included.
|
||||
return folder.GetChildCount(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Ignore" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -27,6 +27,11 @@ namespace Emby.Server.Implementations.EntryPoints;
|
||||
/// </summary>
|
||||
public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
{
|
||||
// A batch holds a live reference to every item it names, so it has to stay small enough that a
|
||||
// library scan - which changes items faster than any batch window closes - cannot grow it without
|
||||
// bound. Reached only by a scan; interactive use closes a batch on the window long before this.
|
||||
internal const int MaxBatchSize = 2000;
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IServerConfigurationManager _configurationManager;
|
||||
private readonly IProviderManager _providerManager;
|
||||
@@ -35,11 +40,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
private readonly ILogger<LibraryChangedNotifier> _logger;
|
||||
|
||||
private readonly Lock _libraryChangedSyncLock = new();
|
||||
private readonly List<Folder> _foldersAddedTo = new();
|
||||
private readonly List<Folder> _foldersRemovedFrom = new();
|
||||
private readonly List<BaseItem> _itemsAdded = new();
|
||||
private readonly List<BaseItem> _itemsRemoved = new();
|
||||
private readonly List<BaseItem> _itemsUpdated = new();
|
||||
private readonly Dictionary<Guid, Folder> _foldersAddedTo = [];
|
||||
private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = [];
|
||||
private readonly Dictionary<Guid, BaseItem> _itemsAdded = [];
|
||||
private readonly Dictionary<Guid, BaseItem> _itemsRemoved = [];
|
||||
private readonly Dictionary<Guid, BaseItem> _itemsUpdated = [];
|
||||
private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new();
|
||||
|
||||
private Timer? _libraryUpdateTimer;
|
||||
@@ -173,7 +178,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e)
|
||||
=> OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom);
|
||||
|
||||
private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList)
|
||||
private void OnLibraryChange(BaseItem item, BaseItem parent, Dictionary<Guid, BaseItem> itemsList, Dictionary<Guid, Folder>? foldersList)
|
||||
{
|
||||
if (!FilterItem(item))
|
||||
{
|
||||
@@ -182,23 +187,28 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
|
||||
lock (_libraryChangedSyncLock)
|
||||
{
|
||||
var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
|
||||
|
||||
// The window runs from the first change of a batch and is never extended. Extending it on
|
||||
// every change would keep a library scan's batch open for the whole scan, and the batch
|
||||
// holds the items it names alive, so it would grow to the size of the library.
|
||||
if (_libraryUpdateTimer is null)
|
||||
{
|
||||
var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
|
||||
_libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
else
|
||||
{
|
||||
_libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
if (foldersList is not null && parent is Folder folder)
|
||||
{
|
||||
foldersList.Add(folder);
|
||||
foldersList[folder.Id] = folder;
|
||||
}
|
||||
|
||||
itemsList.Add(item);
|
||||
itemsList[item.Id] = item;
|
||||
|
||||
// A window long enough to cover a burst still has to give way once the batch is large
|
||||
// enough to be worth sending on its own.
|
||||
if (_itemsAdded.Count + _itemsRemoved.Count + _itemsUpdated.Count >= MaxBatchSize)
|
||||
{
|
||||
_libraryUpdateTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,22 +221,16 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
List<BaseItem> itemsRemoved;
|
||||
lock (_libraryChangedSyncLock)
|
||||
{
|
||||
// Remove dupes in case some were saved multiple times
|
||||
foldersAddedTo = _foldersAddedTo
|
||||
.DistinctBy(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
foldersRemovedFrom = _foldersRemovedFrom
|
||||
.DistinctBy(x => x.Id)
|
||||
.ToList();
|
||||
foldersAddedTo = _foldersAddedTo.Values.ToList();
|
||||
foldersRemovedFrom = _foldersRemovedFrom.Values.ToList();
|
||||
|
||||
itemsUpdated = _itemsUpdated
|
||||
.Where(i => !_itemsAdded.Contains(i))
|
||||
.DistinctBy(x => x.Id)
|
||||
.Where(e => !_itemsAdded.ContainsKey(e.Key))
|
||||
.Select(e => e.Value)
|
||||
.ToList();
|
||||
|
||||
itemsAdded = _itemsAdded.ToList();
|
||||
itemsRemoved = _itemsRemoved.ToList();
|
||||
itemsAdded = _itemsAdded.Values.ToList();
|
||||
itemsRemoved = _itemsRemoved.Values.ToList();
|
||||
|
||||
if (_libraryUpdateTimer is not null)
|
||||
{
|
||||
@@ -241,6 +245,15 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
_foldersRemovedFrom.Clear();
|
||||
}
|
||||
|
||||
if (itemsAdded.Count == 0
|
||||
&& itemsUpdated.Count == 0
|
||||
&& itemsRemoved.Count == 0
|
||||
&& foldersAddedTo.Count == 0
|
||||
&& foldersRemovedFrom.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,15 +18,17 @@ namespace Emby.Server.Implementations.EntryPoints
|
||||
public sealed class UserDataChangeNotifier : IHostedService, IDisposable
|
||||
{
|
||||
private const int UpdateDuration = 500;
|
||||
internal const int MaxBatchSize = 2000;
|
||||
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new();
|
||||
private readonly Dictionary<Guid, Dictionary<Guid, BaseItem>> _changedItems = [];
|
||||
private readonly Lock _syncLock = new();
|
||||
|
||||
private Timer? _updateTimer;
|
||||
private int _changedItemCount;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class.
|
||||
@@ -69,50 +71,64 @@ namespace Emby.Server.Implementations.EntryPoints
|
||||
|
||||
lock (_syncLock)
|
||||
{
|
||||
if (_updateTimer is null)
|
||||
{
|
||||
_updateTimer = new Timer(
|
||||
UpdateTimerCallback,
|
||||
null,
|
||||
UpdateDuration,
|
||||
Timeout.Infinite);
|
||||
}
|
||||
else
|
||||
{
|
||||
_updateTimer.Change(UpdateDuration, Timeout.Infinite);
|
||||
}
|
||||
// The window runs from the first change of a batch and is never extended, so a stream
|
||||
// of changes that never pauses - a library scan - still closes its batches instead of
|
||||
// holding every item it touched alive until the stream stops.
|
||||
_updateTimer ??= new Timer(
|
||||
UpdateTimerCallback,
|
||||
null,
|
||||
UpdateDuration,
|
||||
Timeout.Infinite);
|
||||
|
||||
if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys))
|
||||
if (!_changedItems.TryGetValue(e.UserId, out Dictionary<Guid, BaseItem>? keys))
|
||||
{
|
||||
keys = new List<BaseItem>();
|
||||
keys = [];
|
||||
_changedItems[e.UserId] = keys;
|
||||
}
|
||||
|
||||
keys.Add(e.Item);
|
||||
|
||||
var baseItem = e.Item;
|
||||
|
||||
// Go up one level for indicators
|
||||
if (baseItem is not null)
|
||||
{
|
||||
Track(keys, baseItem);
|
||||
|
||||
var parent = baseItem.GetOwner() ?? baseItem.GetParent();
|
||||
|
||||
if (parent is not null)
|
||||
{
|
||||
keys.Add(parent);
|
||||
Track(keys, parent);
|
||||
}
|
||||
}
|
||||
|
||||
// A window long enough to cover a burst still has to give way once the batch is
|
||||
// large enough to be worth sending on its own.
|
||||
if (_changedItemCount >= MaxBatchSize)
|
||||
{
|
||||
_updateTimer.Change(0, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Track(Dictionary<Guid, BaseItem> keys, BaseItem item)
|
||||
{
|
||||
var before = keys.Count;
|
||||
keys[item.Id] = item;
|
||||
|
||||
if (keys.Count != before)
|
||||
{
|
||||
_changedItemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private async void UpdateTimerCallback(object? state)
|
||||
{
|
||||
List<KeyValuePair<Guid, List<BaseItem>>> changes;
|
||||
List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes;
|
||||
lock (_syncLock)
|
||||
{
|
||||
// Remove dupes in case some were saved multiple times
|
||||
changes = _changedItems.ToList();
|
||||
_changedItems.Clear();
|
||||
_changedItemCount = 0;
|
||||
|
||||
if (_updateTimer is not null)
|
||||
{
|
||||
@@ -121,17 +137,22 @@ namespace Emby.Server.Implementations.EntryPoints
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (userId, changedItems) in changes)
|
||||
{
|
||||
await _sessionManager.SendMessageToUserSessions(
|
||||
[userId],
|
||||
SessionMessageType.UserDataChanged,
|
||||
() => GetUserDataChangeInfo(userId, changedItems),
|
||||
() => GetUserDataChangeInfo(userId, changedItems.Values),
|
||||
default).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems)
|
||||
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems)
|
||||
{
|
||||
var user = _userManager.GetUserById(userId)
|
||||
?? throw new ArgumentException("Invalid user ID", nameof(userId));
|
||||
@@ -140,7 +161,6 @@ namespace Emby.Server.Implementations.EntryPoints
|
||||
{
|
||||
UserId = userId,
|
||||
UserDataList = changedItems
|
||||
.DistinctBy(x => x.Id)
|
||||
.Select(i =>
|
||||
{
|
||||
var dto = _userDataManager.GetUserDataDto(i, user);
|
||||
|
||||
@@ -8,6 +8,7 @@ using Emby.Server.Implementations.Library;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -21,6 +22,7 @@ namespace Emby.Server.Implementations.IO
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IServerConfigurationManager _configurationManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||
|
||||
/// <summary>
|
||||
@@ -47,6 +49,7 @@ namespace Emby.Server.Implementations.IO
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="configurationManager">The configuration manager.</param>
|
||||
/// <param name="fileSystem">The filesystem.</param>
|
||||
/// <param name="directoryService">The directory service.</param>
|
||||
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
|
||||
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||
public LibraryMonitor(
|
||||
@@ -54,6 +57,7 @@ namespace Emby.Server.Implementations.IO
|
||||
ILibraryManager libraryManager,
|
||||
IServerConfigurationManager configurationManager,
|
||||
IFileSystem fileSystem,
|
||||
IDirectoryService directoryService,
|
||||
IHostApplicationLifetime appLifetime,
|
||||
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
|
||||
{
|
||||
@@ -61,6 +65,7 @@ namespace Emby.Server.Implementations.IO
|
||||
_logger = logger;
|
||||
_configurationManager = configurationManager;
|
||||
_fileSystem = fileSystem;
|
||||
_directoryService = directoryService;
|
||||
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||
|
||||
appLifetime.ApplicationStarted.Register(Start);
|
||||
@@ -363,6 +368,8 @@ namespace Emby.Server.Implementations.IO
|
||||
return;
|
||||
}
|
||||
|
||||
_directoryService.Invalidate(path);
|
||||
|
||||
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
||||
foreach (var i in _tempIgnoredPaths.Keys)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -15,7 +16,6 @@ using Emby.Naming.Common;
|
||||
using Emby.Naming.TV;
|
||||
using Emby.Naming.Video;
|
||||
using Emby.Server.Implementations.Library.Resolvers;
|
||||
using Emby.Server.Implementations.Library.Validators;
|
||||
using Emby.Server.Implementations.Playlists;
|
||||
using Emby.Server.Implementations.ScheduledTasks.Tasks;
|
||||
using Emby.Server.Implementations.Sorting;
|
||||
@@ -35,7 +35,6 @@ using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Playlists;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
@@ -75,7 +74,6 @@ namespace Emby.Server.Implementations.Library
|
||||
private readonly Lazy<IProviderManager> _providerManagerFactory;
|
||||
private readonly Lazy<IUserViewManager> _userViewManagerFactory;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IItemRepository _itemRepository;
|
||||
private readonly IItemPersistenceService _persistenceService;
|
||||
@@ -88,6 +86,7 @@ namespace Emby.Server.Implementations.Library
|
||||
private readonly ExtraResolver _extraResolver;
|
||||
private readonly IPathManager _pathManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
|
||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||
private readonly IMediaStreamRepository _mediaStreamRepository;
|
||||
@@ -122,7 +121,6 @@ namespace Emby.Server.Implementations.Library
|
||||
/// <param name="fileSystem">The file system.</param>
|
||||
/// <param name="providerManagerFactory">The provider manager.</param>
|
||||
/// <param name="userViewManagerFactory">The user view manager.</param>
|
||||
/// <param name="mediaEncoder">The media encoder.</param>
|
||||
/// <param name="itemRepository">The item repository.</param>
|
||||
/// <param name="persistenceService">The item persistence service.</param>
|
||||
/// <param name="nextUpService">The next up service.</param>
|
||||
@@ -148,7 +146,6 @@ namespace Emby.Server.Implementations.Library
|
||||
IFileSystem fileSystem,
|
||||
Lazy<IProviderManager> providerManagerFactory,
|
||||
Lazy<IUserViewManager> userViewManagerFactory,
|
||||
IMediaEncoder mediaEncoder,
|
||||
IItemRepository itemRepository,
|
||||
IItemPersistenceService persistenceService,
|
||||
INextUpService nextUpService,
|
||||
@@ -174,7 +171,6 @@ namespace Emby.Server.Implementations.Library
|
||||
_fileSystem = fileSystem;
|
||||
_providerManagerFactory = providerManagerFactory;
|
||||
_userViewManagerFactory = userViewManagerFactory;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_itemRepository = itemRepository;
|
||||
_persistenceService = persistenceService;
|
||||
_nextUpService = nextUpService;
|
||||
@@ -189,6 +185,7 @@ namespace Emby.Server.Implementations.Library
|
||||
_pathManager = pathManager;
|
||||
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||
_localization = localization;
|
||||
_directoryService = directoryService;
|
||||
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
|
||||
|
||||
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
|
||||
@@ -1209,6 +1206,12 @@ namespace Emby.Server.Implementations.Library
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid GetPersonId(string name)
|
||||
{
|
||||
return GetItemByNameId<Person>(Person.GetPath(name));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? GetPerson(string name)
|
||||
{
|
||||
@@ -1222,6 +1225,33 @@ namespace Emby.Server.Implementations.Library
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person GetOrCreatePerson(string name)
|
||||
{
|
||||
var existing = GetPerson(name);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var path = Person.GetPath(name);
|
||||
var info = Directory.CreateDirectory(path);
|
||||
var item = new Person
|
||||
{
|
||||
Name = name,
|
||||
Id = GetItemByNameId<Person>(path),
|
||||
DateCreated = info.CreationTimeUtc,
|
||||
DateModified = info.LastWriteTimeUtc,
|
||||
Path = path
|
||||
};
|
||||
|
||||
item.PresentationUniqueKey = item.CreatePresentationUniqueKey();
|
||||
|
||||
CreateItem(item, null);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the studio.
|
||||
/// </summary>
|
||||
@@ -1354,15 +1384,6 @@ namespace Emby.Server.Implementations.Library
|
||||
return GetNewItemIdInternal(path, typeof(T), forceCaseInsensitiveId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
// Ensure the location is available.
|
||||
Directory.CreateDirectory(_configurationManager.ApplicationPaths.PeoplePath);
|
||||
|
||||
return new PeopleValidator(this, _logger, _fileSystem).ValidatePeople(cancellationToken, progress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the root media folder.
|
||||
/// </summary>
|
||||
@@ -1489,6 +1510,10 @@ namespace Emby.Server.Implementations.Library
|
||||
var numComplete = 0;
|
||||
var numTasks = tasks.Count;
|
||||
|
||||
_logger.LogInformation("Running {TaskCount} post-scan task(s)", numTasks);
|
||||
|
||||
var phaseStart = Stopwatch.GetTimestamp();
|
||||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
// Prevent access to modified closure
|
||||
@@ -1506,20 +1531,45 @@ namespace Emby.Server.Implementations.Library
|
||||
progress.Report(innerPercent);
|
||||
});
|
||||
|
||||
_logger.LogDebug("Running post-scan task {0}", task.GetType().Name);
|
||||
var taskName = task.GetType().Name;
|
||||
var taskStart = Stopwatch.GetTimestamp();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Running post-scan task {TaskNumber}/{TaskCount}: {TaskName}",
|
||||
currentNumComplete + 1,
|
||||
numTasks,
|
||||
taskName);
|
||||
|
||||
try
|
||||
{
|
||||
await task.Run(innerProgress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elapsed = Stopwatch.GetElapsedTime(taskStart);
|
||||
_logger.LogInformation(
|
||||
"Post-scan task {TaskName} completed after {Minutes} minute(s) and {Seconds} seconds",
|
||||
taskName,
|
||||
Math.Truncate(elapsed.TotalMinutes),
|
||||
elapsed.Seconds);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Post-scan task cancelled: {0}", task.GetType().Name);
|
||||
var elapsed = Stopwatch.GetElapsedTime(taskStart);
|
||||
_logger.LogInformation(
|
||||
"Post-scan task {TaskName} cancelled after {Minutes} minute(s) and {Seconds} seconds",
|
||||
taskName,
|
||||
Math.Truncate(elapsed.TotalMinutes),
|
||||
elapsed.Seconds);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error running post-scan task");
|
||||
var elapsed = Stopwatch.GetElapsedTime(taskStart);
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Post-scan task {TaskName} failed after {Minutes} minute(s) and {Seconds} seconds",
|
||||
taskName,
|
||||
Math.Truncate(elapsed.TotalMinutes),
|
||||
elapsed.Seconds);
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
@@ -1528,6 +1578,12 @@ namespace Emby.Server.Implementations.Library
|
||||
progress.Report(percent * 100);
|
||||
}
|
||||
|
||||
var phaseElapsed = Stopwatch.GetElapsedTime(phaseStart);
|
||||
_logger.LogInformation(
|
||||
"All post-scan tasks completed after {Minutes} minute(s) and {Seconds} seconds",
|
||||
Math.Truncate(phaseElapsed.TotalMinutes),
|
||||
phaseElapsed.Seconds);
|
||||
|
||||
_persistenceService.UpdateInheritedValues();
|
||||
|
||||
progress.Report(100);
|
||||
@@ -1745,9 +1801,21 @@ namespace Emby.Server.Implementations.Library
|
||||
return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query);
|
||||
}
|
||||
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
|
||||
/// <inheritdoc/>
|
||||
public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user)
|
||||
{
|
||||
return _countService.GetChildCountBatch(parentIds, userId);
|
||||
var query = new InternalItemsQuery(user);
|
||||
if (user is not null)
|
||||
{
|
||||
AddUserToQuery(query, user);
|
||||
}
|
||||
|
||||
return _countService.GetItemCountsForNameItems(kind, ids, relatedItemKinds, query);
|
||||
}
|
||||
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
|
||||
{
|
||||
return _countService.GetChildCountBatch(parentIds, user);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -1914,14 +1982,14 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
// Optimize by querying against top level views
|
||||
query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
|
||||
query.AncestorIds = [];
|
||||
|
||||
// Prevent searching in all libraries due to empty filter
|
||||
if (query.TopParentIds.Length == 0)
|
||||
var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
|
||||
if (topParentIds.Length == 0)
|
||||
{
|
||||
query.TopParentIds = [Guid.NewGuid()];
|
||||
return;
|
||||
}
|
||||
|
||||
query.TopParentIds = topParentIds;
|
||||
query.AncestorIds = [];
|
||||
}
|
||||
|
||||
public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query)
|
||||
@@ -1967,12 +2035,15 @@ namespace Emby.Server.Implementations.Library
|
||||
if (parents.All(i => i is ICollectionFolder || i is UserView))
|
||||
{
|
||||
// Optimize by querying against top level views
|
||||
query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
|
||||
var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
|
||||
|
||||
// Prevent searching in all libraries due to empty filter
|
||||
if (query.TopParentIds.Length == 0)
|
||||
if (topParentIds.Length > 0)
|
||||
{
|
||||
query.TopParentIds = [Guid.NewGuid()];
|
||||
query.TopParentIds = topParentIds;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetAncestorIds(query, parents);
|
||||
}
|
||||
}
|
||||
else if (parents.Count == 1 && parents.First() is Folder folder
|
||||
@@ -1981,34 +2052,31 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
// Playlists and BoxSets store their contents in LinkedChildren and never
|
||||
// populate AncestorIds for those items, so a recursive AncestorIds query
|
||||
// would return zero rows. Resolve to the linked child IDs up front and
|
||||
// route through the existing indexed ItemIds filter.
|
||||
query.ItemIds = folder.LinkedChildren
|
||||
.Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
|
||||
.Select(lc => lc.ItemId!.Value)
|
||||
.ToArray();
|
||||
|
||||
// Empty linked-children should still return empty rather than scanning everything.
|
||||
if (query.ItemIds.Length == 0)
|
||||
{
|
||||
query.ItemIds = [Guid.NewGuid()];
|
||||
}
|
||||
// would return zero rows. Filter by the descendant set instead, which follows
|
||||
// the links and keeps descending, so a linked folder contributes what is below
|
||||
// it as well - the episodes of a Series added to a collection, for example.
|
||||
query.DescendantOfId = folder.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
// We need to be able to query from any arbitrary ancestor up the tree
|
||||
query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray();
|
||||
|
||||
// Prevent searching in all libraries due to empty filter
|
||||
if (query.AncestorIds.Length == 0)
|
||||
{
|
||||
query.AncestorIds = [Guid.NewGuid()];
|
||||
}
|
||||
SetAncestorIds(query, parents);
|
||||
}
|
||||
|
||||
query.Parent = null;
|
||||
}
|
||||
|
||||
private static void SetAncestorIds(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents)
|
||||
{
|
||||
// We need to be able to query from any arbitrary ancestor up the tree
|
||||
query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray();
|
||||
|
||||
// Prevent searching in all libraries due to empty filter
|
||||
if (query.AncestorIds.Length == 0)
|
||||
{
|
||||
query.AncestorIds = [Guid.NewGuid()];
|
||||
}
|
||||
}
|
||||
|
||||
private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true)
|
||||
{
|
||||
if (query.User is null)
|
||||
@@ -2519,9 +2587,15 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.Exists(image.Path))
|
||||
if (string.IsNullOrEmpty(image.Path) || !File.Exists(image.Path))
|
||||
{
|
||||
_logger.LogWarning("Image not found at {ImagePath}", image.Path);
|
||||
_logger.LogWarning(
|
||||
"{ImageType} image for {ItemName} ({ItemId}) not found at \"{ImagePath}\", source was {SourcePath}",
|
||||
img.Type,
|
||||
item.Name,
|
||||
item.Id,
|
||||
image.Path,
|
||||
img.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2919,7 +2993,8 @@ namespace Emby.Server.Implementations.Library
|
||||
"views",
|
||||
_fileSystem.GetValidFilename(viewType.ToString()));
|
||||
|
||||
var id = GetNewItemId(path + "_namedview_" + name, typeof(UserView));
|
||||
// The display name is localized, so it must not take part in the id.
|
||||
var id = GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView));
|
||||
|
||||
var item = GetItemById(id) as UserView;
|
||||
|
||||
@@ -2943,6 +3018,13 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
refresh = true;
|
||||
}
|
||||
else if (!string.Equals(item.Name, name, StringComparison.Ordinal))
|
||||
{
|
||||
item.Name = name;
|
||||
item.ForcedSortName = sortName;
|
||||
|
||||
refresh = true;
|
||||
}
|
||||
|
||||
if (refresh)
|
||||
{
|
||||
@@ -2963,7 +3045,9 @@ namespace Emby.Server.Implementations.Library
|
||||
var parentIdString = parentId.IsEmpty()
|
||||
? null
|
||||
: parentId.ToString("N", CultureInfo.InvariantCulture);
|
||||
var idValues = "38_namedview_" + name + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
|
||||
|
||||
// The name is either localized (grouped views) or the library folder's own name.
|
||||
var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
|
||||
|
||||
var id = GetNewItemId(idValues, typeof(UserView));
|
||||
|
||||
@@ -2993,6 +3077,11 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
isNew = true;
|
||||
}
|
||||
else if (!string.Equals(item.Name, name, StringComparison.Ordinal))
|
||||
{
|
||||
item.Name = name;
|
||||
item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
var lastRefreshedUtc = item.DateLastRefreshed;
|
||||
var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
|
||||
@@ -3094,7 +3183,7 @@ namespace Emby.Server.Implementations.Library
|
||||
var parentIdString = parentId.IsEmpty()
|
||||
? null
|
||||
: parentId.ToString("N", CultureInfo.InvariantCulture);
|
||||
var idValues = "37_namedview_" + name + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
|
||||
var idValues = "37_namedview_" + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
|
||||
if (!string.IsNullOrEmpty(uniqueId))
|
||||
{
|
||||
idValues += uniqueId;
|
||||
@@ -3128,9 +3217,10 @@ namespace Emby.Server.Implementations.Library
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
if (viewType != item.ViewType)
|
||||
if (viewType != item.ViewType || !string.Equals(item.Name, name, StringComparison.Ordinal))
|
||||
{
|
||||
item.ViewType = viewType;
|
||||
item.Name = name;
|
||||
item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
@@ -3550,6 +3640,12 @@ namespace Emby.Server.Implementations.Library
|
||||
return _peopleRepository.GetPeopleNames(query);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int DeleteOrphanedCredits()
|
||||
{
|
||||
return _peopleRepository.DeleteOrphanedCredits();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
|
||||
{
|
||||
@@ -3595,7 +3691,20 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return item.GetImageInfo(image.Type, imageIndex);
|
||||
var localImage = item.GetImageInfo(image.Type, imageIndex);
|
||||
if (localImage is null)
|
||||
{
|
||||
throw new InvalidOperationException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Downloaded {0} image {1} from {2} is not attached to {3} ({4})",
|
||||
image.Type,
|
||||
imageIndex,
|
||||
url,
|
||||
item.Name,
|
||||
item.Id));
|
||||
}
|
||||
|
||||
return localImage;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
@@ -3617,7 +3726,13 @@ namespace Emby.Server.Implementations.Library
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to convert any images to local");
|
||||
throw new InvalidOperationException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Unable to convert any {0} image url in \"{1}\" to a local file for {2} ({3})",
|
||||
image.Type,
|
||||
image.Path,
|
||||
item.Name,
|
||||
item.Id));
|
||||
}
|
||||
|
||||
public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary)
|
||||
@@ -3673,6 +3788,10 @@ namespace Emby.Server.Implementations.Library
|
||||
AddMediaPathInternal(name, path, false);
|
||||
}
|
||||
}
|
||||
|
||||
// The libraries root was listed before this folder existed, so drop that listing:
|
||||
// anything still reading it resolves the library set without the new folder.
|
||||
_directoryService.Invalidate(virtualFolderPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -3699,27 +3818,14 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
var itemUpdateType = ItemUpdateType.MetadataDownload;
|
||||
var saveEntity = false;
|
||||
var createEntity = false;
|
||||
var personEntity = GetPerson(person.Name);
|
||||
|
||||
if (personEntity is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Person.GetPath(person.Name);
|
||||
var info = Directory.CreateDirectory(path);
|
||||
personEntity = new Person()
|
||||
{
|
||||
Name = person.Name,
|
||||
Id = GetItemByNameId<Person>(path),
|
||||
DateCreated = info.CreationTimeUtc,
|
||||
DateModified = info.LastWriteTimeUtc,
|
||||
Path = path
|
||||
};
|
||||
|
||||
personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey();
|
||||
personEntity = GetOrCreatePerson(person.Name);
|
||||
saveEntity = true;
|
||||
createEntity = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -3753,11 +3859,6 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
if (saveEntity)
|
||||
{
|
||||
if (createEntity)
|
||||
{
|
||||
CreateItems([personEntity], null, CancellationToken.None);
|
||||
}
|
||||
|
||||
await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
|
||||
personEntity.DateLastSaved = DateTime.UtcNow;
|
||||
|
||||
@@ -3797,7 +3898,9 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||
var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName)
|
||||
?? throw new FileNotFoundException(
|
||||
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
|
||||
|
||||
CreateShortcut(virtualFolderPath, pathInfo);
|
||||
|
||||
@@ -3818,7 +3921,9 @@ namespace Emby.Server.Implementations.Library
|
||||
ArgumentNullException.ThrowIfNull(mediaPath);
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||
var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName)
|
||||
?? throw new FileNotFoundException(
|
||||
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
|
||||
|
||||
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
|
||||
|
||||
@@ -3857,9 +3962,9 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
|
||||
var path = Path.Combine(rootFolderPath, name);
|
||||
var path = FileSystemHelper.GetChildPath(rootFolderPath, name);
|
||||
|
||||
if (!Directory.Exists(path))
|
||||
if (path is null || !Directory.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException("The media folder does not exist");
|
||||
}
|
||||
@@ -3869,6 +3974,7 @@ namespace Emby.Server.Implementations.Library
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
_directoryService.Invalidate(path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -3923,9 +4029,9 @@ namespace Emby.Server.Implementations.Library
|
||||
ArgumentException.ThrowIfNullOrEmpty(mediaPath);
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||
var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName);
|
||||
|
||||
if (!Directory.Exists(virtualFolderPath))
|
||||
if (virtualFolderPath is null || !Directory.Exists(virtualFolderPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
|
||||
@@ -3938,6 +4044,7 @@ namespace Emby.Server.Implementations.Library
|
||||
if (!string.IsNullOrEmpty(shortcut))
|
||||
{
|
||||
_fileSystem.DeleteFile(shortcut);
|
||||
_directoryService.Invalidate(shortcut);
|
||||
}
|
||||
|
||||
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
|
||||
@@ -3981,6 +4088,7 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
||||
_directoryService.Invalidate(lnk);
|
||||
RemoveContentTypeOverrides(path);
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
args.LibraryOptions.SeasonZeroDisplayName :
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localization.GetLocalizedString("NameSeasonNumber"),
|
||||
_localization.GetServerLocalizedString("NameSeasonNumber"),
|
||||
seasonNumber,
|
||||
args.LibraryOptions.PreferredMetadataLanguage);
|
||||
}
|
||||
@@ -129,6 +129,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
|
||||
var tmdbId = justName.GetAttributeValue("tmdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
|
||||
|
||||
// Anime databases model a single cour as its own entry, so a multi-season
|
||||
// series maps to one of these ids per season rather than one per series.
|
||||
var anidbId = justName.GetAttributeValue("anidbid");
|
||||
item.TrySetProviderId("AniDB", anidbId);
|
||||
|
||||
var aniListId = justName.GetAttributeValue("anilistid");
|
||||
item.TrySetProviderId("AniList", aniListId);
|
||||
|
||||
var aniSearchId = justName.GetAttributeValue("anisearchid");
|
||||
item.TrySetProviderId("AniSearch", aniSearchId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,13 +112,12 @@ public class SearchManager : ISearchManager
|
||||
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;
|
||||
return await internalTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
||||
@@ -144,17 +143,16 @@ public class SearchManager : ISearchManager
|
||||
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
||||
|
||||
var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (allowedCount == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var allowedIds = await baseQuery
|
||||
.Select(e => e.Id)
|
||||
.ToHashSetAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (allowedIds.Count == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
|
||||
if (filtered.Count < candidates.Count)
|
||||
{
|
||||
|
||||
@@ -260,7 +260,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
||||
}
|
||||
|
||||
var candidateRows = await context.ItemValuesMap.AsNoTracking()
|
||||
.Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
|
||||
.Where(m => !m.Item.PrimaryVersionId.HasValue && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
|
||||
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -276,6 +276,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
||||
if (personSourceRows.Count > 0)
|
||||
{
|
||||
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
|
||||
.Where(m => !m.Item.PrimaryVersionId.HasValue)
|
||||
.Where(m => context.PeopleBaseItemMap
|
||||
.Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
|
||||
.Select(s => s.PeopleId)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the access filter that decides which items a similar-items lookup may return for a user.
|
||||
/// </summary>
|
||||
internal static class SimilarItemsAccessFilter
|
||||
{
|
||||
private static readonly BaseItemKind[] _itemByNameKinds =
|
||||
[
|
||||
BaseItemKind.Person,
|
||||
BaseItemKind.Genre,
|
||||
BaseItemKind.MusicGenre,
|
||||
BaseItemKind.MusicArtist,
|
||||
BaseItemKind.Studio
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Builds an access filter carrying the user's library access and parental restrictions.
|
||||
/// </summary>
|
||||
/// <param name="user">The user the lookup runs for.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <returns>The access filter.</returns>
|
||||
public static InternalItemsQuery Build(User user, ILibraryManager libraryManager)
|
||||
{
|
||||
// IncludeItemTypes is read only for the by-name exemption here; the caller applies this
|
||||
// filter through ApplyAccessFiltering, which does not translate it into a type restriction.
|
||||
var accessFilter = new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = _itemByNameKinds
|
||||
};
|
||||
|
||||
// ConfigureUserAccess populates TopParentIds for the libraries the user may open.
|
||||
libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
|
||||
return accessFilter;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
@@ -16,11 +18,13 @@ using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
@@ -35,6 +39,8 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
private ISimilarItemsProvider[] _similarItemsProviders = [];
|
||||
|
||||
/// <summary>
|
||||
@@ -45,18 +51,24 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="fileSystem">The file system.</param>
|
||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="queryHelpers">The shared item query helpers.</param>
|
||||
public SimilarItemsManager(
|
||||
ILogger<SimilarItemsManager> logger,
|
||||
IServerApplicationPaths appPaths,
|
||||
ILibraryManager libraryManager,
|
||||
IFileSystem fileSystem,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemQueryHelpers queryHelpers)
|
||||
{
|
||||
_logger = logger;
|
||||
_appPaths = appPaths;
|
||||
_libraryManager = libraryManager;
|
||||
_fileSystem = fileSystem;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_dbProvider = dbProvider;
|
||||
_queryHelpers = queryHelpers;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -230,11 +242,64 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
}
|
||||
}
|
||||
|
||||
return allResults
|
||||
var ordered = allResults
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Item)
|
||||
.Take(requestedLimit)
|
||||
.ToList();
|
||||
|
||||
return await FilterByLibraryAccessAsync(ordered, user, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BaseItem>> FilterByLibraryAccessAsync(
|
||||
IReadOnlyList<BaseItem> candidates,
|
||||
User? user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (candidates.Count == 0 || user is null)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var accessFilter = SimilarItemsAccessFilter.Build(user, _libraryManager);
|
||||
|
||||
// No accessible libraries means nothing to compare against, and an empty TopParentIds set
|
||||
// would disable the filter rather than reject everything.
|
||||
if (accessFilter.TopParentIds.Length == 0)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
Guid[] candidateIds = [.. candidates.Select(c => c.Id)];
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var baseQuery = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.WhereOneOrMany(candidateIds, e => e.Id);
|
||||
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
||||
|
||||
var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (allowedCount == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var allowedIds = await baseQuery
|
||||
.Select(e => e.Id)
|
||||
.ToHashSetAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var filtered = candidates.Where(c => allowedIds.Contains(c.Id)).ToList();
|
||||
_logger.LogDebug(
|
||||
"Dropped {Dropped} of {Total} similar-item candidates due to user access filtering",
|
||||
candidates.Count - filtered.Count,
|
||||
candidates.Count);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -376,19 +441,39 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
|
||||
var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Filter once across every category rather than per baseline, so a batch provider costs one
|
||||
// access query no matter how many categories it produced.
|
||||
var allItems = batchResults.Values.SelectMany(items => items).DistinctBy(item => item.Id).ToList();
|
||||
var allowed = await FilterByLibraryAccessAsync(allItems, query.User, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
HashSet<Guid>? allowedIds = allowed.Count == allItems.Count
|
||||
? null
|
||||
: [.. allowed.Select(item => item.Id)];
|
||||
|
||||
var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
|
||||
foreach (var baseline in baselineItems)
|
||||
{
|
||||
if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0)
|
||||
if (!batchResults.TryGetValue(baseline.Id, out var similar) || similar.Count == 0)
|
||||
{
|
||||
recommendations.Add(new SimilarItemsRecommendation
|
||||
{
|
||||
BaselineItemName = baseline.Name,
|
||||
CategoryId = baseline.Id,
|
||||
RecommendationType = recommendationType,
|
||||
Items = similar
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowedIds is not null)
|
||||
{
|
||||
similar = similar.Where(item => allowedIds.Contains(item.Id)).ToList();
|
||||
if (similar.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
recommendations.Add(new SimilarItemsRecommendation
|
||||
{
|
||||
BaselineItemName = baseline.Name,
|
||||
CategoryId = baseline.Id,
|
||||
RecommendationType = recommendationType,
|
||||
Items = similar
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
|
||||
@@ -112,7 +112,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
if (_config.Configuration.EnableFolderView)
|
||||
{
|
||||
var name = _localizationManager.GetLocalizedString("Folders");
|
||||
var name = _localizationManager.GetServerLocalizedString("Folders");
|
||||
list.Add(_libraryManager.GetNamedView(name, CollectionType.folders, string.Empty));
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
public UserView GetUserSubView(Guid parentId, CollectionType? type, string localizationKey, string sortName)
|
||||
{
|
||||
var name = _localizationManager.GetLocalizedString(localizationKey);
|
||||
var name = _localizationManager.GetServerLocalizedString(localizationKey);
|
||||
|
||||
return GetUserSubViewWithName(name, parentId, type, sortName);
|
||||
}
|
||||
@@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library
|
||||
return GetUserView((Folder)parents[0], viewType, string.Empty);
|
||||
}
|
||||
|
||||
var name = _localizationManager.GetLocalizedString(localizationKey);
|
||||
var name = _localizationManager.GetServerLocalizedString(localizationKey);
|
||||
return _libraryManager.GetNamedView(user, name, viewType, sortName);
|
||||
}
|
||||
|
||||
@@ -396,6 +396,12 @@ namespace Emby.Server.Implementations.Library
|
||||
query.Limit = limit;
|
||||
return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies);
|
||||
}
|
||||
|
||||
if (collectionType is null)
|
||||
{
|
||||
query.Limit = limit;
|
||||
return _libraryManager.GetLatestItemList(query, parents, CollectionType.unknown);
|
||||
}
|
||||
}
|
||||
|
||||
return _libraryManager.GetItemList(query, parents);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -61,6 +62,9 @@ public class ArtistsValidator
|
||||
var count = names.Count;
|
||||
var refreshed = 0;
|
||||
|
||||
var liveIds = new HashSet<Guid>();
|
||||
var unresolved = 0;
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
try
|
||||
@@ -73,13 +77,20 @@ public class ArtistsValidator
|
||||
|
||||
// Fall back to GetArtist if not found (creates new item if needed)
|
||||
item ??= _libraryManager.GetArtist(name);
|
||||
var isNew = !existingArtistIds.Contains(item.Id);
|
||||
var neverRefreshed = item.DateLastRefreshed == default;
|
||||
|
||||
if (isNew || neverRefreshed)
|
||||
// A name with no item is nothing to refresh, and nothing to keep alive either.
|
||||
if (item is not null)
|
||||
{
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
liveIds.Add(item.Id);
|
||||
|
||||
var isNew = !existingArtistIds.Contains(item.Id);
|
||||
var neverRefreshed = item.DateLastRefreshed == default;
|
||||
|
||||
if (isNew || neverRefreshed)
|
||||
{
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -88,6 +99,7 @@ public class ArtistsValidator
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
unresolved++;
|
||||
_logger.LogError(ex, "Error refreshing {ArtistName}", name);
|
||||
}
|
||||
|
||||
@@ -101,13 +113,26 @@ public class ArtistsValidator
|
||||
|
||||
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count);
|
||||
|
||||
// Every name that threw is a name whose artist is missing from the live set, and deleting against
|
||||
// a live set with holes in it deletes artists the library still refers to. Leave the sweep to a
|
||||
// run that got a clean read of them.
|
||||
if (unresolved > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Not removing dead artists: {Count} of {TotalCount} names could not be resolved this run",
|
||||
unresolved,
|
||||
count);
|
||||
|
||||
progress.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
||||
IsDeadArtist = true,
|
||||
IsLocked = false
|
||||
}).Cast<MusicArtist>()
|
||||
.Where(item => item.IsAccessedByName)
|
||||
}).OfType<MusicArtist>()
|
||||
.Where(item => item.IsAccessedByName && !liveIds.Contains(item.Id))
|
||||
.ToList();
|
||||
|
||||
foreach (var item in deadEntities)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Validators;
|
||||
@@ -17,104 +17,143 @@ namespace Emby.Server.Implementations.Library.Validators;
|
||||
public class PeopleValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// The _library manager.
|
||||
/// The library manager.
|
||||
/// </summary>
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// The _logger.
|
||||
/// The logger.
|
||||
/// </summary>
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<PeopleValidator> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PeopleValidator" /> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="fileSystem">The file system.</param>
|
||||
public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem)
|
||||
public PeopleValidator(ILibraryManager libraryManager, ILogger<PeopleValidator> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_fileSystem = fileSystem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the people.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <param name="progress">The progress.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
|
||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
|
||||
// Before the refresh below walks them: a credit no item maps to any more stands for nothing,
|
||||
// and while it is there the person it names cannot reach the dead-person sweep either.
|
||||
var numOrphaned = _libraryManager.DeleteOrphanedCredits();
|
||||
if (numOrphaned > 0)
|
||||
{
|
||||
_logger.LogInformation("Deleted {Amount} credits no item maps to", numOrphaned);
|
||||
}
|
||||
|
||||
var names = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
|
||||
var existingPersonIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Person]
|
||||
}).ToHashSet();
|
||||
|
||||
var (newNames, deadIds) = PartitionCreditsByPersonId(names, _libraryManager.GetPersonId, existingPersonIds);
|
||||
|
||||
var numComplete = 0;
|
||||
var count = names.Count;
|
||||
var refreshed = 0;
|
||||
|
||||
var numPeople = people.Count;
|
||||
|
||||
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
||||
|
||||
_logger.LogDebug("Will refresh {Amount} people", numPeople);
|
||||
|
||||
foreach (var person in people)
|
||||
foreach (var name in names)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var item = _libraryManager.GetPerson(person);
|
||||
if (item is null)
|
||||
var item = _libraryManager.GetOrCreatePerson(name);
|
||||
var isNew = !existingPersonIds.Contains(item.Id);
|
||||
var neverRefreshed = item.DateLastRefreshed == default;
|
||||
|
||||
if (isNew || neverRefreshed)
|
||||
{
|
||||
_logger.LogWarning("Failed to get person: {Name}", person);
|
||||
continue;
|
||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
}
|
||||
|
||||
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
ImageRefreshMode = MetadataRefreshMode.ValidationOnly,
|
||||
MetadataRefreshMode = MetadataRefreshMode.ValidationOnly
|
||||
};
|
||||
|
||||
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Don't clutter the log
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error validating IBN entry {Person}", person);
|
||||
_logger.LogError(ex, "Error refreshing {PersonName}", name);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
numComplete++;
|
||||
double percent = numComplete;
|
||||
percent /= numPeople;
|
||||
percent /= count;
|
||||
percent *= 100;
|
||||
|
||||
subProgress.Report(100 * percent);
|
||||
progress.Report(percent);
|
||||
}
|
||||
|
||||
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Person],
|
||||
IsDeadPerson = true,
|
||||
IsLocked = false
|
||||
});
|
||||
_logger.LogInformation(
|
||||
"Refreshed metadata for {RefreshedCount} people out of {TotalCount} total, {NewCount} of which had no item yet",
|
||||
refreshed,
|
||||
count,
|
||||
newNames.Count);
|
||||
|
||||
subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
|
||||
// A person somebody locked is theirs, not ours, however little the library still credits them.
|
||||
var deadEntities = deadIds
|
||||
.Select(_libraryManager.GetItemById)
|
||||
.OfType<Person>()
|
||||
.Where(item => !item.IsLocked)
|
||||
.ToList();
|
||||
|
||||
var i = 0;
|
||||
foreach (var item in deadEntities.Chunk(500))
|
||||
foreach (var item in deadEntities)
|
||||
{
|
||||
_libraryManager.DeleteItemsUnsafeFast(item, true);
|
||||
subProgress.Report(100f / deadEntities.Count * (i++ * 100));
|
||||
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
||||
}
|
||||
|
||||
_libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
_logger.LogInformation("People validation complete");
|
||||
/// <summary>
|
||||
/// Splits the person items into the ones a credit still calls for and the ones nothing does.
|
||||
/// </summary>
|
||||
/// <param name="creditNames">Every name credited on an item, from the people table.</param>
|
||||
/// <param name="getPersonId">Maps a credit name to the id its person item has.</param>
|
||||
/// <param name="existingPersonIds">The ids of the person items that exist.</param>
|
||||
/// <returns>The credits needing an item, and the ids of the items nothing credits.</returns>
|
||||
internal static (List<string> NewNames, List<Guid> DeadIds) PartitionCreditsByPersonId(
|
||||
IReadOnlyList<string> creditNames,
|
||||
Func<string, Guid> getPersonId,
|
||||
IReadOnlySet<Guid> existingPersonIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(creditNames);
|
||||
ArgumentNullException.ThrowIfNull(getPersonId);
|
||||
ArgumentNullException.ThrowIfNull(existingPersonIds);
|
||||
|
||||
var newNames = new List<string>();
|
||||
var liveIds = new HashSet<Guid>();
|
||||
|
||||
foreach (var name in creditNames)
|
||||
{
|
||||
var personId = getPersonId(name);
|
||||
|
||||
// Distinct credit names can normalize onto one id; only the first of them needs an item.
|
||||
if (liveIds.Add(personId) && !existingPersonIds.Contains(personId))
|
||||
{
|
||||
newNames.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
var deadIds = existingPersonIds.Where(id => !liveIds.Contains(id)).ToList();
|
||||
|
||||
return (newNames, deadIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,5 +106,17 @@
|
||||
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
|
||||
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
|
||||
"CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка",
|
||||
"CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
|
||||
"CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.",
|
||||
"LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}",
|
||||
"NameExtraDeletedScene": "Выдаленая сцэна",
|
||||
"NameExtraInterview": "Інтэрв'ю",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraScene": "Сцэна",
|
||||
"NameExtraTrailer": "Трэйлер",
|
||||
"NameExtraBehindTheScenes": "За кулісамі",
|
||||
"NameExtraClip": "Кліп",
|
||||
"NameExtraFeaturette": "Кароткаметражка",
|
||||
"NameExtraSample": "Прыклад",
|
||||
"NameExtraShort": "Кароткаметражка",
|
||||
"NameExtraThemeSong": "Тэматычная песня"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,20 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.",
|
||||
"TaskExtractMediaSegments": "Сканиране за сегменти",
|
||||
"CleanupUserDataTask": "Задача за почистване на потребителски данни",
|
||||
"CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни."
|
||||
"CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни.",
|
||||
"LyricDownloadFailureFromForItem": "Текстът на песента не успя да се изтегли от {0} за {1}",
|
||||
"NameExtraBehindTheScenes": "Зад кулисите",
|
||||
"NameExtraScene": "Сцена",
|
||||
"NameExtraShort": "Откъс",
|
||||
"NameExtraThemeVideo": "Тематично видео",
|
||||
"NameExtraTrailer": "Трейлър",
|
||||
"NameExtraUnknown": "Екстра",
|
||||
"NameExtraClip": "Клип",
|
||||
"NameExtraDeletedScene": "Изтрита Сцена",
|
||||
"NameExtraFeaturette": "Кратък филм",
|
||||
"NameExtraInterview": "Интервю",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Пример",
|
||||
"NameExtraThemeSong": "Тема-песен",
|
||||
"Original": "Оригинал"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,17 @@
|
||||
"TaskMoveTrickplayImages": "Migracija lokacije slike Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke trik-igara prema postavkama biblioteke.",
|
||||
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
|
||||
"CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana."
|
||||
"CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana.",
|
||||
"NameExtraBehindTheScenes": "Iza kulisa",
|
||||
"NameExtraClip": "Isječak",
|
||||
"NameExtraDeletedScene": "Izbrišana scena",
|
||||
"NameExtraFeaturette": "Kratki prilog",
|
||||
"NameExtraInterview": "Intervju",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Uzorak",
|
||||
"NameExtraScene": "Scena",
|
||||
"NameExtraShort": "Kratko",
|
||||
"NameExtraThemeSong": "Tema",
|
||||
"NameExtraThemeVideo": "Tematski video",
|
||||
"NameExtraTrailer": "Najava"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"Shows": "Serier",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.",
|
||||
"SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}",
|
||||
"TvShows": "TV-serier",
|
||||
"TvShows": "Serier",
|
||||
"UserCreatedWithName": "Bruger {0} er blevet oprettet",
|
||||
"UserDeletedWithName": "Brugeren {0} er nu slettet",
|
||||
"UserDownloadingItemWithValues": "{0} henter {1}",
|
||||
@@ -80,8 +80,8 @@
|
||||
"TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.",
|
||||
"TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.",
|
||||
"TaskRefreshChannels": "Opdatér kanaler",
|
||||
"TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.",
|
||||
"TaskCleanTranscode": "Tøm omkodningsmappen",
|
||||
"TaskCleanTranscodeDescription": "Fjerner transkodningsfiler, som er mere end 1 dag gamle.",
|
||||
"TaskCleanTranscode": "Tøm transkodningsmappen",
|
||||
"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.",
|
||||
@@ -109,17 +109,17 @@
|
||||
"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",
|
||||
"NameExtraBehindTheScenes": "Bag Scenerne",
|
||||
"NameExtraBehindTheScenes": "Bag scenerne",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Slettet Scene",
|
||||
"NameExtraDeletedScene": "Slettet scene",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Interview",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Smagsprøve",
|
||||
"NameExtraScene": "Scene",
|
||||
"NameExtraShort": "Kort",
|
||||
"NameExtraThemeSong": "Tema Sang",
|
||||
"NameExtraThemeVideo": "Tema Video",
|
||||
"NameExtraThemeSong": "Temasang",
|
||||
"NameExtraThemeVideo": "Temavideo",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Ekstra"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"AppDeviceValues": "App: {0}, Gerät: {1}",
|
||||
"Artists": "Interpreten",
|
||||
"Artists": "Künstler",
|
||||
"AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert",
|
||||
"Books": "Bücher",
|
||||
"ChapterNameValue": "Kapitel {0}",
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
|
||||
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
|
||||
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
|
||||
"Original": "Πρωτότυπο"
|
||||
"Original": "Πρωτότυπο",
|
||||
"NameExtraBehindTheScenes": "Πίσω από τις Σκηνές",
|
||||
"NameExtraDeletedScene": "Διεγραμμένη Σκηνή",
|
||||
"NameExtraFeaturette": "Πρόσθετα βίντεο",
|
||||
"NameExtraInterview": "Συνέντευξη",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Δείγμα",
|
||||
"NameExtraScene": "Σκηνή",
|
||||
"NameExtraShort": "Βίντεο μικρού μήκους",
|
||||
"NameExtraThemeSong": "Θεματικό Τραγούδι",
|
||||
"NameExtraThemeVideo": "Θεματικό Βίντεο",
|
||||
"NameExtraTrailer": "τρέιλερ ταινίας",
|
||||
"NameExtraUnknown": "Πρόσθετα",
|
||||
"NameExtraClip": "Απόσπασμα"
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"Music": "Music",
|
||||
"MusicVideos": "Music Videos",
|
||||
"NameInstallFailed": "{0} installation failed",
|
||||
"NameSeasonNumber": "Season {0}",
|
||||
"NameSeasonUnknown": "Season Unknown",
|
||||
"NameSeasonNumber": "Series {0}",
|
||||
"NameSeasonUnknown": "Series Unknown",
|
||||
"NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Application update available",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Application update installed",
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "User data cleanup task",
|
||||
"CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.",
|
||||
"LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Behind The Scenes",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Deleted Scene",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Interview",
|
||||
"NameExtraSample": "Sample",
|
||||
"NameExtraScene": "Scene",
|
||||
"NameExtraShort": "Short",
|
||||
"NameExtraThemeSong": "Theme Song",
|
||||
"NameExtraThemeVideo": "Theme Video",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraNumbered": "{0} {1}"
|
||||
}
|
||||
|
||||
@@ -97,5 +97,9 @@
|
||||
"TaskAudioNormalizationDescription": "Skanas dosierojn por sonnivelaj normaligaj datumoj.",
|
||||
"TaskRefreshTrickplayImages": "Generi la bildojn por TrickPlay (Antaŭrigardo rapida antaŭen)",
|
||||
"TaskAudioNormalization": "Normaligo Sonnivela",
|
||||
"HearingImpaired": "Surda"
|
||||
"HearingImpaired": "Surda",
|
||||
"NameExtraDeletedScene": "Forigita sceno",
|
||||
"NameExtraScene": "Sceno",
|
||||
"NameExtraThemeSong": "Tema Kanto",
|
||||
"Original": "Originala"
|
||||
}
|
||||
|
||||
@@ -114,5 +114,12 @@
|
||||
"NameExtraScene": "Eszena",
|
||||
"NameExtraShort": "Laburra",
|
||||
"NameExtraThemeSong": "Gai-abestia",
|
||||
"NameExtraThemeVideo": "Gai-bideoa"
|
||||
"NameExtraThemeVideo": "Gai-bideoa",
|
||||
"NameExtraBehindTheScenes": "Eszenen atzean",
|
||||
"NameExtraClip": "Klipa",
|
||||
"NameExtraDeletedScene": "Ezabatutako eszena",
|
||||
"NameExtraFeaturette": "Erreportajea",
|
||||
"NameExtraInterview": "Elkarrizketa",
|
||||
"NameExtraTrailer": "Trailerra",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,17 @@
|
||||
"CleanupUserDataTask": "Käyttäjätietojen puhdistustehtävä",
|
||||
"CleanupUserDataTaskDescription": "Puhdistaa kaikki käyttäjätiedot (katselutila, suosikit ym.) medioista, joita ei ole ollut saatavilla yli 90 päivään.",
|
||||
"LyricDownloadFailureFromForItem": "Sanoitusten lataus kohteesta {0} kappaleelle {1} epäonnistui",
|
||||
"Original": "Alkuperäinen"
|
||||
"Original": "Alkuperäinen",
|
||||
"NameExtraBehindTheScenes": "Kulissien Takana",
|
||||
"NameExtraClip": "Klippi",
|
||||
"NameExtraDeletedScene": "Poistettu Kohtaus",
|
||||
"NameExtraFeaturette": "Lyhytelokuva",
|
||||
"NameExtraInterview": "Haastattelu",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Näyte",
|
||||
"NameExtraScene": "Kohtaus",
|
||||
"NameExtraShort": "Lyhytfilmi",
|
||||
"NameExtraThemeSong": "Tunnusmusiikki",
|
||||
"NameExtraThemeVideo": "Tunnusvideo",
|
||||
"NameExtraTrailer": "Traileri"
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"Artists": "Tónlistafólk",
|
||||
"Collections": "Søvn",
|
||||
"Collections": "Samlingar",
|
||||
"Default": "Forsett",
|
||||
"External": "Ytri",
|
||||
"Genres": "Greinar",
|
||||
"AppDeviceValues": "App: {0}, Eind: {1}",
|
||||
"Books": "Bøkur",
|
||||
"ChapterNameValue": "Kapittul {0}",
|
||||
"Favorites": "Yndis",
|
||||
"Folders": "Mappur",
|
||||
"Favorites": "Yndislista",
|
||||
"Folders": "Skjáttur",
|
||||
"Forced": "Kravt",
|
||||
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
|
||||
"HeaderFavoriteEpisodes": "Yndispartar",
|
||||
"LabelIpAddressValue": "IP-atsetur: {0}",
|
||||
"AuthenticationSucceededWithUserName": "{0} varð samgildur",
|
||||
"AuthenticationSucceededWithUserName": "{0} var samgildur",
|
||||
"HeaderFavoriteShows": "Yndisrøðir",
|
||||
"HeaderLiveTV": "Beinleiðis sjónvarp",
|
||||
"HearingImpaired": "Hoyrnarveik",
|
||||
@@ -55,8 +55,8 @@
|
||||
"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",
|
||||
"NameSeasonNumber": "Umfar {0}",
|
||||
"NameSeasonUnknown": "Ókent umfar",
|
||||
"ScheduledTaskFailedWithName": "{0} miseydnaðist",
|
||||
"Undefined": "Óskilmarkað",
|
||||
"TasksMaintenanceCategory": "Viðlíkahald",
|
||||
@@ -68,7 +68,7 @@
|
||||
"NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan",
|
||||
"TasksApplicationCategory": "Nýtsluskipan",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Skipanardagføring varð innløgd",
|
||||
"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.",
|
||||
@@ -104,12 +104,22 @@
|
||||
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
|
||||
"NameExtraShort": "Stuttfilmur",
|
||||
"NameExtraThemeSong": "Eyðkennislag",
|
||||
"NameExtraTrailer": "Forfilmur",
|
||||
"NameExtraTrailer": "Brellbiti",
|
||||
"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."
|
||||
"TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.",
|
||||
"NameExtraSample": "Sýnislutur",
|
||||
"TaskRefreshTrickplayImages": "Framleið Trickplay-myndir",
|
||||
"TaskRefreshTrickplayImagesDescription": "Framleiðir trickplay-myndir fyri kykmyndir í søvnunm har tað er virkt.",
|
||||
"TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað",
|
||||
"TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.",
|
||||
"NameExtraThemeVideo": "Eyðkenniskykmynd",
|
||||
"NameExtraDeletedScene": "Úrtikin mynd",
|
||||
"NameExtraScene": "Mynd",
|
||||
"NameExtraUnknown": "Eykatilfar",
|
||||
"Original": "Upprunalig(t/ur)"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,17 @@
|
||||
"CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora",
|
||||
"CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.",
|
||||
"Original": "Bunaidh",
|
||||
"LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}"
|
||||
"LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}",
|
||||
"NameExtraBehindTheScenes": "Taobh thiar de na Radhairc",
|
||||
"NameExtraClip": "Gearrthóg",
|
||||
"NameExtraDeletedScene": "Radharc Scriosta",
|
||||
"NameExtraFeaturette": "Mionghné",
|
||||
"NameExtraInterview": "Agallamh",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Sampla",
|
||||
"NameExtraScene": "Radharc",
|
||||
"NameExtraShort": "Gearr",
|
||||
"NameExtraThemeSong": "Amhrán Téama",
|
||||
"NameExtraThemeVideo": "Físeán Téama",
|
||||
"NameExtraTrailer": "Leantóir"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,19 @@
|
||||
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
|
||||
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
|
||||
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.",
|
||||
"Original": "Orixinal"
|
||||
"Original": "Orixinal",
|
||||
"LyricDownloadFailureFromForItem": "Non se puideron descargar as letras desde {0} para {1}",
|
||||
"NameExtraBehindTheScenes": "Detrás das Cámaras",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Escena Eliminada",
|
||||
"NameExtraFeaturette": "Reportaxe especial",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Mostra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Curtametraxe",
|
||||
"NameExtraThemeSong": "Canción principal",
|
||||
"NameExtraThemeVideo": "Vídeo da canción principal",
|
||||
"NameExtraTrailer": "Tráiler",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,12 @@
|
||||
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
|
||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש",
|
||||
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
|
||||
"Original": "מקור"
|
||||
"Original": "מקור",
|
||||
"NameExtraBehindTheScenes": "מאחורי הקלעים",
|
||||
"NameExtraClip": "קליפ",
|
||||
"NameExtraDeletedScene": "סצנה שנמחקה",
|
||||
"NameExtraFeaturette": "סרט קצר",
|
||||
"NameExtraInterview": "ריאיון",
|
||||
"NameExtraSample": "דגימה",
|
||||
"NameExtraScene": "סצנה"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,20 @@
|
||||
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
|
||||
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
|
||||
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य",
|
||||
"Original": "असली"
|
||||
"Original": "असली",
|
||||
"LyricDownloadFailureFromForItem": "{0} के लिए {1} से बोल (Lyrics) डाउनलोड करने में विफल रहा",
|
||||
"NameExtraBehindTheScenes": "परदे के पीछे",
|
||||
"NameExtraClip": "क्लिप",
|
||||
"NameExtraDeletedScene": "हटाया गया दृश्य",
|
||||
"NameExtraFeaturette": "फीचरेट",
|
||||
"NameExtraInterview": "साक्षात्कार",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "नमूना",
|
||||
"NameExtraScene": "दृश्य",
|
||||
"NameExtraShort": "शॉर्ट",
|
||||
"NameExtraThemeSong": "थीम सॉन्ग",
|
||||
"NameExtraThemeVideo": "थीम वीडियो",
|
||||
"NameExtraTrailer": "ट्रेलर",
|
||||
"NameExtraUnknown": "अतिरिक्त",
|
||||
"CleanupUserDataTaskDescription": "कम से कम 90 दिनों से अनुपस्थित मीडिया से सभी उपयोगकर्ता डेटा (देखने की स्थिति, पसंदीदा स्थिति आदि) को साफ़ करता है।"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,17 @@
|
||||
"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",
|
||||
"LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo"
|
||||
"LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo",
|
||||
"NameExtraBehindTheScenes": "Iza kulisa",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Obrisana Scena",
|
||||
"NameExtraFeaturette": "Promotivni video",
|
||||
"NameExtraInterview": "Intervju",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Uzorak",
|
||||
"NameExtraScene": "Scena",
|
||||
"NameExtraShort": "Kratki film",
|
||||
"NameExtraThemeSong": "Glavna Pjesma",
|
||||
"NameExtraThemeVideo": "Tema videa",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
}
|
||||
|
||||
@@ -120,5 +120,6 @@
|
||||
"NameExtraThemeSong": "Főcímdal",
|
||||
"NameExtraThemeVideo": "Főcímvideó",
|
||||
"NameExtraTrailer": "Előzetes",
|
||||
"NameExtraUnknown": "Extra"
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraNumbered": "{0} {1}"
|
||||
}
|
||||
|
||||
@@ -24,5 +24,10 @@
|
||||
"TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը",
|
||||
"AppDeviceValues": "Հավելված` {0}, Սարք `{1}",
|
||||
"ChapterNameValue": "Գլուխ {0}",
|
||||
"Collections": "Հավաքածուներ"
|
||||
"Collections": "Հավաքածուներ",
|
||||
"Artists": "Երաժիշտներ",
|
||||
"Default": "Լռելյայն",
|
||||
"Favorites": "Ընտրյալ",
|
||||
"Forced": "Ստիպուած",
|
||||
"Genres": "Ոճ"
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"NameExtraSample": "Campione",
|
||||
"NameExtraShort": "Corto",
|
||||
"NameExtraThemeSong": "Sigla musicale",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraFeaturette": "Caratteristica",
|
||||
"NameExtraThemeVideo": "Video tematico",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,17 @@
|
||||
"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."
|
||||
"CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn.",
|
||||
"NameExtraBehindTheScenes": "Hannert de Kulissen",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Geläschte Scène",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Interview",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Beispill",
|
||||
"NameExtraScene": "Scène",
|
||||
"NameExtraShort": "Kuerzfilm",
|
||||
"NameExtraThemeSong": "Theme-Lidd",
|
||||
"NameExtraThemeVideo": "Theme-Video",
|
||||
"NameExtraTrailer": "Bande-Annonce"
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@
|
||||
"UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}",
|
||||
"UserOnlineFromDevice": "{0} prisijungęs iš {1}",
|
||||
"UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}",
|
||||
"UserStartedPlayingItemWithValues": "{0} atkuriama {1} į {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} baigė atkūrimą {1} į {2}",
|
||||
"VersionNumber": "Versija {0}",
|
||||
"TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.",
|
||||
"TaskUpdatePlugins": "Atnaujinti įskieius",
|
||||
@@ -87,7 +87,7 @@
|
||||
"TaskCleanActivityLog": "Išvalyti veiklos žurnalą",
|
||||
"Undefined": "Neapibrėžtas",
|
||||
"Forced": "Priverstinis",
|
||||
"Default": "Numatytas",
|
||||
"Default": "Numatytasis",
|
||||
"TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.",
|
||||
"TaskOptimizeDatabase": "Optimizuoti duomenų bazę",
|
||||
"TaskKeyframeExtractorDescription": "Iš vaizdo įrašo paruošia reikšminius kadrus, kad būtų sukuriamas tikslenis HLS grojaraštis. Šios užduoties vykdymas gali ilgai užtrukti.",
|
||||
@@ -100,14 +100,14 @@
|
||||
"TaskAudioNormalization": "Garso normalizavimas",
|
||||
"TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
|
||||
"TaskExtractMediaSegments": "Medijos segmentų nuskaitymas",
|
||||
"TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus",
|
||||
"TaskDownloadMissingLyrics": "Atsisiųsti trūkstamus dainų tekstus",
|
||||
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
|
||||
"TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą",
|
||||
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.",
|
||||
"TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius",
|
||||
"TaskDownloadMissingLyricsDescription": "Atsisiųsti dainų tekstus",
|
||||
"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ėgstamą būseną ir t. t.).",
|
||||
"LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}",
|
||||
"LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}",
|
||||
"NameExtraBehindTheScenes": "Užkulisiuose",
|
||||
"NameExtraClip": "Klipas",
|
||||
"NameExtraDeletedScene": "Ištrinta scena",
|
||||
|
||||
@@ -108,5 +108,17 @@
|
||||
"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",
|
||||
"LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}"
|
||||
"LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}",
|
||||
"NameExtraBehindTheScenes": "Aiz kadra",
|
||||
"NameExtraClip": "Klips",
|
||||
"NameExtraDeletedScene": "Izdzēsta aina",
|
||||
"NameExtraFeaturette": "Īsfilma",
|
||||
"NameExtraInterview": "Intervija",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Paraugs",
|
||||
"NameExtraScene": "Aina",
|
||||
"NameExtraShort": "Īsfilma",
|
||||
"NameExtraThemeSong": "Motīvu dziesma",
|
||||
"NameExtraThemeVideo": "Tēmas video",
|
||||
"NameExtraTrailer": "Treileris"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,11 @@
|
||||
"TaskAudioNormalization": "Normalisasi Audio",
|
||||
"TaskAudioNormalizationDescription": "Mengimbas fail-fail untuk data normalisasi audio.",
|
||||
"CleanupUserDataTaskDescription": "Membersihkan semua data pengguna (keadaan tontonan, status kegemaran, dan sebagainya) daripada media yang tidak lagi wujud sekurang-kurangnya selama 90 hari.",
|
||||
"CleanupUserDataTask": "Tugas pembersihan data pengguna"
|
||||
"CleanupUserDataTask": "Tugas pembersihan data pengguna",
|
||||
"LyricDownloadFailureFromForItem": "Lirik gagal dimuat turun dari {0} untuk {1}",
|
||||
"NameExtraBehindTheScenes": "Di Sebalik Takbir",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraInterview": "Temu bual",
|
||||
"NameExtraThemeSong": "Lagu Tema",
|
||||
"NameExtraThemeVideo": "Video Tema"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,15 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.",
|
||||
"TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.",
|
||||
"CleanupUserDataTaskDescription": "Sletter all brukerdata (avspillings-status, favoritter osv.) fra innhold som har vært utilgjengelig i minst 90 dager.",
|
||||
"CleanupUserDataTask": "Oppgave for opprydding av brukerdata"
|
||||
"CleanupUserDataTask": "Oppgave for opprydding av brukerdata",
|
||||
"NameExtraBehindTheScenes": "Bak kulissene",
|
||||
"NameExtraDeletedScene": "Slettet scene",
|
||||
"NameExtraFeaturette": "Presentasjonsfilm",
|
||||
"NameExtraInterview": "Intervju",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Prøve",
|
||||
"NameExtraScene": "Scene",
|
||||
"NameExtraThemeSong": "Tema-låt",
|
||||
"NameExtraThemeVideo": "Tema-video",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
}
|
||||
|
||||
@@ -120,5 +120,6 @@
|
||||
"NameExtraThemeVideo": "Vídeo de Abertura",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraFeaturette": "Nos Bastidores"
|
||||
"NameExtraFeaturette": "Nos Bastidores",
|
||||
"NameExtraInterview": "Entrevista"
|
||||
}
|
||||
|
||||
@@ -5,108 +5,121 @@
|
||||
"Books": "Livros",
|
||||
"ChapterNameValue": "Capítulo {0}",
|
||||
"Collections": "Coleções",
|
||||
"FailedLoginAttemptWithUserName": "Tentativa de login falhada a partir de {0}",
|
||||
"FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}",
|
||||
"Favorites": "Favoritos",
|
||||
"Folders": "Pastas",
|
||||
"Genres": "Géneros",
|
||||
"HeaderContinueWatching": "Continuar a ver",
|
||||
"HeaderFavoriteEpisodes": "Episódios Favoritos",
|
||||
"HeaderFavoriteShows": "Séries Favoritas",
|
||||
"HeaderLiveTV": "TV em Direto",
|
||||
"HeaderNextUp": "A Seguir",
|
||||
"HomeVideos": "Vídeos Caseiros",
|
||||
"HeaderFavoriteEpisodes": "Episódios favoritos",
|
||||
"HeaderFavoriteShows": "Séries favoritas",
|
||||
"HeaderLiveTV": "TV em direto",
|
||||
"HeaderNextUp": "A seguir",
|
||||
"HomeVideos": "Vídeos caseiros",
|
||||
"Inherit": "Herdar",
|
||||
"LabelIpAddressValue": "Endereço IP: {0}",
|
||||
"LabelRunningTimeValue": "Duração: {0}",
|
||||
"Latest": "Mais Recente",
|
||||
"MixedContent": "Conteúdo Misto",
|
||||
"Latest": "Mais recente",
|
||||
"MixedContent": "Conteúdo misto",
|
||||
"Movies": "Filmes",
|
||||
"Music": "Música",
|
||||
"MusicVideos": "Videoclipes",
|
||||
"NameInstallFailed": "{0} falha na instalação",
|
||||
"NameInstallFailed": "Falha na instalação de {0}",
|
||||
"NameSeasonNumber": "Temporada {0}",
|
||||
"NameSeasonUnknown": "Temporada Desconhecida",
|
||||
"NameSeasonUnknown": "Temporada desconhecida",
|
||||
"NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada",
|
||||
"NotificationOptionAudioPlayback": "Reprodução Iniciada",
|
||||
"NotificationOptionAudioPlaybackStopped": "Reprodução Parada",
|
||||
"NotificationOptionAudioPlayback": "Reprodução de áudio iniciada",
|
||||
"NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida",
|
||||
"NotificationOptionCameraImageUploaded": "Imagem da câmara enviada",
|
||||
"NotificationOptionInstallationFailed": "Falha na instalação",
|
||||
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
|
||||
"NotificationOptionPluginError": "Falha na extensão",
|
||||
"NotificationOptionPluginInstalled": "Extensão instalada",
|
||||
"NotificationOptionPluginUninstalled": "Extensão desinstalada",
|
||||
"NotificationOptionPluginUpdateInstalled": "Extensão atualizada",
|
||||
"NotificationOptionPluginError": "Falha no plugin",
|
||||
"NotificationOptionPluginInstalled": "Plugin instalado",
|
||||
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
|
||||
"NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada",
|
||||
"NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor",
|
||||
"NotificationOptionTaskFailed": "Falha em tarefa agendada",
|
||||
"NotificationOptionUserLockedOut": "Utilizador bloqueado",
|
||||
"NotificationOptionVideoPlayback": "Reprodução do vídeo iniciada",
|
||||
"NotificationOptionVideoPlaybackStopped": "Reprodução do vídeo parada",
|
||||
"NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada",
|
||||
"NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida",
|
||||
"Photos": "Fotografias",
|
||||
"PluginInstalledWithName": "{0} foi instalado",
|
||||
"PluginUninstalledWithName": "{0} foi desinstalado",
|
||||
"PluginUpdatedWithName": "{0} foi atualizado",
|
||||
"ScheduledTaskFailedWithName": "{0} falhou",
|
||||
"Shows": "Séries",
|
||||
"StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.",
|
||||
"SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}",
|
||||
"TvShows": "Séries",
|
||||
"StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.",
|
||||
"SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}",
|
||||
"TvShows": "Séries de TV",
|
||||
"UserCreatedWithName": "Utilizador {0} criado",
|
||||
"UserDeletedWithName": "Utilizador {0} apagado",
|
||||
"UserDeletedWithName": "Utilizador {0} eliminado",
|
||||
"UserDownloadingItemWithValues": "{0} está a transferir {1}",
|
||||
"UserLockedOutWithName": "Utilizador {0} bloqueado",
|
||||
"UserOfflineFromDevice": "{0} desligou-se a partir de {1}",
|
||||
"UserOnlineFromDevice": "{0} ligou-se a partir de {1}",
|
||||
"UserOfflineFromDevice": "{0} desligou-se de {1}",
|
||||
"UserOnlineFromDevice": "{0} está online em {1}",
|
||||
"UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
|
||||
"VersionNumber": "Versão {0}",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas em falta baseado na configuração de metadados.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.",
|
||||
"TaskDownloadMissingSubtitles": "Transferir legendas em falta",
|
||||
"TaskRefreshChannelsDescription": "Atualizar informação sobre canais da Internet.",
|
||||
"TaskRefreshChannels": "Atualizar Canais",
|
||||
"TaskCleanTranscodeDescription": "Apagar ficheiros de transcode com mais de um dia.",
|
||||
"TaskCleanTranscode": "Limpar a Diretoria de Transcode",
|
||||
"TaskUpdatePluginsDescription": "Faz o download e instala updates para os plugins que estão configurados para atualizar automaticamente.",
|
||||
"TaskUpdatePlugins": "Atualizar Plugins",
|
||||
"TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.",
|
||||
"TaskRefreshPeople": "Atualizar Pessoas",
|
||||
"TaskCleanLogsDescription": "Apagar ficheiros de log que têm mais de {0} dias.",
|
||||
"TaskCleanLogs": "Limpar a Diretoria de Logs",
|
||||
"TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.",
|
||||
"TaskRefreshLibrary": "Analisar mediateca",
|
||||
"TaskRefreshChapterImagesDescription": "Criar thumbnails para os vídeos que têm capítulos.",
|
||||
"TaskRefreshChapterImages": "Extrair Imagens dos Capítulos",
|
||||
"TaskCleanCacheDescription": "Apagar ficheiros em cache que já não são necessários.",
|
||||
"TaskCleanCache": "Limpar Cache",
|
||||
"TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.",
|
||||
"TaskRefreshChannels": "Atualizar canais",
|
||||
"TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.",
|
||||
"TaskCleanTranscode": "Limpar pasta de transcodificação",
|
||||
"TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.",
|
||||
"TaskUpdatePlugins": "Atualizar plugins",
|
||||
"TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.",
|
||||
"TaskRefreshPeople": "Atualizar pessoas",
|
||||
"TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.",
|
||||
"TaskCleanLogs": "Limpar pasta de registos",
|
||||
"TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.",
|
||||
"TaskRefreshLibrary": "Analisar biblioteca multimédia",
|
||||
"TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.",
|
||||
"TaskRefreshChapterImages": "Extrair imagens dos capítulos",
|
||||
"TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.",
|
||||
"TaskCleanCache": "Limpar pasta de cache",
|
||||
"TasksChannelsCategory": "Canais da Internet",
|
||||
"TasksApplicationCategory": "Aplicação",
|
||||
"TasksLibraryCategory": "Mediateca",
|
||||
"TasksLibraryCategory": "Biblioteca",
|
||||
"TasksMaintenanceCategory": "Manutenção",
|
||||
"TaskCleanActivityLogDescription": "Apaga as entradas do registo de atividade anteriores à data configurada.",
|
||||
"TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.",
|
||||
"TaskCleanActivityLog": "Limpar registo de atividade",
|
||||
"Undefined": "Indefinido",
|
||||
"Forced": "Forçado",
|
||||
"Default": "Predefinição",
|
||||
"TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.",
|
||||
"TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.",
|
||||
"TaskOptimizeDatabase": "Otimizar base de dados",
|
||||
"TaskKeyframeExtractorDescription": "Extrai quadros-chave de ficheiros de video para criar listas de reprodução HLS mais precisas. Esta tarefa pode demorar algum tempo.",
|
||||
"TaskKeyframeExtractor": "Extrator de Quadros-chave",
|
||||
"TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.",
|
||||
"TaskKeyframeExtractor": "Extrator de fotogramas-chave",
|
||||
"External": "Externo",
|
||||
"HearingImpaired": "Surdo",
|
||||
"HearingImpaired": "Deficiência auditiva",
|
||||
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
|
||||
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
|
||||
"TaskAudioNormalization": "Normalização de áudio",
|
||||
"TaskExtractMediaSegments": "Analisar segmentos de multimédia",
|
||||
"TaskDownloadMissingLyrics": "Transferir letra em falta",
|
||||
"TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
|
||||
"TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
|
||||
"CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
|
||||
"TaskExtractMediaSegments": "Analisar segmentos multimédia",
|
||||
"TaskDownloadMissingLyrics": "Transferir letras em falta",
|
||||
"TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay",
|
||||
"TaskDownloadMissingLyricsDescription": "Transfere letras de músicas",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.",
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.",
|
||||
"CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.",
|
||||
"CleanupUserDataTask": "Limpeza de dados de utilizador",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
|
||||
"LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}",
|
||||
"NameExtraDeletedScene": "Cena eliminada",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraBehindTheScenes": "Bastidores",
|
||||
"NameExtraSample": "Amostra",
|
||||
"NameExtraScene": "Cena",
|
||||
"NameExtraClip": "Excerto",
|
||||
"NameExtraFeaturette": "Minidocumentário",
|
||||
"NameExtraThemeSong": "Tema musical",
|
||||
"NameExtraThemeVideo": "Vídeo temático",
|
||||
"NameExtraShort": "Curta-metragem",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
}
|
||||
|
||||
@@ -1,124 +1,125 @@
|
||||
{
|
||||
"HeaderLiveTV": "TV Em Direto",
|
||||
"HeaderLiveTV": "TV em direto",
|
||||
"Collections": "Coleções",
|
||||
"Books": "Livros",
|
||||
"Artists": "Artistas",
|
||||
"HeaderNextUp": "A Seguir",
|
||||
"HeaderFavoriteEpisodes": "Episódios Favoritos",
|
||||
"HeaderFavoriteShows": "Séries Favoritas",
|
||||
"HeaderNextUp": "A seguir",
|
||||
"HeaderFavoriteEpisodes": "Episódios favoritos",
|
||||
"HeaderFavoriteShows": "Séries favoritas",
|
||||
"HeaderContinueWatching": "Continuar a ver",
|
||||
"Genres": "Géneros",
|
||||
"Folders": "Pastas",
|
||||
"Favorites": "Favoritos",
|
||||
"UserDownloadingItemWithValues": "{0} está sendo baixado {1}",
|
||||
"UserDownloadingItemWithValues": "{0} está a transferir {1}",
|
||||
"VersionNumber": "Versão {0}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
|
||||
"UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}",
|
||||
"UserPasswordChangedWithName": "A senha do usuário {0} foi alterada",
|
||||
"UserOnlineFromDevice": "{0} está online a partir de {1}",
|
||||
"UserOfflineFromDevice": "{0} desconectou-se a partir de {1}",
|
||||
"UserLockedOutWithName": "O usuário {0} foi bloqueado",
|
||||
"UserDeletedWithName": "O usuário {0} foi removido",
|
||||
"UserCreatedWithName": "O usuário {0} foi criado",
|
||||
"TvShows": "Séries",
|
||||
"SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}",
|
||||
"StartupEmbyServerIsLoading": "O servidor Jellyfin está iniciando. Tente novamente dentro de momentos.",
|
||||
"UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}",
|
||||
"UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}",
|
||||
"UserOnlineFromDevice": "{0} está online em {1}",
|
||||
"UserOfflineFromDevice": "{0} desligou-se de {1}",
|
||||
"UserLockedOutWithName": "Utilizador {0} bloqueado",
|
||||
"UserDeletedWithName": "Utilizador {0} eliminado",
|
||||
"UserCreatedWithName": "Utilizador {0} criado",
|
||||
"TvShows": "Séries de TV",
|
||||
"SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}",
|
||||
"StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.",
|
||||
"ScheduledTaskFailedWithName": "{0} falhou",
|
||||
"PluginUpdatedWithName": "{0} foi atualizado",
|
||||
"PluginUninstalledWithName": "{0} foi desinstalado",
|
||||
"PluginInstalledWithName": "{0} foi instalado",
|
||||
"NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada",
|
||||
"NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida",
|
||||
"NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada",
|
||||
"NotificationOptionUserLockedOut": "Usuário bloqueado",
|
||||
"NotificationOptionTaskFailed": "Falha na tarefa agendada",
|
||||
"NotificationOptionServerRestartRequired": "É necessário reiniciar o servidor",
|
||||
"NotificationOptionPluginUpdateInstalled": "Plugin atualizado",
|
||||
"NotificationOptionUserLockedOut": "Utilizador bloqueado",
|
||||
"NotificationOptionTaskFailed": "Falha em tarefa agendada",
|
||||
"NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor",
|
||||
"NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada",
|
||||
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
|
||||
"NotificationOptionPluginInstalled": "Plugin instalado",
|
||||
"NotificationOptionPluginError": "Falha no plugin",
|
||||
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
|
||||
"NotificationOptionInstallationFailed": "Falha de instalação",
|
||||
"NotificationOptionCameraImageUploaded": "Imagem de câmera enviada",
|
||||
"NotificationOptionAudioPlaybackStopped": "Reprodução Parada",
|
||||
"NotificationOptionAudioPlayback": "Reprodução Iniciada",
|
||||
"NotificationOptionApplicationUpdateInstalled": "A atualização do aplicativo foi instalada",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Uma atualização do aplicativo está disponível",
|
||||
"NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para download.",
|
||||
"NameSeasonUnknown": "Temporada Desconhecida",
|
||||
"NotificationOptionInstallationFailed": "Falha na instalação",
|
||||
"NotificationOptionCameraImageUploaded": "Imagem da câmara enviada",
|
||||
"NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida",
|
||||
"NotificationOptionAudioPlayback": "Reprodução de áudio iniciada",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível",
|
||||
"NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.",
|
||||
"NameSeasonUnknown": "Temporada desconhecida",
|
||||
"NameSeasonNumber": "Temporada {0}",
|
||||
"NameInstallFailed": "Falha na instalação de {0}",
|
||||
"MusicVideos": "Videoclipes",
|
||||
"Music": "Música",
|
||||
"MixedContent": "Conteúdo diverso",
|
||||
"Latest": "Mais Recente",
|
||||
"MixedContent": "Conteúdo misto",
|
||||
"Latest": "Mais recente",
|
||||
"LabelRunningTimeValue": "Duração: {0}",
|
||||
"LabelIpAddressValue": "Endereço de IP: {0}",
|
||||
"LabelIpAddressValue": "Endereço IP: {0}",
|
||||
"Inherit": "Herdar",
|
||||
"HomeVideos": "Vídeos Caseiros",
|
||||
"HomeVideos": "Vídeos caseiros",
|
||||
"Shows": "Séries",
|
||||
"Photos": "Fotografias",
|
||||
"Movies": "Filmes",
|
||||
"FailedLoginAttemptWithUserName": "Tentativa de início de sessão falhada a partir de {0}",
|
||||
"FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}",
|
||||
"ChapterNameValue": "Capítulo {0}",
|
||||
"AuthenticationSucceededWithUserName": "{0} autenticado com sucesso",
|
||||
"AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}",
|
||||
"TaskCleanCache": "Limpar Diretório de Cache",
|
||||
"TaskCleanCache": "Limpar pasta de cache",
|
||||
"TasksApplicationCategory": "Aplicação",
|
||||
"TasksLibraryCategory": "Mediateca",
|
||||
"TasksLibraryCategory": "Biblioteca",
|
||||
"TasksMaintenanceCategory": "Manutenção",
|
||||
"TaskRefreshChannels": "Atualizar Canais",
|
||||
"TaskUpdatePlugins": "Atualizar Plugins",
|
||||
"TaskCleanLogsDescription": "Deletar arquivos de log que existe a mais de {0} dias.",
|
||||
"TaskCleanLogs": "Limpar diretório de logs",
|
||||
"TaskRefreshLibrary": "Analisar mediateca",
|
||||
"TaskRefreshChannels": "Atualizar canais",
|
||||
"TaskUpdatePlugins": "Atualizar plugins",
|
||||
"TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.",
|
||||
"TaskCleanLogs": "Limpar pasta de registos",
|
||||
"TaskRefreshLibrary": "Analisar biblioteca multimédia",
|
||||
"TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.",
|
||||
"TaskCleanCacheDescription": "Apaga ficheiros em cache que já não são usados pelo sistema.",
|
||||
"TasksChannelsCategory": "Canais de Internet",
|
||||
"TaskRefreshChapterImages": "Extrair Imagens do Capítulo",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Pesquisa na Internet as legendas em falta com base na configuração de metadados.",
|
||||
"TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.",
|
||||
"TasksChannelsCategory": "Canais da Internet",
|
||||
"TaskRefreshChapterImages": "Extrair imagens dos capítulos",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.",
|
||||
"TaskDownloadMissingSubtitles": "Transferir legendas em falta",
|
||||
"TaskRefreshChannelsDescription": "Atualiza as informações do canal da Internet.",
|
||||
"TaskCleanTranscodeDescription": "Apagar os ficheiros com mais de um dia, de Transcode.",
|
||||
"TaskCleanTranscode": "Limpar o diretório de Transcode",
|
||||
"TaskUpdatePluginsDescription": "Baixa e instala as atualizações para plug-ins configurados para atualização automática.",
|
||||
"TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.",
|
||||
"TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.",
|
||||
"TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.",
|
||||
"TaskCleanTranscode": "Limpar pasta de transcodificação",
|
||||
"TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.",
|
||||
"TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.",
|
||||
"TaskRefreshPeople": "Atualizar pessoas",
|
||||
"TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.",
|
||||
"TaskCleanActivityLog": "Limpar registro de atividade",
|
||||
"TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.",
|
||||
"TaskCleanActivityLog": "Limpar registo de atividade",
|
||||
"Undefined": "Indefinido",
|
||||
"Forced": "Forçado",
|
||||
"Default": "Predefinição",
|
||||
"TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado.",
|
||||
"TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.",
|
||||
"TaskOptimizeDatabase": "Otimizar base de dados",
|
||||
"TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.",
|
||||
"TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.",
|
||||
"External": "Externo",
|
||||
"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 miniaturas de pré-visualização (Trickplay) para vídeos nas bibliotecas ativadas.",
|
||||
"HearingImpaired": "Deficiência auditiva",
|
||||
"TaskKeyframeExtractor": "Extrator de fotogramas-chave",
|
||||
"TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.",
|
||||
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de 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.",
|
||||
"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 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.",
|
||||
"TaskDownloadMissingLyrics": "Transferir letras em falta",
|
||||
"TaskDownloadMissingLyricsDescription": "Transfere letras de músicas",
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.",
|
||||
"TaskExtractMediaSegments": "Analisar segmentos multimédia",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.",
|
||||
"TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay",
|
||||
"CleanupUserDataTask": "Limpeza de dados de utilizador",
|
||||
"CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}",
|
||||
"LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}",
|
||||
"NameExtraBehindTheScenes": "Bastidores",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Cena Eliminada",
|
||||
"NameExtraFeaturette": "Média-metragem",
|
||||
"NameExtraClip": "Excerto",
|
||||
"NameExtraDeletedScene": "Cena eliminada",
|
||||
"NameExtraFeaturette": "Minidocumentário",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraSample": "Amostra",
|
||||
"NameExtraShort": "Curta-metragem",
|
||||
"NameExtraThemeSong": "Tema Principal",
|
||||
"NameExtraThemeVideo": "Vídeo de Abertura",
|
||||
"NameExtraThemeSong": "Tema musical",
|
||||
"NameExtraThemeVideo": "Vídeo temático",
|
||||
"NameExtraScene": "Cena",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraNumbered": "{0} {1}"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,17 @@
|
||||
"CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului",
|
||||
"CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile.",
|
||||
"LyricDownloadFailureFromForItem": "Versurile nu au putut fi descărcate din {0} pentru {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "În culise",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Scenă ștearsă",
|
||||
"NameExtraFeaturette": "Material bonus",
|
||||
"NameExtraInterview": "Interviu",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Monstră",
|
||||
"NameExtraScene": "Scenă",
|
||||
"NameExtraShort": "Scurt",
|
||||
"NameExtraThemeSong": "Audio de Fundal",
|
||||
"NameExtraThemeVideo": "Video de Fundal",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.",
|
||||
"Original": "Оригинальный",
|
||||
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}",
|
||||
"NameExtraBehindTheScenes": "За кулисами",
|
||||
"NameExtraBehindTheScenes": "За кадром",
|
||||
"NameExtraClip": "Отрывок",
|
||||
"NameExtraDeletedScene": "Удалённая сцена",
|
||||
"NameExtraFeaturette": "Короткометражка",
|
||||
@@ -119,5 +119,7 @@
|
||||
"NameExtraThemeSong": "Заглавная песня",
|
||||
"NameExtraThemeVideo": "Заглавное видео",
|
||||
"NameExtraTrailer": "Трейлер",
|
||||
"NameExtraUnknown": "Дополнительный материал"
|
||||
"NameExtraUnknown": "Дополнительный материал",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraShort": "Короткометражка"
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
"Default": "Predvolené",
|
||||
"TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.",
|
||||
"TaskOptimizeDatabase": "Optimalizovať databázu",
|
||||
"TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS playlistov. Táto úloha môže trvať dlhšiu dobu.",
|
||||
"TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z videosúborov na vytvorenie presnejších HLS zoznamov. Táto úloha môže trvať dlhší čas.",
|
||||
"TaskKeyframeExtractor": "Extraktor kľúčových snímkov",
|
||||
"External": "Externé",
|
||||
"HearingImpaired": "Sluchovo postihnutí",
|
||||
|
||||
@@ -108,5 +108,17 @@
|
||||
"CleanupUserDataTask": "Čiščenje uporabniških podatkov",
|
||||
"CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.",
|
||||
"LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "V zakulisju",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Izbrisan prizor",
|
||||
"NameExtraFeaturette": "Kratek dokumentarec o izdelavi filma",
|
||||
"NameExtraInterview": "Intervju",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Vzorec",
|
||||
"NameExtraScene": "Prizor",
|
||||
"NameExtraShort": "Kratki film",
|
||||
"NameExtraThemeSong": "Tematska Pesem",
|
||||
"NameExtraThemeVideo": "Tematski Video",
|
||||
"NameExtraTrailer": "Napovednik"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"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",
|
||||
"LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}",
|
||||
"Original": "Origjinal"
|
||||
"Original": "Origjinal",
|
||||
"NameExtraBehindTheScenes": "Pamje nga prapaskenat",
|
||||
"NameExtraClip": "Pjesë",
|
||||
"NameExtraDeletedScene": "Skenë e fshirë",
|
||||
"NameExtraFeaturette": "Film i shkurtër",
|
||||
"NameExtraInterview": "Intervistë",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Shembull",
|
||||
"NameExtraScene": "Skenë",
|
||||
"NameExtraShort": "Film i shkurtër",
|
||||
"NameExtraThemeSong": "Kënga e temës",
|
||||
"NameExtraThemeVideo": "Videoja e temës",
|
||||
"NameExtraTrailer": "Parapamje",
|
||||
"NameExtraUnknown": "Shtesë"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
|
||||
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
|
||||
"LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
|
||||
"Original": "Изворно"
|
||||
"Original": "Изворно",
|
||||
"NameExtraBehindTheScenes": "Иза кулиса",
|
||||
"NameExtraClip": "Исечак",
|
||||
"NameExtraDeletedScene": "Обрисана сцена",
|
||||
"NameExtraFeaturette": "Кратки прилог",
|
||||
"NameExtraInterview": "Интервју",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Узорак",
|
||||
"NameExtraScene": "Сцена",
|
||||
"NameExtraShort": "Кратки филм",
|
||||
"NameExtraThemeSong": "Музичка тема",
|
||||
"NameExtraThemeVideo": "Спот музичке теме",
|
||||
"NameExtraTrailer": "Реклама",
|
||||
"NameExtraUnknown": "Додатак"
|
||||
}
|
||||
|
||||
@@ -116,5 +116,10 @@
|
||||
"NameExtraScene": "Scen",
|
||||
"NameExtraShort": "Kortfilm",
|
||||
"NameExtraThemeSong": "Signaturmelodi",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraClip": "Klipp",
|
||||
"NameExtraFeaturette": "Kortfilm",
|
||||
"NameExtraSample": "Prov",
|
||||
"NameExtraThemeVideo": "Signaturvideo",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,20 @@
|
||||
"TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்",
|
||||
"TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.",
|
||||
"CleanupUserDataTaskDescription": "குறைந்தது 90 நாட்களுக்கு இல்லாத மீடியாவிலிருந்து அனைத்து பயனர் தரவையும் (கண்காணிப்பு நிலை, பிடித்த நிலை போன்றவை) சுத்தம் செய்கிறது.",
|
||||
"CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி"
|
||||
"CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி",
|
||||
"LyricDownloadFailureFromForItem": "{0} இலிருந்து {1} க்கு பாடல் வரிகளைப் பதிவிறக்க முடியவில்லை",
|
||||
"NameExtraBehindTheScenes": "திரைக்குப் பின்னால்",
|
||||
"NameExtraClip": "துண்டுக்காட்சி",
|
||||
"NameExtraDeletedScene": "நீக்கப்பட்ட காட்சிகள்",
|
||||
"NameExtraFeaturette": "திரைப்பின்னணித் தொகுப்பு",
|
||||
"NameExtraInterview": "நேர்காணல்",
|
||||
"NameExtraSample": "மாதிரி",
|
||||
"NameExtraScene": "காட்சி",
|
||||
"NameExtraShort": "குறுகிய",
|
||||
"NameExtraThemeSong": "மையப் பாடல்",
|
||||
"NameExtraThemeVideo": "மையக் காணொளி",
|
||||
"NameExtraTrailer": "முன்னோட்டம்",
|
||||
"NameExtraUnknown": "கூடுதல்",
|
||||
"Original": "அசல்",
|
||||
"NameExtraNumbered": "{0} {1}"
|
||||
}
|
||||
|
||||
@@ -22,20 +22,20 @@
|
||||
"NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว",
|
||||
"NameSeasonUnknown": "ไม่ทราบซีซัน",
|
||||
"NameSeasonNumber": "ซีซัน {0}",
|
||||
"NameInstallFailed": "การติดตั้ง {0} ล้มเหลว",
|
||||
"NameInstallFailed": "ติดตั้ง {0} ไม่สำเร็จ",
|
||||
"MusicVideos": "มิวสิควิดีโอ",
|
||||
"Music": "ดนตรี",
|
||||
"Music": "เพลง",
|
||||
"Movies": "ภาพยนตร์",
|
||||
"MixedContent": "เนื้อหาผสม",
|
||||
"Latest": "ล่าสุด",
|
||||
"LabelRunningTimeValue": "ผ่านไปแล้ว: {0}",
|
||||
"LabelIpAddressValue": "ที่อยู่ IP: {0}",
|
||||
"Inherit": "สืบทอด",
|
||||
"HomeVideos": "โฮมวิดีโอ",
|
||||
"HeaderNextUp": "ถัดไป",
|
||||
"HeaderLiveTV": "ทีวีสด",
|
||||
"HeaderFavoriteShows": "รายการที่ชื่นชอบ",
|
||||
"HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ",
|
||||
"MixedContent": "เนื้อหาหลากหลายประเภท",
|
||||
"Latest": "มาใหม่ล่าสุด",
|
||||
"LabelRunningTimeValue": "ความยาว: {0}",
|
||||
"LabelIpAddressValue": "หมายเลข IP: {0}",
|
||||
"Inherit": "ใช้ค่าเริ่มต้น",
|
||||
"HomeVideos": "วิดีโอส่วนตัว",
|
||||
"HeaderNextUp": "รายการถัดไป",
|
||||
"HeaderLiveTV": "ทีวีถ่ายทอดสด",
|
||||
"HeaderFavoriteShows": "รายการที่ชอบ",
|
||||
"HeaderFavoriteEpisodes": "ตอนที่ชอบ",
|
||||
"HeaderContinueWatching": "ดูต่อ",
|
||||
"Genres": "ประเภท",
|
||||
"Folders": "โฟลเดอร์",
|
||||
@@ -107,6 +107,19 @@
|
||||
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
|
||||
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
|
||||
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน",
|
||||
"LyricDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1}",
|
||||
"Original": "ต้นฉบับ"
|
||||
"LyricDownloadFailureFromForItem": "ดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1} ไม่สำเร็จ",
|
||||
"Original": "ต้นฉบับ",
|
||||
"NameExtraBehindTheScenes": "เบื้องหลังการถ่ายทำ",
|
||||
"NameExtraClip": "คลิปวิดีโอ",
|
||||
"NameExtraDeletedScene": "ฉากที่ถูกตัดออก",
|
||||
"NameExtraFeaturette": "คลิปสั้นพิเศษ",
|
||||
"NameExtraInterview": "บทสัมภาษณ์",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "ตัวอย่าง",
|
||||
"NameExtraScene": "ฉาก",
|
||||
"NameExtraShort": "ภาพยนตร์สั้น",
|
||||
"NameExtraThemeSong": "เพลงประกอบ",
|
||||
"NameExtraThemeVideo": "วิดีโอธีม",
|
||||
"NameExtraTrailer": "ตัวอย่างภาพยนตร์",
|
||||
"NameExtraUnknown": "เนื้อหาพิเศษ"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"AppDeviceValues": "Uygulama: {0}, Aygıt: {1}",
|
||||
"AppDeviceValues": "Uygulama: {0}, Cihaz: {1}",
|
||||
"Artists": "Sanatçılar",
|
||||
"AuthenticationSucceededWithUserName": "{0} kimliği başarıyla doğrulandı",
|
||||
"Books": "Kitaplar",
|
||||
|
||||
@@ -115,11 +115,11 @@
|
||||
"NameExtraFeaturette": "花絮",
|
||||
"NameExtraInterview": "采访",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "样本",
|
||||
"NameExtraScene": "场景",
|
||||
"NameExtraShort": "短片",
|
||||
"NameExtraSample": "试看",
|
||||
"NameExtraScene": "精选片段",
|
||||
"NameExtraShort": "短篇",
|
||||
"NameExtraThemeSong": "主题曲",
|
||||
"NameExtraThemeVideo": "主题视频",
|
||||
"NameExtraTrailer": "预告片",
|
||||
"NameExtraUnknown": "额外"
|
||||
"NameExtraUnknown": "额外内容"
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"Folders": "資料夾",
|
||||
"Genres": "風格",
|
||||
"HeaderContinueWatching": "繼續睇返",
|
||||
"HeaderFavoriteEpisodes": "心水嘅劇集",
|
||||
"HeaderFavoriteShows": "心水嘅節目",
|
||||
"HeaderFavoriteEpisodes": "心水劇集",
|
||||
"HeaderFavoriteShows": "心水節目",
|
||||
"HeaderLiveTV": "電視直播",
|
||||
"HeaderNextUp": "跟住落嚟",
|
||||
"HomeVideos": "家庭影片",
|
||||
@@ -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": "額外內容"
|
||||
}
|
||||
|
||||
@@ -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": "主題影片",
|
||||
"NameExtraTrailer": "預告片",
|
||||
"NameExtraUnknown": "額外內容"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Emby.Server.Implementations.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// A Redis-backed implementation of <see cref="ITranscodeSessionStore"/> that provides
|
||||
/// durable, distributed session tracking with lease-based ownership between pods.
|
||||
/// </summary>
|
||||
public sealed class RedisTranscodeSessionStore : ITranscodeSessionStore
|
||||
{
|
||||
private const string KeyPrefix = "jellyfin:transcode:";
|
||||
|
||||
/// <summary>
|
||||
/// Lua script for atomic takeover: reads the stored session, checks whether the lease has
|
||||
/// expired (comparing the stored expiry against the caller-supplied current time) and, if it
|
||||
/// has, claims it for the calling pod before returning 1; returns 0 otherwise.
|
||||
/// </summary>
|
||||
private const string TakeoverScript = @"
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then return 0 end
|
||||
local session = cjson.decode(raw)
|
||||
if tonumber(session['LeaseExpiresUtc']) > tonumber(ARGV[1]) then return 0 end
|
||||
session['OwnerPod'] = ARGV[2]
|
||||
session['LeaseExpiresUtc'] = tonumber(ARGV[1]) + tonumber(ARGV[3])
|
||||
redis.call('SET', KEYS[1], cjson.encode(session), 'PX', tonumber(ARGV[4]))
|
||||
return 1";
|
||||
|
||||
/// <summary>
|
||||
/// Lua script for atomic, ownership-checked renewal: extends the lease only while the calling
|
||||
/// pod still owns it, so a renewal racing a successful takeover cannot revert the new owner.
|
||||
/// </summary>
|
||||
private const string RenewScript = @"
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then return 0 end
|
||||
local session = cjson.decode(raw)
|
||||
if session['OwnerPod'] ~= ARGV[2] then return 0 end
|
||||
session['LeaseExpiresUtc'] = tonumber(ARGV[1]) + tonumber(ARGV[3])
|
||||
redis.call('SET', KEYS[1], cjson.encode(session), 'PX', tonumber(ARGV[4]))
|
||||
return 1";
|
||||
|
||||
// The lease expiry is serialized as unix milliseconds because the Lua scripts compare it
|
||||
// numerically; an ISO-8601 string cannot be compared against a number in Lua.
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new UnixMillisecondsDateTimeConverter() }
|
||||
};
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IDatabase _db;
|
||||
private readonly TranscodeStoreOptions _options;
|
||||
private readonly ILogger<RedisTranscodeSessionStore> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedisTranscodeSessionStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||
/// <param name="options">The transcode store configuration options.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public RedisTranscodeSessionStore(
|
||||
IConnectionMultiplexer redis,
|
||||
IOptions<TranscodeStoreOptions> options,
|
||||
ILogger<RedisTranscodeSessionStore> logger)
|
||||
{
|
||||
_redis = redis;
|
||||
_db = redis.GetDatabase();
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private long LeaseDurationMs => (long)_options.LeaseDurationSeconds * 1000;
|
||||
|
||||
private long RetentionMs => Math.Max((long)_options.SessionRetentionSeconds * 1000, LeaseDurationMs);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(session.PlaySessionId);
|
||||
var json = JsonSerializer.Serialize(session, _jsonOptions);
|
||||
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(RetentionMs)).ConfigureAwait(false);
|
||||
_logger.LogDebug("Set transcode session {PlaySessionId} in Redis.", session.PlaySessionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(playSessionId);
|
||||
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
|
||||
if (!raw.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString(), _jsonOptions);
|
||||
|
||||
// The record outlives the lease so that an orphaned session can still be taken over,
|
||||
// so the lease has to be checked explicitly here.
|
||||
if (session is null || session.LeaseExpiresUtc <= DateTime.UtcNow)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RenewLeaseAsync(string playSessionId, string ownerPod, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = (long?)await _db.ScriptEvaluateAsync(
|
||||
RenewScript,
|
||||
keys: new RedisKey[] { GetKey(playSessionId) },
|
||||
values: new RedisValue[] { UnixMillisecondsNow(), ownerPod, LeaseDurationMs, RetentionMs }).ConfigureAwait(false);
|
||||
|
||||
if (result != 1)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Pod {OwnerPod} no longer owns transcode session {PlaySessionId}; lease not renewed.",
|
||||
ownerPod,
|
||||
playSessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Renewed lease for transcode session {PlaySessionId}.", playSessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(playSessionId);
|
||||
await _db.KeyDeleteAsync(key).ConfigureAwait(false);
|
||||
_logger.LogDebug("Deleted transcode session {PlaySessionId} from Redis.", playSessionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = (long?)await _db.ScriptEvaluateAsync(
|
||||
TakeoverScript,
|
||||
keys: new RedisKey[] { GetKey(playSessionId) },
|
||||
values: new RedisValue[] { UnixMillisecondsNow(), claimingPod, LeaseDurationMs, RetentionMs }).ConfigureAwait(false);
|
||||
|
||||
var succeeded = result == 1;
|
||||
if (succeeded)
|
||||
{
|
||||
_logger.LogInformation("Pod {ClaimingPod} successfully took over transcode session {PlaySessionId}.", claimingPod, playSessionId);
|
||||
}
|
||||
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sessions = new List<TranscodeSession>();
|
||||
var servers = _redis.GetServers();
|
||||
|
||||
foreach (var server in servers)
|
||||
{
|
||||
if (!server.IsConnected)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var keys = new List<RedisKey>();
|
||||
await foreach (var key in server.KeysAsync(database: _db.Database, pattern: KeyPrefix + "*", pageSize: 1000).WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
keys.Add(key);
|
||||
}
|
||||
|
||||
var tasks = keys.Select(key => _db.StringGetAsync(key)).ToList();
|
||||
var values = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
|
||||
foreach (var raw in values)
|
||||
{
|
||||
if (!raw.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TranscodeSession? session;
|
||||
try
|
||||
{
|
||||
session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString(), _jsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize transcode session from Redis.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (session is not null && session.LeaseExpiresUtc > DateTime.UtcNow)
|
||||
{
|
||||
sessions.Add(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
private static string GetKey(string playSessionId) => KeyPrefix + playSessionId;
|
||||
|
||||
private static long UnixMillisecondsNow() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
private sealed class UnixMillisecondsDateTimeConverter : JsonConverter<DateTime>
|
||||
{
|
||||
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var milliseconds = reader.TryGetInt64(out var value) ? value : (long)reader.GetDouble();
|
||||
return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds).UtcDateTime;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
||||
{
|
||||
var utc = value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
writer.WriteNumberValue(new DateTimeOffset(utc, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Emby.Server.Implementations.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// Pings the configured Redis transcode session store once at startup so an unreachable store is
|
||||
/// reported there instead of being discovered as a silent loss of cross-pod takeover.
|
||||
/// </summary>
|
||||
public sealed class TranscodeStoreConnectivityProbe : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<TranscodeStoreConnectivityProbe> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TranscodeStoreConnectivityProbe"/> class.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The service provider used to resolve the Redis connection.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public TranscodeStoreConnectivityProbe(IServiceProvider serviceProvider, ILogger<TranscodeStoreConnectivityProbe> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Resolved here rather than injected: connecting must not be able to abort startup.
|
||||
var redis = _serviceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var roundTrip = await redis.GetDatabase().PingAsync().ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Redis transcode session store is reachable ({RoundTripMs}ms round trip). HA transcode takeover is active.",
|
||||
(long)roundTrip.TotalMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Redis transcode session store is configured but UNREACHABLE. HA transcode takeover is not working: sessions stay local to this instance and are lost when it restarts. Check {Key}.",
|
||||
TranscodeStoreOptions.RedisConnectionStringKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -395,29 +395,11 @@ namespace Emby.Server.Implementations.Plugins
|
||||
var url = new Uri(packageInfo.ImageUrl);
|
||||
imagePath = Path.Join(path, url.Segments[^1]);
|
||||
|
||||
var fileStream = AsyncFile.OpenWrite(imagePath);
|
||||
Stream? downloadStream = null;
|
||||
try
|
||||
// The catalog is refreshed on every dashboard visit and rewrites the manifest of
|
||||
// every installed plugin, so only fetch an image that is actually missing.
|
||||
if (!ImageExists(imagePath))
|
||||
{
|
||||
downloadStream = await HttpClientFactory
|
||||
.CreateClient(NamedClient.Default)
|
||||
.GetStreamAsync(url)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath);
|
||||
imagePath = string.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fileStream.DisposeAsync().ConfigureAwait(false);
|
||||
if (downloadStream is not null)
|
||||
{
|
||||
await downloadStream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
imagePath = await DownloadImage(url, imagePath).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +438,67 @@ namespace Emby.Server.Implementations.Plugins
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ImageExists(string imagePath)
|
||||
{
|
||||
var image = new FileInfo(imagePath);
|
||||
|
||||
// A previous download may have been interrupted, leaving an empty file behind.
|
||||
return image.Exists && image.Length > 0;
|
||||
}
|
||||
|
||||
private async Task<string> DownloadImage(Uri url, string imagePath)
|
||||
{
|
||||
// Download to a temporary file and move it into place, so that neither a failed download
|
||||
// nor a concurrent one can be observed as a partially written image.
|
||||
var tempPath = imagePath + "." + Path.GetRandomFileName();
|
||||
|
||||
try
|
||||
{
|
||||
var fileStream = AsyncFile.Create(tempPath);
|
||||
Stream? downloadStream = null;
|
||||
try
|
||||
{
|
||||
downloadStream = await HttpClientFactory
|
||||
.CreateClient(NamedClient.Default)
|
||||
.GetStreamAsync(url)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fileStream.DisposeAsync().ConfigureAwait(false);
|
||||
if (downloadStream is not null)
|
||||
{
|
||||
await downloadStream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(tempPath, imagePath, true);
|
||||
|
||||
return imagePath;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath);
|
||||
TryDeleteFile(tempPath);
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unable to delete {Path}.", path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path.
|
||||
/// If no file is found, no reconciliation occurs.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.ScheduledTasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Emby.Server.Implementations.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// A Redis-backed <see cref="IScanLeaderLease"/> that elects a single scan-leader instance using a
|
||||
/// TTL lease on a shared key. The lease value is this instance's pod identity; a leader that keeps
|
||||
/// renewing retains the lease, and any instance can claim it once the previous leader's lease expires.
|
||||
/// </summary>
|
||||
public sealed class RedisScanLeaderLease : IScanLeaderLease
|
||||
{
|
||||
private const string LeaderKey = "jellyfin:scanleader";
|
||||
|
||||
/// <summary>
|
||||
/// Lua script for atomic acquire-or-renew: if the key is unset (missing or already expired) it is
|
||||
/// set to this pod for the lease duration and 1 is returned; if it already holds this pod the TTL is
|
||||
/// extended and 1 is returned; otherwise another pod owns a live lease and 0 is returned.
|
||||
/// </summary>
|
||||
private const string AcquireOrRenewScript = @"
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if not current then
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
||||
return 1
|
||||
elseif current == ARGV[1] then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end";
|
||||
|
||||
private readonly IDatabase _db;
|
||||
private readonly ScanLeaderOptions _options;
|
||||
private readonly ILogger<RedisScanLeaderLease> _logger;
|
||||
private readonly string _podId;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedisScanLeaderLease"/> class.
|
||||
/// </summary>
|
||||
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||
/// <param name="options">The scan-leader configuration options.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public RedisScanLeaderLease(
|
||||
IConnectionMultiplexer redis,
|
||||
IOptions<ScanLeaderOptions> options,
|
||||
ILogger<RedisScanLeaderLease> logger)
|
||||
{
|
||||
_db = redis.GetDatabase();
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_podId = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TryAcquireOrRenewAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
|
||||
|
||||
try
|
||||
{
|
||||
var result = (long?)await _db.ScriptEvaluateAsync(
|
||||
AcquireOrRenewScript,
|
||||
keys: new RedisKey[] { LeaderKey },
|
||||
values: new RedisValue[] { _podId, leaseDurationMs }).ConfigureAwait(false);
|
||||
|
||||
return result == 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail-safe: if Redis is unreachable, treat this instance as the leader so scheduled scans
|
||||
// keep running. Every instance scanning is preferable to no instance scanning.
|
||||
_logger.LogWarning(ex, "Scan-leader lease evaluation failed; treating {PodId} as leader.", _podId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Jellyfin.Data.Events;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.ScheduledTasks;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -27,6 +28,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
||||
private readonly IApplicationPaths _applicationPaths;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ITaskManager _taskManager;
|
||||
private readonly IScanLeaderLease _scanLeaderLease;
|
||||
private readonly ScanLeaderOptions _scanLeaderOptions;
|
||||
private readonly Lock _lastExecutionResultSyncLock = new();
|
||||
private bool _readFromFile;
|
||||
private TaskResult _lastExecutionResult;
|
||||
@@ -41,6 +44,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
||||
/// <param name="applicationPaths">The application paths.</param>
|
||||
/// <param name="taskManager">The task manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="scanLeaderLease">The scan-leader lease used to gate periodic library-mutating tasks, or <c>null</c> to disable gating.</param>
|
||||
/// <param name="scanLeaderOptions">The scan-leader options, or <c>null</c> to disable gating.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// scheduledTask
|
||||
/// or
|
||||
@@ -52,7 +57,13 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
||||
/// or
|
||||
/// logger.
|
||||
/// </exception>
|
||||
public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger)
|
||||
public ScheduledTaskWorker(
|
||||
IScheduledTask scheduledTask,
|
||||
IApplicationPaths applicationPaths,
|
||||
ITaskManager taskManager,
|
||||
ILogger logger,
|
||||
IScanLeaderLease scanLeaderLease = null,
|
||||
ScanLeaderOptions scanLeaderOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scheduledTask);
|
||||
ArgumentNullException.ThrowIfNull(applicationPaths);
|
||||
@@ -63,6 +74,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
||||
_applicationPaths = applicationPaths;
|
||||
_taskManager = taskManager;
|
||||
_logger = logger;
|
||||
_scanLeaderLease = scanLeaderLease;
|
||||
_scanLeaderOptions = scanLeaderOptions;
|
||||
|
||||
InitTriggerEvents();
|
||||
}
|
||||
@@ -268,6 +281,20 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
||||
|
||||
trigger.Stop();
|
||||
|
||||
if (_scanLeaderLease is not null
|
||||
&& _scanLeaderOptions is not null
|
||||
&& _scanLeaderOptions.Enabled
|
||||
&& _scanLeaderOptions.GatedTaskKeys is not null
|
||||
&& _scanLeaderOptions.GatedTaskKeys.Contains(ScheduledTask.Key, StringComparer.Ordinal)
|
||||
&& !await _scanLeaderLease.TryAcquireOrRenewAsync().ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogDebug("Skipping gated task {Task}: this instance does not hold the scan-leader lease.", Name);
|
||||
|
||||
// Re-arm the trigger for the next interval without enqueueing on this instance.
|
||||
trigger.Start(LastExecutionResult, _logger, Name, false);
|
||||
return;
|
||||
}
|
||||
|
||||
_taskManager.QueueScheduledTask(ScheduledTask, trigger.TaskOptions);
|
||||
|
||||
await Task.Delay(1000).ConfigureAwait(false);
|
||||
|
||||
@@ -5,8 +5,10 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Events;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.ScheduledTasks;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Emby.Server.Implementations.ScheduledTasks;
|
||||
|
||||
@@ -23,18 +25,26 @@ public class TaskManager : ITaskManager
|
||||
|
||||
private readonly IApplicationPaths _applicationPaths;
|
||||
private readonly ILogger<TaskManager> _logger;
|
||||
private readonly IScanLeaderLease? _scanLeaderLease;
|
||||
private readonly ScanLeaderOptions? _scanLeaderOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TaskManager" /> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">The application paths.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="scanLeaderLease">The scan-leader lease used to gate periodic library-mutating tasks, or <c>null</c> to disable gating.</param>
|
||||
/// <param name="scanLeaderOptions">The scan-leader options, or <c>null</c> to disable gating.</param>
|
||||
public TaskManager(
|
||||
IApplicationPaths applicationPaths,
|
||||
ILogger<TaskManager> logger)
|
||||
ILogger<TaskManager> logger,
|
||||
IScanLeaderLease? scanLeaderLease = null,
|
||||
IOptions<ScanLeaderOptions>? scanLeaderOptions = null)
|
||||
{
|
||||
_applicationPaths = applicationPaths;
|
||||
_logger = logger;
|
||||
_scanLeaderLease = scanLeaderLease;
|
||||
_scanLeaderOptions = scanLeaderOptions?.Value;
|
||||
|
||||
ScheduledTasks = [];
|
||||
}
|
||||
@@ -175,7 +185,7 @@ public class TaskManager : ITaskManager
|
||||
/// <inheritdoc />
|
||||
public void AddTasks(IEnumerable<IScheduledTask> tasks)
|
||||
{
|
||||
var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _logger));
|
||||
var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _logger, _scanLeaderLease, _scanLeaderOptions));
|
||||
|
||||
ScheduledTasks = ScheduledTasks.Concat(list).ToArray();
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ public partial class AudioNormalizationTask : IScheduledTask
|
||||
if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol)
|
||||
{
|
||||
t.LUFS = await CalculateLUFSAsync(
|
||||
string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)),
|
||||
string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.EscapeProcessArgument()),
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
toSaveDbItems.Add(t);
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
@@ -21,6 +22,7 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
||||
private readonly IConfigurationManager _configurationManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly ITranscodeSessionStore _sessionStore;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
|
||||
@@ -29,16 +31,19 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
|
||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||
/// <param name="sessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface.</param>
|
||||
public DeleteTranscodeFileTask(
|
||||
ILogger<DeleteTranscodeFileTask> logger,
|
||||
IFileSystem fileSystem,
|
||||
IConfigurationManager configurationManager,
|
||||
ILocalizationManager localization)
|
||||
ILocalizationManager localization,
|
||||
ITranscodeSessionStore sessionStore)
|
||||
{
|
||||
_logger = logger;
|
||||
_fileSystem = fileSystem;
|
||||
_configurationManager = configurationManager;
|
||||
_localization = localization;
|
||||
_sessionStore = sessionStore;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -78,25 +83,39 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var minDateModified = DateTime.UtcNow.AddDays(-1);
|
||||
progress.Report(50);
|
||||
|
||||
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, progress, cancellationToken);
|
||||
IEnumerable<TranscodeSession> activeSessions;
|
||||
try
|
||||
{
|
||||
activeSessions = await _sessionStore.GetActiveSessionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve active transcode sessions. Skipping deletion to avoid removing files in use.");
|
||||
progress.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, activeSessions, progress, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the transcoded temp files from directory with a last write time less than a given date.
|
||||
/// Deletes the transcoded temp files from directory with a last write time less than a given date,
|
||||
/// skipping any files that belong to an active transcode session.
|
||||
/// </summary>
|
||||
/// <param name="directory">The directory.</param>
|
||||
/// <param name="minDateModified">The min date modified.</param>
|
||||
/// <param name="activeSessions">The currently active transcode sessions.</param>
|
||||
/// <param name="progress">The progress.</param>
|
||||
/// <param name="cancellationToken">The task cancellation token.</param>
|
||||
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IEnumerable<TranscodeSession> activeSessions, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var activeSessionList = activeSessions.ToList();
|
||||
|
||||
var filesToDelete = _fileSystem.GetFiles(directory, true)
|
||||
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
|
||||
.ToList();
|
||||
@@ -112,6 +131,13 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (IsFileProtectedByActiveSession(file.FullName, activeSessionList))
|
||||
{
|
||||
_logger.LogDebug("Skipping deletion of {FilePath} as it belongs to an active transcode session.", file.FullName);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
|
||||
|
||||
index++;
|
||||
@@ -121,4 +147,24 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private static bool IsFileProtectedByActiveSession(string filePath, IList<TranscodeSession> activeSessions)
|
||||
{
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(session.ManifestPath) &&
|
||||
string.Equals(filePath, session.ManifestPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(session.SegmentPathPrefix) &&
|
||||
filePath.StartsWith(session.SegmentPathPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Optimizes Jellyfin's database by issuing a VACUUM command.
|
||||
/// Optimizes Jellyfin's database by issuing VACUUM and ANALYZE commands.
|
||||
/// </summary>
|
||||
public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
||||
{
|
||||
@@ -82,7 +82,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
|
||||
_logger.LogInformation("Vacuuming and analyzing jellyfin.db...");
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.Library.Validators;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
@@ -29,6 +30,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<PeopleValidationTask> _logger;
|
||||
private readonly ILogger<PeopleValidator> _validatorLogger;
|
||||
private readonly IItemTypeLookup _itemTypeLookup;
|
||||
|
||||
/// <summary>
|
||||
@@ -39,6 +41,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
/// <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>
|
||||
/// <param name="validatorLogger">Instance of the <see cref="ILogger{PeopleValidator}"/> interface.</param>
|
||||
/// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
|
||||
public PeopleValidationTask(
|
||||
ILibraryManager libraryManager,
|
||||
@@ -46,6 +49,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILogger<PeopleValidationTask> logger,
|
||||
ILogger<PeopleValidator> validatorLogger,
|
||||
IItemTypeLookup itemTypeLookup)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
@@ -53,6 +57,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_fileSystem = fileSystem;
|
||||
_logger = logger;
|
||||
_validatorLogger = validatorLogger;
|
||||
_itemTypeLookup = itemTypeLookup;
|
||||
}
|
||||
|
||||
@@ -109,6 +114,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
var dupQuery = context.Peoples
|
||||
.GroupBy(e => new { e.Name, e.PersonType })
|
||||
.Where(e => e.Count() > 1)
|
||||
.OrderBy(e => e.Key.Name)
|
||||
.ThenBy(e => e.Key.PersonType)
|
||||
.Select(e => e.Select(f => f.Id).ToArray());
|
||||
|
||||
var total = dupQuery.Count();
|
||||
@@ -163,7 +170,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
// 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);
|
||||
await new PeopleValidator(_libraryManager, _validatorLogger)
|
||||
.Run(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));
|
||||
@@ -177,33 +186,14 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
|
||||
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
||||
|
||||
List<Guid> peopleIds;
|
||||
|
||||
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
|
||||
// Read the candidates in one go rather than paging them. A refresh stamps the person and takes
|
||||
// it out of this set, so a growing offset over a shrinking set walks past people it never visits.
|
||||
peopleIds = await context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Type == personTypeName)
|
||||
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
|
||||
@@ -211,22 +201,36 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
!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++;
|
||||
}
|
||||
.Select(b => b.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
progress.Report(100.0 * numComplete / numPeople);
|
||||
_logger.LogDebug("Found {Count} people needing image/overview refresh", peopleIds.Count);
|
||||
|
||||
if (peopleIds.Count == 0)
|
||||
{
|
||||
progress.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
var numComplete = 0;
|
||||
var numRefreshed = 0;
|
||||
|
||||
foreach (var personId in peopleIds)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (await RefreshPersonAsync(personId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
numRefreshed++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
|
||||
numComplete++;
|
||||
progress.Report(100.0 * numComplete / peopleIds.Count);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
|
||||
}
|
||||
|
||||
private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken)
|
||||
@@ -243,8 +247,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
|
||||
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default,
|
||||
MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default
|
||||
ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh,
|
||||
MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh
|
||||
};
|
||||
|
||||
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -107,6 +107,11 @@ public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask
|
||||
{
|
||||
_logger.LogError(ex, "Error updating {Name}", package.Name);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
// One slow download must not abort the updates for the remaining plugins.
|
||||
_logger.LogError(ex, "Error downloading {Name}", package.Name);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating {Name}", package.Name);
|
||||
|
||||
@@ -256,7 +256,7 @@ namespace Emby.Server.Implementations.Session
|
||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
|
||||
var activityDate = DateTime.UtcNow;
|
||||
var session = GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
|
||||
var session = await GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
|
||||
var lastActivityDate = session.LastActivityDate;
|
||||
session.LastActivityDate = activityDate;
|
||||
|
||||
@@ -309,7 +309,7 @@ namespace Emby.Server.Implementations.Session
|
||||
{
|
||||
if (!session.SessionControllers.Any(i => i.IsSessionActive))
|
||||
{
|
||||
var key = GetSessionKey(session.Client, session.DeviceId);
|
||||
var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
|
||||
|
||||
_activeConnections.TryRemove(key, out _);
|
||||
if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId))
|
||||
@@ -369,7 +369,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
var key = GetSessionKey(session.Client, session.DeviceId);
|
||||
var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
|
||||
|
||||
_activeConnections.TryRemove(key, out _);
|
||||
|
||||
@@ -475,8 +475,11 @@ namespace Emby.Server.Implementations.Session
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSessionKey(string appName, string deviceId)
|
||||
=> appName + deviceId;
|
||||
// The user is part of the key because the client name and the device id are taken from the
|
||||
// request headers and are not bound to the access token. Without it, any authenticated user
|
||||
// could claim another user's client/device pair and take over their session.
|
||||
private static string GetSessionKey(string appName, string deviceId, Guid userId)
|
||||
=> appName + deviceId + userId.ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection.
|
||||
@@ -488,7 +491,7 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <param name="remoteEndPoint">The remote end point.</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>SessionInfo.</returns>
|
||||
private SessionInfo GetSessionInfo(
|
||||
private async Task<SessionInfo> GetSessionInfo(
|
||||
string appName,
|
||||
string appVersion,
|
||||
string deviceId,
|
||||
@@ -500,8 +503,8 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
|
||||
var key = GetSessionKey(appName, deviceId);
|
||||
SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
|
||||
var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty);
|
||||
SessionInfo newSession = await CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
|
||||
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
|
||||
if (ReferenceEquals(newSession, sessionInfo))
|
||||
{
|
||||
@@ -529,7 +532,7 @@ namespace Emby.Server.Implementations.Session
|
||||
return sessionInfo;
|
||||
}
|
||||
|
||||
private SessionInfo CreateSessionInfo(
|
||||
private async Task<SessionInfo> CreateSessionInfo(
|
||||
string key,
|
||||
string appName,
|
||||
string appVersion,
|
||||
@@ -559,7 +562,7 @@ namespace Emby.Server.Implementations.Session
|
||||
deviceName = "Network Device";
|
||||
}
|
||||
|
||||
var deviceOptions = _deviceManager.GetDeviceOptions(deviceId) ?? new()
|
||||
var deviceOptions = await _deviceManager.GetDeviceOptions(deviceId).ConfigureAwait(false) ?? new()
|
||||
{
|
||||
DeviceId = deviceId
|
||||
};
|
||||
@@ -655,7 +658,7 @@ namespace Emby.Server.Implementations.Session
|
||||
ItemId = session.NowPlayingItem is null ? Guid.Empty : session.NowPlayingItem.Id,
|
||||
SessionId = session.Id,
|
||||
MediaSourceId = session.PlayState?.MediaSourceId,
|
||||
PositionTicks = session.PlayState?.PositionTicks
|
||||
PositionTicks = session.LastPlaybackCheckInPositionTicks
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1537,11 +1540,52 @@ namespace Emby.Server.Implementations.Session
|
||||
return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken);
|
||||
}
|
||||
|
||||
private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
|
||||
private void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(controllingSession);
|
||||
|
||||
var controllingUserId = controllingSession.UserId;
|
||||
|
||||
// Controlling a session is always allowed when:
|
||||
// - the caller has no associated user (an API key, which is a privileged context),
|
||||
// - the target session is public (has no owning user), or
|
||||
// - the caller's user is associated with the target session.
|
||||
// Controlling a session owned by a different user requires the
|
||||
// EnableRemoteControlOfOtherUsers permission.
|
||||
if (controllingUserId.IsEmpty()
|
||||
|| session.UserId.IsEmpty()
|
||||
|| session.ContainsUser(controllingUserId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var controllingUser = _userManager.GetUserById(controllingUserId);
|
||||
if (controllingUser is null
|
||||
|| !controllingUser.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
|
||||
{
|
||||
throw new SecurityException("The current user does not have permission to remote control other users.");
|
||||
}
|
||||
}
|
||||
|
||||
private void AssertCanAttachUser(SessionInfo controllingSession, Guid userId)
|
||||
{
|
||||
var controllingUserId = controllingSession.UserId;
|
||||
|
||||
// Playback reported by a session is also written to the user data of its additional users,
|
||||
// so attaching anyone but the calling user requires administrative privileges.
|
||||
if (controllingUserId.IsEmpty() || controllingUserId.Equals(userId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var controllingUser = _userManager.GetUserById(controllingUserId);
|
||||
if (controllingUser is null
|
||||
|| !controllingUser.HasPermission(PermissionKind.IsAdministrator))
|
||||
{
|
||||
throw new SecurityException("The current user does not have permission to attach another user to a session.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1559,16 +1603,24 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <summary>
|
||||
/// Adds the additional user.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="userId">The user identifier.</param>
|
||||
/// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
|
||||
/// <exception cref="SecurityException">The controlling user is not allowed to attach the user to the session.</exception>
|
||||
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
|
||||
public void AddAdditionalUser(string sessionId, Guid userId)
|
||||
public void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
var controllingSession = GetSession(controllingSessionId);
|
||||
AssertCanControl(session, controllingSession);
|
||||
AssertCanAttachUser(controllingSession, userId);
|
||||
}
|
||||
|
||||
if (session.UserId.Equals(userId))
|
||||
{
|
||||
throw new ArgumentException("The requested user is already the primary user of the session.");
|
||||
@@ -1576,7 +1628,8 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId)))
|
||||
{
|
||||
var user = _userManager.GetUserById(userId);
|
||||
var user = _userManager.GetUserById(userId)
|
||||
?? throw new ArgumentException("The requested user does not exist.");
|
||||
var newUser = new SessionUserInfo
|
||||
{
|
||||
UserId = userId,
|
||||
@@ -1590,16 +1643,22 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <summary>
|
||||
/// Removes the additional user.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="userId">The user identifier.</param>
|
||||
/// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
|
||||
/// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
|
||||
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
|
||||
public void RemoveAdditionalUser(string sessionId, Guid userId)
|
||||
public void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
AssertCanControl(session, GetSession(controllingSessionId));
|
||||
}
|
||||
|
||||
if (session.UserId.Equals(userId))
|
||||
{
|
||||
throw new ArgumentException("The requested user is already the primary user of the session.");
|
||||
@@ -1709,12 +1768,12 @@ namespace Emby.Server.Implementations.Session
|
||||
// This should be validated above, but if it isn't don't delete all tokens.
|
||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
|
||||
var existing = _deviceManager.GetDevices(
|
||||
var existing = (await _deviceManager.GetDevices(
|
||||
new DeviceQuery
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
UserId = user.Id
|
||||
}).Items;
|
||||
}).ConfigureAwait(false)).Items;
|
||||
|
||||
foreach (var auth in existing)
|
||||
{
|
||||
@@ -1742,12 +1801,12 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(accessToken);
|
||||
|
||||
var existing = _deviceManager.GetDevices(
|
||||
var existing = (await _deviceManager.GetDevices(
|
||||
new DeviceQuery
|
||||
{
|
||||
Limit = 1,
|
||||
AccessToken = accessToken
|
||||
}).Items;
|
||||
}).ConfigureAwait(false)).Items;
|
||||
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
@@ -1786,10 +1845,10 @@ namespace Emby.Server.Implementations.Session
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var existing = _deviceManager.GetDevices(new DeviceQuery
|
||||
var existing = await _deviceManager.GetDevices(new DeviceQuery
|
||||
{
|
||||
UserId = userId
|
||||
});
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
foreach (var info in existing.Items)
|
||||
{
|
||||
@@ -1803,14 +1862,21 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <summary>
|
||||
/// Reports the capabilities.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="capabilities">The capabilities.</param>
|
||||
public void ReportCapabilities(string sessionId, ClientCapabilities capabilities)
|
||||
/// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
|
||||
public void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities)
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
AssertCanControl(session, GetSession(controllingSessionId));
|
||||
}
|
||||
|
||||
ReportCapabilities(session, capabilities, true);
|
||||
}
|
||||
|
||||
@@ -1905,13 +1971,18 @@ namespace Emby.Server.Implementations.Session
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ReportNowViewingItem(string sessionId, string itemId)
|
||||
public void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(itemId);
|
||||
|
||||
var item = _libraryManager.GetItemById(new Guid(itemId));
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
AssertCanControl(session, GetSession(controllingSessionId));
|
||||
}
|
||||
|
||||
session.NowViewingItem = GetItemInfo(item, null);
|
||||
}
|
||||
|
||||
@@ -1974,11 +2045,11 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <inheritdoc />
|
||||
public async Task<SessionInfo> GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint)
|
||||
{
|
||||
var items = _deviceManager.GetDevices(new DeviceQuery
|
||||
var items = (await _deviceManager.GetDevices(new DeviceQuery
|
||||
{
|
||||
AccessToken = token,
|
||||
Limit = 1
|
||||
}).Items;
|
||||
}).ConfigureAwait(false)).Items;
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
|
||||
@@ -90,6 +90,18 @@ namespace Emby.Server.Implementations.SyncPlay
|
||||
/// <value>The default ping.</value>
|
||||
public long DefaultPing { get; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum ping, in milliseconds, accepted from a session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pings are reported by clients and are scaled into the delays used to schedule playback,
|
||||
/// so an unbounded value lets a single session push the whole group's resume point
|
||||
/// arbitrarily far out, or overflow the arithmetic entirely. Anything above this is not a
|
||||
/// usable measurement for synchronisation.
|
||||
/// </remarks>
|
||||
/// <value>The maximum ping.</value>
|
||||
public long MaxPing { get; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
|
||||
/// </summary>
|
||||
@@ -438,7 +450,7 @@ namespace Emby.Server.Implementations.SyncPlay
|
||||
{
|
||||
if (_participants.TryGetValue(session.Id, out GroupMember value))
|
||||
{
|
||||
value.Ping = ping;
|
||||
value.Ping = Math.Clamp(ping, 0, MaxPing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,7 +463,9 @@ namespace Emby.Server.Implementations.SyncPlay
|
||||
max = Math.Max(max, session.Ping);
|
||||
}
|
||||
|
||||
return max;
|
||||
// A group with no participants has no ping to report. Returning long.MinValue would
|
||||
// overflow the callers that scale this value into ticks, so fall back to the default.
|
||||
return max == long.MinValue ? DefaultPing : max;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -181,8 +181,8 @@ namespace Emby.Server.Implementations.SyncPlay
|
||||
{
|
||||
if (existingGroup.GroupId.Equals(request.GroupId))
|
||||
{
|
||||
// Restore session.
|
||||
UpdateSessionsCounter(session.UserId, 1);
|
||||
// Restore session. The session is already in the group and has already
|
||||
// been counted, so the counter must not be incremented a second time.
|
||||
group.SessionJoin(session, request, cancellationToken);
|
||||
return;
|
||||
}
|
||||
@@ -332,8 +332,11 @@ namespace Emby.Server.Implementations.SyncPlay
|
||||
// Group lock required as Group is not thread-safe.
|
||||
lock (group)
|
||||
{
|
||||
// Make sure that session still belongs to this group.
|
||||
if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId))
|
||||
// Make sure that session still belongs to this group. The lookup can fail
|
||||
// outright when the session left while this request was waiting on the group
|
||||
// lock, which is exactly the case this re-check exists to catch.
|
||||
if (!_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup)
|
||||
|| !checkGroup.GroupId.Equals(group.GroupId))
|
||||
{
|
||||
// Drop request.
|
||||
return;
|
||||
@@ -400,7 +403,7 @@ namespace Emby.Server.Implementations.SyncPlay
|
||||
// Update sessions counter.
|
||||
var newSessionsCounter = _activeUsers.AddOrUpdate(
|
||||
userId,
|
||||
1,
|
||||
toAdd,
|
||||
(_, sessionsCounter) => sessionsCounter + toAdd);
|
||||
|
||||
// Should never happen.
|
||||
|
||||
@@ -11,7 +11,6 @@ using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Events;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
@@ -34,6 +33,9 @@ namespace Emby.Server.Implementations.Updates
|
||||
public class InstallationManager : IInstallationManager
|
||||
{
|
||||
private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
|
||||
// Budget for the whole package download. The response headers are already bounded by the
|
||||
// HttpClient timeout; this covers reading the package body, which can be large and slow.
|
||||
private static readonly TimeSpan PackageDownloadTimeout = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// The logger.
|
||||
@@ -82,8 +84,8 @@ namespace Emby.Server.Implementations.Updates
|
||||
IServerConfigurationManager config,
|
||||
IPluginManager pluginManager)
|
||||
{
|
||||
_currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>();
|
||||
_completedInstallationsInternal = new ConcurrentBag<InstallationInfo>();
|
||||
_currentInstallations = [];
|
||||
_completedInstallationsInternal = [];
|
||||
|
||||
_logger = logger;
|
||||
_applicationHost = appHost;
|
||||
@@ -341,8 +343,9 @@ namespace Emby.Server.Implementations.Updates
|
||||
|
||||
_applicationHost.NotifyPendingRestart();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
catch (OperationCanceledException) when (linkedToken.IsCancellationRequested)
|
||||
{
|
||||
// Only an actually cancelled token is a cancellation.
|
||||
lock (_currentInstallationsLock)
|
||||
{
|
||||
_currentInstallations.Remove(tuple);
|
||||
@@ -356,7 +359,7 @@ namespace Emby.Server.Implementations.Updates
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Package installation failed");
|
||||
_logger.LogError(ex, "Package installation failed: {Name} {Version}", package.Name, package.Version);
|
||||
|
||||
lock (_currentInstallationsLock)
|
||||
{
|
||||
@@ -546,12 +549,36 @@ namespace Emby.Server.Implementations.Updates
|
||||
throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory.");
|
||||
}
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
// ResponseHeadersRead keeps the body out of the HttpClient timeout, which otherwise covers
|
||||
// the whole download; the package gets the longer budget below instead.
|
||||
using var downloadTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
downloadTokenSource.CancelAfter(PackageDownloadTimeout);
|
||||
var downloadToken = downloadTokenSource.Token;
|
||||
|
||||
var buffer = new MemoryStream();
|
||||
await using (buffer.ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.GetAsync(new Uri(package.SourceUrl), HttpCompletionOption.ResponseHeadersRead, downloadToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// The package is read twice, for the checksum and for the extraction, so it has
|
||||
// to be buffered: the response stream is not seekable.
|
||||
await response.Content.CopyToAsync(buffer, downloadToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Either our budget above or the HttpClient timeout ran out.
|
||||
throw new TimeoutException(
|
||||
$"Downloading the package {package.Name} {package.Version} from {package.SourceUrl} timed out.",
|
||||
ex);
|
||||
}
|
||||
|
||||
buffer.Position = 0;
|
||||
Stream stream = buffer;
|
||||
|
||||
// CA5351: Do Not Use Broken Cryptographic Algorithms
|
||||
#pragma warning disable CA5351
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -126,6 +126,12 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = null;
|
||||
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
@@ -193,31 +199,7 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
|
||||
var result = _libraryManager.GetArtists(query);
|
||||
|
||||
var dtos = result.Items.Select(i =>
|
||||
{
|
||||
var (baseItem, itemCounts) = i;
|
||||
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
|
||||
|
||||
if (includeItemTypes.Length != 0)
|
||||
{
|
||||
dto.ChildCount = itemCounts.ItemCount;
|
||||
dto.ProgramCount = itemCounts.ProgramCount;
|
||||
dto.SeriesCount = itemCounts.SeriesCount;
|
||||
dto.EpisodeCount = itemCounts.EpisodeCount;
|
||||
dto.MovieCount = itemCounts.MovieCount;
|
||||
dto.TrailerCount = itemCounts.TrailerCount;
|
||||
dto.AlbumCount = itemCounts.AlbumCount;
|
||||
dto.SongCount = itemCounts.SongCount;
|
||||
dto.ArtistCount = itemCounts.ArtistCount;
|
||||
}
|
||||
|
||||
return dto;
|
||||
});
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
query.StartIndex,
|
||||
result.TotalRecordCount,
|
||||
dtos.ToArray());
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -298,6 +280,12 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = null;
|
||||
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
@@ -365,31 +353,7 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
|
||||
var result = _libraryManager.GetAlbumArtists(query);
|
||||
|
||||
var dtos = result.Items.Select(i =>
|
||||
{
|
||||
var (baseItem, itemCounts) = i;
|
||||
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
|
||||
|
||||
if (includeItemTypes.Length != 0)
|
||||
{
|
||||
dto.ChildCount = itemCounts.ItemCount;
|
||||
dto.ProgramCount = itemCounts.ProgramCount;
|
||||
dto.SeriesCount = itemCounts.SeriesCount;
|
||||
dto.EpisodeCount = itemCounts.EpisodeCount;
|
||||
dto.MovieCount = itemCounts.MovieCount;
|
||||
dto.TrailerCount = itemCounts.TrailerCount;
|
||||
dto.AlbumCount = itemCounts.AlbumCount;
|
||||
dto.SongCount = itemCounts.SongCount;
|
||||
dto.ArtistCount = itemCounts.ArtistCount;
|
||||
}
|
||||
|
||||
return dto;
|
||||
});
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
query.StartIndex,
|
||||
result.TotalRecordCount,
|
||||
dtos.ToArray());
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -50,10 +50,10 @@ public class DevicesController : BaseJellyfinApiController
|
||||
/// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<DeviceInfoDto>> GetDevices([FromQuery] Guid? userId)
|
||||
public async Task<ActionResult<QueryResult<DeviceInfoDto>>> GetDevices([FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
return _deviceManager.GetDevicesForUser(userId);
|
||||
return await _deviceManager.GetDevicesForUser(userId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -66,9 +66,9 @@ public class DevicesController : BaseJellyfinApiController
|
||||
[HttpGet("Info")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<DeviceInfoDto> GetDeviceInfo([FromQuery, Required] string id)
|
||||
public async Task<ActionResult<DeviceInfoDto>> GetDeviceInfo([FromQuery, Required] string id)
|
||||
{
|
||||
var deviceInfo = _deviceManager.GetDevice(id);
|
||||
var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false);
|
||||
if (deviceInfo is null)
|
||||
{
|
||||
return NotFound();
|
||||
@@ -87,9 +87,9 @@ public class DevicesController : BaseJellyfinApiController
|
||||
[HttpGet("Options")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<DeviceOptionsDto> GetDeviceOptions([FromQuery, Required] string id)
|
||||
public async Task<ActionResult<DeviceOptionsDto>> GetDeviceOptions([FromQuery, Required] string id)
|
||||
{
|
||||
var deviceInfo = _deviceManager.GetDeviceOptions(id);
|
||||
var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false);
|
||||
if (deviceInfo is null)
|
||||
{
|
||||
return NotFound();
|
||||
@@ -127,7 +127,12 @@ public class DevicesController : BaseJellyfinApiController
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> DeleteDevice([FromQuery] string[] id)
|
||||
{
|
||||
var devices = id.Select(_deviceManager.GetDevice).ToArray();
|
||||
var devices = new List<DeviceInfoDto?>(id.Length);
|
||||
foreach (var deviceId in id)
|
||||
{
|
||||
devices.Add(await _deviceManager.GetDevice(deviceId).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
if (devices.Any(f => f is null))
|
||||
{
|
||||
return BadRequest();
|
||||
@@ -135,7 +140,7 @@ public class DevicesController : BaseJellyfinApiController
|
||||
|
||||
foreach (var device in devices)
|
||||
{
|
||||
var sessions = _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id });
|
||||
var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id }).ConfigureAwait(false);
|
||||
|
||||
foreach (var session in sessions.Items)
|
||||
{
|
||||
|
||||
@@ -20,7 +20,6 @@ using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Streaming;
|
||||
using MediaBrowser.MediaEncoding.Encoder;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Entities;
|
||||
@@ -30,6 +29,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
@@ -45,6 +45,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
private const EncoderPreset DefaultEventEncoderPreset = EncoderPreset.superfast;
|
||||
private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
|
||||
|
||||
private static readonly string _podIdentity = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
|
||||
|
||||
private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0);
|
||||
private readonly Version _minFFmpegX265BframeInFmp4 = new Version(7, 0, 1);
|
||||
private readonly Version _minFFmpegHlsSegmentOptions = new Version(5, 0);
|
||||
@@ -61,6 +63,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
|
||||
private readonly DynamicHlsHelper _dynamicHlsHelper;
|
||||
private readonly EncodingOptions _encodingOptions;
|
||||
private readonly ITranscodeSessionStore _transcodeSessionStore;
|
||||
private readonly TranscodeStoreOptions _transcodeStoreOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
|
||||
@@ -76,6 +80,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
/// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
|
||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||
/// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param>
|
||||
/// <param name="transcodeSessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface used to register and renew HLS transcoding session leases in the durable store.</param>
|
||||
/// <param name="transcodeStoreOptions">The <see cref="TranscodeStoreOptions"/> holding the session lease duration.</param>
|
||||
public DynamicHlsController(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
@@ -87,7 +93,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
ILogger<DynamicHlsController> logger,
|
||||
DynamicHlsHelper dynamicHlsHelper,
|
||||
EncodingHelper encodingHelper,
|
||||
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
|
||||
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator,
|
||||
ITranscodeSessionStore transcodeSessionStore,
|
||||
IOptions<TranscodeStoreOptions> transcodeStoreOptions)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
@@ -100,6 +108,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
_dynamicHlsHelper = dynamicHlsHelper;
|
||||
_encodingHelper = encodingHelper;
|
||||
_dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
|
||||
_transcodeSessionStore = transcodeSessionStore;
|
||||
_transcodeStoreOptions = transcodeStoreOptions.Value;
|
||||
|
||||
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
|
||||
}
|
||||
@@ -307,15 +317,24 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
// If the playlist doesn't already exist, startup ffmpeg
|
||||
try
|
||||
{
|
||||
var isHaMode = await IsHaTakeoverAsync(playSessionId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
job = await _transcodeManager.StartFfMpeg(
|
||||
state,
|
||||
playlistPath,
|
||||
GetCommandLineArguments(playlistPath, state, true, 0),
|
||||
GetCommandLineArguments(playlistPath, state, true, 0, isHaMode),
|
||||
Request.HttpContext.User.GetUserId(),
|
||||
TranscodingJobType,
|
||||
cancellationTokenSource)
|
||||
.ConfigureAwait(false);
|
||||
job.IsLiveOutput = true;
|
||||
await RegisterTranscodeSessionAsync(
|
||||
playSessionId ?? string.Empty,
|
||||
mediaSourceId ?? string.Empty,
|
||||
playlistPath,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
StartLeaseRenewal(playSessionId ?? string.Empty, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -1512,14 +1531,23 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
|
||||
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
|
||||
|
||||
var isHaMode = await IsHaTakeoverAsync(streamingRequest.PlaySessionId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
state.WaitForPath = segmentPath;
|
||||
job = await _transcodeManager.StartFfMpeg(
|
||||
state,
|
||||
playlistPath,
|
||||
GetCommandLineArguments(playlistPath, state, false, segmentId),
|
||||
GetCommandLineArguments(playlistPath, state, false, segmentId, isHaMode),
|
||||
Request.HttpContext.User.GetUserId(),
|
||||
TranscodingJobType,
|
||||
cancellationTokenSource).ConfigureAwait(false);
|
||||
await RegisterTranscodeSessionAsync(
|
||||
streamingRequest.PlaySessionId ?? string.Empty,
|
||||
streamingRequest.MediaSourceId ?? string.Empty,
|
||||
playlistPath,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
StartLeaseRenewal(streamingRequest.PlaySessionId ?? string.Empty, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -1544,6 +1572,31 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the segment length ffmpeg is told to use. HA mode shortens segments so a takeover pod
|
||||
/// has to re-encode less; the configured value is user-editable so it is clamped.
|
||||
/// </summary>
|
||||
/// <param name="isHaMode">Whether the session is being resumed from the durable store.</param>
|
||||
/// <param name="requestedSegmentLength">The segment length requested by the streaming pipeline.</param>
|
||||
/// <param name="encodingOptions">The encoding options holding the recovery segment length.</param>
|
||||
/// <returns>The segment length in seconds.</returns>
|
||||
internal static int GetEffectiveSegmentLength(bool isHaMode, int requestedSegmentLength, EncodingOptions encodingOptions)
|
||||
=> isHaMode
|
||||
? Math.Clamp(encodingOptions.RecoverySegmentLengthSeconds, 1, 6)
|
||||
: requestedSegmentLength;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ffmpeg <c>hls_list_size</c>. HA mode keeps a bounded rolling buffer; 0 (unbounded)
|
||||
/// otherwise.
|
||||
/// </summary>
|
||||
/// <param name="isHaMode">Whether the session is being resumed from the durable store.</param>
|
||||
/// <param name="encodingOptions">The encoding options holding the recovery buffer count.</param>
|
||||
/// <returns>The number of segments to keep in the playlist, or 0 for all of them.</returns>
|
||||
internal static int GetHlsListSize(bool isHaMode, EncodingOptions encodingOptions)
|
||||
=> isHaMode
|
||||
? Math.Clamp(encodingOptions.RecoverySegmentBufferCount, 2, 10)
|
||||
: 0;
|
||||
|
||||
internal static async Task WaitForActiveTranscodingRequests(TranscodingJob? job, CancellationToken cancellationToken)
|
||||
{
|
||||
while (job?.ActiveRequestCount > 0)
|
||||
@@ -1555,6 +1608,116 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
private static double[] GetSegmentLengths(StreamState state)
|
||||
=> GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the play session is already present in the durable store, which means
|
||||
/// another pod owned it and this request is resuming it after a failover.
|
||||
/// </summary>
|
||||
private async Task<bool> IsHaTakeoverAsync(string? playSessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(playSessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _transcodeSessionStore.TryGetAsync(playSessionId, cancellationToken).ConfigureAwait(false) is not null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to check HA mode for session {PlaySessionId}.", playSessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the durable record for an HLS output owned by this instance. The manifest and segment
|
||||
/// paths have to be real, otherwise <c>DeleteTranscodeFileTask</c> cannot tell which files
|
||||
/// belong to a live session.
|
||||
/// </summary>
|
||||
/// <param name="playSessionId">The play session identifier.</param>
|
||||
/// <param name="mediaSourceId">The media source identifier.</param>
|
||||
/// <param name="playlistPath">The absolute path of the HLS playlist this instance is writing.</param>
|
||||
/// <param name="leaseDuration">The initial lease duration.</param>
|
||||
/// <returns>The session record to store.</returns>
|
||||
internal static TranscodeSession CreateSessionRecord(string playSessionId, string mediaSourceId, string playlistPath, TimeSpan leaseDuration)
|
||||
=> TranscodeSession.CreateForPlaylist(playSessionId, mediaSourceId, _podIdentity, playlistPath, leaseDuration);
|
||||
|
||||
private async Task RegisterTranscodeSessionAsync(string playSessionId, string mediaSourceId, string playlistPath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var session = CreateSessionRecord(
|
||||
playSessionId,
|
||||
mediaSourceId,
|
||||
playlistPath,
|
||||
TimeSpan.FromSeconds(_transcodeStoreOptions.LeaseDurationSeconds));
|
||||
await _transcodeSessionStore.SetAsync(session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to register HLS session {PlaySessionId} in durable store.", playSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private void StartLeaseRenewal(string playSessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var renewalInterval = TimeSpan.FromSeconds(Math.Max(1, _transcodeStoreOptions.LeaseDurationSeconds / 3));
|
||||
|
||||
_ = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
var ownsLease = true;
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(renewalInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!await _transcodeSessionStore.RenewLeaseAsync(playSessionId, _podIdentity, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Another pod owns the session now; its record must not be touched.
|
||||
ownsLease = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to renew lease for HLS session {PlaySessionId}.", playSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Lease renewal loop for HLS session {PlaySessionId} encountered an unexpected error.", playSessionId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ownsLease)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _transcodeSessionStore.DeleteAsync(playSessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete HLS session {PlaySessionId} from durable store.", playSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength)
|
||||
{
|
||||
var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
|
||||
@@ -1576,7 +1739,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
return segments;
|
||||
}
|
||||
|
||||
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
|
||||
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber, bool isHaMode = false)
|
||||
{
|
||||
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
||||
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
|
||||
@@ -1589,10 +1752,13 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
||||
|
||||
var effectiveSegmentLength = GetEffectiveSegmentLength(isHaMode, state.SegmentLength, _encodingOptions);
|
||||
var hlsListSize = GetHlsListSize(isHaMode, _encodingOptions);
|
||||
|
||||
var segmentFormat = string.Empty;
|
||||
var segmentContainer = outputExtension.TrimStart('.');
|
||||
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
|
||||
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
|
||||
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size {hlsListSize}";
|
||||
|
||||
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -1645,16 +1811,16 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
|
||||
threads,
|
||||
mapArgs,
|
||||
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer),
|
||||
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer, effectiveSegmentLength),
|
||||
GetAudioArguments(state),
|
||||
maxMuxingQueueSize,
|
||||
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
|
||||
effectiveSegmentLength.ToString(CultureInfo.InvariantCulture),
|
||||
segmentFormat,
|
||||
startNumber.ToString(CultureInfo.InvariantCulture),
|
||||
baseUrlParam,
|
||||
EncodingUtils.NormalizePath(outputTsArg),
|
||||
outputTsArg.EscapeProcessArgument(),
|
||||
hlsArguments,
|
||||
EncodingUtils.NormalizePath(outputPath)).Trim();
|
||||
outputPath.EscapeProcessArgument()).Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1785,8 +1951,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
/// <param name="startNumber">The first number in the hls sequence.</param>
|
||||
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The effective segment length in seconds (overrides <see cref="StreamState.SegmentLength"/> when HA mode is active).</param>
|
||||
/// <returns>The command line arguments for video transcoding.</returns>
|
||||
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer)
|
||||
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer, int? segmentLength = null)
|
||||
{
|
||||
if (state.VideoStream is null)
|
||||
{
|
||||
@@ -1861,7 +2028,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
|
||||
|
||||
// Set the key frame params for video encoding to match the hls segment time.
|
||||
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber);
|
||||
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, segmentLength ?? state.SegmentLength, isEventPlaylist, startNumber);
|
||||
|
||||
// Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
|
||||
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
|
||||
|
||||
@@ -97,6 +97,12 @@ public class GenresController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
@@ -143,8 +149,7 @@ public class GenresController : BaseJellyfinApiController
|
||||
result = _libraryManager.GetGenres(query);
|
||||
}
|
||||
|
||||
var shouldIncludeItemTypes = includeItemTypes.Length != 0;
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -13,6 +13,7 @@ using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -263,7 +264,7 @@ public class ItemLookupController : BaseJellyfinApiController
|
||||
searchResult.ProviderIds);
|
||||
|
||||
// Since the refresh process won't erase provider Ids, we need to set this explicitly now.
|
||||
item.ProviderIds = searchResult.ProviderIds;
|
||||
item.SetProviderIds(searchResult.ProviderIds);
|
||||
await _providerManager.RefreshFullItem(
|
||||
item,
|
||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
|
||||
@@ -428,15 +428,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
|
||||
if (request.ProviderIds is not null)
|
||||
{
|
||||
foreach (var pair in request.ProviderIds.ToList())
|
||||
{
|
||||
if (string.IsNullOrEmpty(pair.Value))
|
||||
{
|
||||
request.ProviderIds.Remove(pair.Key);
|
||||
}
|
||||
}
|
||||
|
||||
item.ProviderIds = request.ProviderIds;
|
||||
item.SetProviderIds(request.ProviderIds);
|
||||
}
|
||||
|
||||
if (item is Video video)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user