Compare commits
591 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c59e6afcb | |||
| a7919b9bac | |||
| 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,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"dotnet-ef": {
|
"dotnet-ef": {
|
||||||
"version": "10.0.10",
|
"version": "10.0.11",
|
||||||
"commands": [
|
"commands": [
|
||||||
"dotnet-ef"
|
"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)
|
- [Smith00101010](https://github.com/Smith00101010)
|
||||||
- [sorinyo2004](https://github.com/sorinyo2004)
|
- [sorinyo2004](https://github.com/sorinyo2004)
|
||||||
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
|
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
|
||||||
|
- [st7105](https://github.com/st7105)
|
||||||
- [sparky8251](https://github.com/sparky8251)
|
- [sparky8251](https://github.com/sparky8251)
|
||||||
- [spookbits](https://github.com/spookbits)
|
- [spookbits](https://github.com/spookbits)
|
||||||
- [ssenart](https://github.com/ssenart)
|
- [ssenart](https://github.com/ssenart)
|
||||||
@@ -237,6 +238,9 @@
|
|||||||
- [elio42](https://github.com/elio42)
|
- [elio42](https://github.com/elio42)
|
||||||
- [rwebster85](https://github.com/rwebster85)
|
- [rwebster85](https://github.com/rwebster85)
|
||||||
- [Florin-Popescu](https://github.com/Florin-Popescu)
|
- [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
|
# Emby Contributors
|
||||||
|
|
||||||
@@ -247,7 +251,7 @@
|
|||||||
- [Mark2xv](https://github.com/Mark2xv)
|
- [Mark2xv](https://github.com/Mark2xv)
|
||||||
- [ScottRapsey](https://github.com/ScottRapsey)
|
- [ScottRapsey](https://github.com/ScottRapsey)
|
||||||
- [skynet600](https://github.com/skynet600)
|
- [skynet600](https://github.com/skynet600)
|
||||||
- [Cheesegeezer](https://githum.com/Cheesegeezer)
|
- [Cheesegeezer](https://github.com/Cheesegeezer)
|
||||||
- [Radeon](https://github.com/radeonorama)
|
- [Radeon](https://github.com/radeonorama)
|
||||||
- [gcw07](https://github.com/gcw07)
|
- [gcw07](https://github.com/gcw07)
|
||||||
- [SivaramAdhiappan](https://github.com/shivaram1190)
|
- [SivaramAdhiappan](https://github.com/shivaram1190)
|
||||||
|
|||||||
+32
-25
@@ -8,8 +8,9 @@
|
|||||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||||
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
|
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
|
||||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
<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="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.SkiaSharp" Version="1.4.0-pre.1" />
|
||||||
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
|
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
|
||||||
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
|
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
|
||||||
@@ -26,33 +27,36 @@
|
|||||||
<PackageVersion Include="libse" Version="4.0.12" />
|
<PackageVersion Include="libse" Version="4.0.12" />
|
||||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.9.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" />
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
<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="MimeTypes" Version="2.5.2" />
|
||||||
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
||||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||||
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
<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="PDFtoImage" Version="5.2.1" />
|
||||||
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||||
@@ -74,15 +78,18 @@
|
|||||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
||||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
||||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
<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="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||||
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" 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="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="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.runner.visualstudio" Version="3.1.5" />
|
||||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||||
<PackageVersion Include="Xunit.v3.Priority" Version="1.1.18" />
|
<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 =
|
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>.+?)((\s*\[[^\]]+\]\s*)+)(\.[^\s]+)?$",
|
||||||
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
|
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
|
||||||
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
|
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
|
||||||
|
|||||||
@@ -44,7 +44,14 @@ namespace Emby.Naming.ExternalFiles
|
|||||||
}
|
}
|
||||||
|
|
||||||
var extension = Path.GetExtension(path.AsSpan());
|
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.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
&& !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.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
|
if (nextIndex >= name.Length
|
||||||
|| !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal))
|
|| !"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;
|
result.EndingEpisodeNumber = num;
|
||||||
}
|
}
|
||||||
@@ -226,7 +228,7 @@ namespace Emby.Naming.TV
|
|||||||
info.SeriesName = result.SeriesName;
|
info.SeriesName = result.SeriesName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue)
|
if (!info.EndingEpisodeNumber.HasValue && result.EndingEpisodeNumber >= info.EpisodeNumber)
|
||||||
{
|
{
|
||||||
info.EndingEpisodeNumber = result.EndingEpisodeNumber;
|
info.EndingEpisodeNumber = result.EndingEpisodeNumber;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ namespace Emby.Server.Implementations.Collections
|
|||||||
SaveLocalMetadata = true
|
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);
|
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();
|
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
|
// Batch-fetch child counts for all folders to avoid N+1 queries
|
||||||
Dictionary<Guid, int>? childCountBatch = null;
|
Dictionary<Guid, int>? childCountBatch = null;
|
||||||
if (options.ContainsField(ItemFields.ChildCount))
|
if (options.ContainsField(ItemFields.ChildCount))
|
||||||
@@ -192,7 +199,7 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
|
var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
|
||||||
if (folderIds.Count > 0)
|
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))
|
if (options.ContainsField(ItemFields.ItemCounts))
|
||||||
{
|
{
|
||||||
SetItemByNameInfo(dto, user);
|
SetItemByNameInfo(dto, user, itemCountsBatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
returnItems[index] = dto;
|
returnItems[index] = dto;
|
||||||
@@ -518,14 +525,36 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
return 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))
|
if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds))
|
||||||
{
|
{
|
||||||
return;
|
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.AlbumCount = counts.AlbumCount;
|
||||||
dto.ArtistCount = counts.ArtistCount;
|
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
|
// 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)
|
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;
|
var folderChildCount = folder.LinkedChildren.Length;
|
||||||
// The default is an empty array, so we can't reliably use the count when it's empty
|
// The default is an empty array, so we can't reliably use the count when it's empty
|
||||||
if (folderChildCount > 0)
|
if (folderChildCount > 0)
|
||||||
@@ -696,7 +729,8 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
return count;
|
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);
|
return folder.GetChildCount(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Ignore" />
|
<PackageReference Include="Ignore" />
|
||||||
|
<PackageReference Include="StackExchange.Redis" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ namespace Emby.Server.Implementations.EntryPoints;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
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 ILibraryManager _libraryManager;
|
||||||
private readonly IServerConfigurationManager _configurationManager;
|
private readonly IServerConfigurationManager _configurationManager;
|
||||||
private readonly IProviderManager _providerManager;
|
private readonly IProviderManager _providerManager;
|
||||||
@@ -35,11 +40,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
|||||||
private readonly ILogger<LibraryChangedNotifier> _logger;
|
private readonly ILogger<LibraryChangedNotifier> _logger;
|
||||||
|
|
||||||
private readonly Lock _libraryChangedSyncLock = new();
|
private readonly Lock _libraryChangedSyncLock = new();
|
||||||
private readonly List<Folder> _foldersAddedTo = new();
|
private readonly Dictionary<Guid, Folder> _foldersAddedTo = [];
|
||||||
private readonly List<Folder> _foldersRemovedFrom = new();
|
private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = [];
|
||||||
private readonly List<BaseItem> _itemsAdded = new();
|
private readonly Dictionary<Guid, BaseItem> _itemsAdded = [];
|
||||||
private readonly List<BaseItem> _itemsRemoved = new();
|
private readonly Dictionary<Guid, BaseItem> _itemsRemoved = [];
|
||||||
private readonly List<BaseItem> _itemsUpdated = new();
|
private readonly Dictionary<Guid, BaseItem> _itemsUpdated = [];
|
||||||
private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new();
|
private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new();
|
||||||
|
|
||||||
private Timer? _libraryUpdateTimer;
|
private Timer? _libraryUpdateTimer;
|
||||||
@@ -173,7 +178,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
|||||||
private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e)
|
private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e)
|
||||||
=> OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom);
|
=> 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))
|
if (!FilterItem(item))
|
||||||
{
|
{
|
||||||
@@ -182,23 +187,28 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
|||||||
|
|
||||||
lock (_libraryChangedSyncLock)
|
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)
|
if (_libraryUpdateTimer is null)
|
||||||
{
|
{
|
||||||
|
var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
|
||||||
_libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan);
|
_libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
_libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (foldersList is not null && parent is Folder folder)
|
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;
|
List<BaseItem> itemsRemoved;
|
||||||
lock (_libraryChangedSyncLock)
|
lock (_libraryChangedSyncLock)
|
||||||
{
|
{
|
||||||
// Remove dupes in case some were saved multiple times
|
foldersAddedTo = _foldersAddedTo.Values.ToList();
|
||||||
foldersAddedTo = _foldersAddedTo
|
foldersRemovedFrom = _foldersRemovedFrom.Values.ToList();
|
||||||
.DistinctBy(x => x.Id)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
foldersRemovedFrom = _foldersRemovedFrom
|
|
||||||
.DistinctBy(x => x.Id)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
itemsUpdated = _itemsUpdated
|
itemsUpdated = _itemsUpdated
|
||||||
.Where(i => !_itemsAdded.Contains(i))
|
.Where(e => !_itemsAdded.ContainsKey(e.Key))
|
||||||
.DistinctBy(x => x.Id)
|
.Select(e => e.Value)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
itemsAdded = _itemsAdded.ToList();
|
itemsAdded = _itemsAdded.Values.ToList();
|
||||||
itemsRemoved = _itemsRemoved.ToList();
|
itemsRemoved = _itemsRemoved.Values.ToList();
|
||||||
|
|
||||||
if (_libraryUpdateTimer is not null)
|
if (_libraryUpdateTimer is not null)
|
||||||
{
|
{
|
||||||
@@ -241,6 +245,15 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
|||||||
_foldersRemovedFrom.Clear();
|
_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);
|
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
|
public sealed class UserDataChangeNotifier : IHostedService, IDisposable
|
||||||
{
|
{
|
||||||
private const int UpdateDuration = 500;
|
private const int UpdateDuration = 500;
|
||||||
|
internal const int MaxBatchSize = 2000;
|
||||||
|
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
private readonly IUserDataManager _userDataManager;
|
private readonly IUserDataManager _userDataManager;
|
||||||
private readonly IUserManager _userManager;
|
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 readonly Lock _syncLock = new();
|
||||||
|
|
||||||
private Timer? _updateTimer;
|
private Timer? _updateTimer;
|
||||||
|
private int _changedItemCount;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class.
|
/// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class.
|
||||||
@@ -69,50 +71,64 @@ namespace Emby.Server.Implementations.EntryPoints
|
|||||||
|
|
||||||
lock (_syncLock)
|
lock (_syncLock)
|
||||||
{
|
{
|
||||||
if (_updateTimer is null)
|
// 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
|
||||||
_updateTimer = new Timer(
|
// holding every item it touched alive until the stream stops.
|
||||||
UpdateTimerCallback,
|
_updateTimer ??= new Timer(
|
||||||
null,
|
UpdateTimerCallback,
|
||||||
UpdateDuration,
|
null,
|
||||||
Timeout.Infinite);
|
UpdateDuration,
|
||||||
}
|
Timeout.Infinite);
|
||||||
else
|
|
||||||
{
|
|
||||||
_updateTimer.Change(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;
|
_changedItems[e.UserId] = keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
keys.Add(e.Item);
|
|
||||||
|
|
||||||
var baseItem = e.Item;
|
var baseItem = e.Item;
|
||||||
|
|
||||||
// Go up one level for indicators
|
// Go up one level for indicators
|
||||||
if (baseItem is not null)
|
if (baseItem is not null)
|
||||||
{
|
{
|
||||||
|
Track(keys, baseItem);
|
||||||
|
|
||||||
var parent = baseItem.GetOwner() ?? baseItem.GetParent();
|
var parent = baseItem.GetOwner() ?? baseItem.GetParent();
|
||||||
|
|
||||||
if (parent is not null)
|
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)
|
private async void UpdateTimerCallback(object? state)
|
||||||
{
|
{
|
||||||
List<KeyValuePair<Guid, List<BaseItem>>> changes;
|
List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes;
|
||||||
lock (_syncLock)
|
lock (_syncLock)
|
||||||
{
|
{
|
||||||
// Remove dupes in case some were saved multiple times
|
|
||||||
changes = _changedItems.ToList();
|
changes = _changedItems.ToList();
|
||||||
_changedItems.Clear();
|
_changedItems.Clear();
|
||||||
|
_changedItemCount = 0;
|
||||||
|
|
||||||
if (_updateTimer is not null)
|
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)
|
foreach (var (userId, changedItems) in changes)
|
||||||
{
|
{
|
||||||
await _sessionManager.SendMessageToUserSessions(
|
await _sessionManager.SendMessageToUserSessions(
|
||||||
[userId],
|
[userId],
|
||||||
SessionMessageType.UserDataChanged,
|
SessionMessageType.UserDataChanged,
|
||||||
() => GetUserDataChangeInfo(userId, changedItems),
|
() => GetUserDataChangeInfo(userId, changedItems.Values),
|
||||||
default).ConfigureAwait(false);
|
default).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems)
|
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems)
|
||||||
{
|
{
|
||||||
var user = _userManager.GetUserById(userId)
|
var user = _userManager.GetUserById(userId)
|
||||||
?? throw new ArgumentException("Invalid user ID", nameof(userId));
|
?? throw new ArgumentException("Invalid user ID", nameof(userId));
|
||||||
@@ -140,7 +161,6 @@ namespace Emby.Server.Implementations.EntryPoints
|
|||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
UserDataList = changedItems
|
UserDataList = changedItems
|
||||||
.DistinctBy(x => x.Id)
|
|
||||||
.Select(i =>
|
.Select(i =>
|
||||||
{
|
{
|
||||||
var dto = _userDataManager.GetUserDataDto(i, user);
|
var dto = _userDataManager.GetUserDataDto(i, user);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using Emby.Server.Implementations.Library;
|
|||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -21,6 +22,7 @@ namespace Emby.Server.Implementations.IO
|
|||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly IServerConfigurationManager _configurationManager;
|
private readonly IServerConfigurationManager _configurationManager;
|
||||||
private readonly IFileSystem _fileSystem;
|
private readonly IFileSystem _fileSystem;
|
||||||
|
private readonly IDirectoryService _directoryService;
|
||||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -47,6 +49,7 @@ namespace Emby.Server.Implementations.IO
|
|||||||
/// <param name="libraryManager">The library manager.</param>
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
/// <param name="configurationManager">The configuration manager.</param>
|
/// <param name="configurationManager">The configuration manager.</param>
|
||||||
/// <param name="fileSystem">The filesystem.</param>
|
/// <param name="fileSystem">The filesystem.</param>
|
||||||
|
/// <param name="directoryService">The directory service.</param>
|
||||||
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
|
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
|
||||||
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||||
public LibraryMonitor(
|
public LibraryMonitor(
|
||||||
@@ -54,6 +57,7 @@ namespace Emby.Server.Implementations.IO
|
|||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
IServerConfigurationManager configurationManager,
|
IServerConfigurationManager configurationManager,
|
||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
|
IDirectoryService directoryService,
|
||||||
IHostApplicationLifetime appLifetime,
|
IHostApplicationLifetime appLifetime,
|
||||||
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
|
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
|
||||||
{
|
{
|
||||||
@@ -61,6 +65,7 @@ namespace Emby.Server.Implementations.IO
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configurationManager = configurationManager;
|
_configurationManager = configurationManager;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
|
_directoryService = directoryService;
|
||||||
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||||
|
|
||||||
appLifetime.ApplicationStarted.Register(Start);
|
appLifetime.ApplicationStarted.Register(Start);
|
||||||
@@ -363,6 +368,8 @@ namespace Emby.Server.Implementations.IO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_directoryService.Invalidate(path);
|
||||||
|
|
||||||
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
||||||
foreach (var i in _tempIgnoredPaths.Keys)
|
foreach (var i in _tempIgnoredPaths.Keys)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -15,7 +16,6 @@ using Emby.Naming.Common;
|
|||||||
using Emby.Naming.TV;
|
using Emby.Naming.TV;
|
||||||
using Emby.Naming.Video;
|
using Emby.Naming.Video;
|
||||||
using Emby.Server.Implementations.Library.Resolvers;
|
using Emby.Server.Implementations.Library.Resolvers;
|
||||||
using Emby.Server.Implementations.Library.Validators;
|
|
||||||
using Emby.Server.Implementations.Playlists;
|
using Emby.Server.Implementations.Playlists;
|
||||||
using Emby.Server.Implementations.ScheduledTasks.Tasks;
|
using Emby.Server.Implementations.ScheduledTasks.Tasks;
|
||||||
using Emby.Server.Implementations.Sorting;
|
using Emby.Server.Implementations.Sorting;
|
||||||
@@ -35,7 +35,6 @@ using MediaBrowser.Controller.Entities.Movies;
|
|||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.LiveTv;
|
using MediaBrowser.Controller.LiveTv;
|
||||||
using MediaBrowser.Controller.MediaEncoding;
|
|
||||||
using MediaBrowser.Controller.Persistence;
|
using MediaBrowser.Controller.Persistence;
|
||||||
using MediaBrowser.Controller.Playlists;
|
using MediaBrowser.Controller.Playlists;
|
||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
@@ -75,7 +74,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
private readonly Lazy<IProviderManager> _providerManagerFactory;
|
private readonly Lazy<IProviderManager> _providerManagerFactory;
|
||||||
private readonly Lazy<IUserViewManager> _userViewManagerFactory;
|
private readonly Lazy<IUserViewManager> _userViewManagerFactory;
|
||||||
private readonly IServerApplicationHost _appHost;
|
private readonly IServerApplicationHost _appHost;
|
||||||
private readonly IMediaEncoder _mediaEncoder;
|
|
||||||
private readonly IFileSystem _fileSystem;
|
private readonly IFileSystem _fileSystem;
|
||||||
private readonly IItemRepository _itemRepository;
|
private readonly IItemRepository _itemRepository;
|
||||||
private readonly IItemPersistenceService _persistenceService;
|
private readonly IItemPersistenceService _persistenceService;
|
||||||
@@ -88,6 +86,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
private readonly ExtraResolver _extraResolver;
|
private readonly ExtraResolver _extraResolver;
|
||||||
private readonly IPathManager _pathManager;
|
private readonly IPathManager _pathManager;
|
||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
|
private readonly IDirectoryService _directoryService;
|
||||||
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
|
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
|
||||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||||
private readonly IMediaStreamRepository _mediaStreamRepository;
|
private readonly IMediaStreamRepository _mediaStreamRepository;
|
||||||
@@ -122,7 +121,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <param name="fileSystem">The file system.</param>
|
/// <param name="fileSystem">The file system.</param>
|
||||||
/// <param name="providerManagerFactory">The provider manager.</param>
|
/// <param name="providerManagerFactory">The provider manager.</param>
|
||||||
/// <param name="userViewManagerFactory">The user view 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="itemRepository">The item repository.</param>
|
||||||
/// <param name="persistenceService">The item persistence service.</param>
|
/// <param name="persistenceService">The item persistence service.</param>
|
||||||
/// <param name="nextUpService">The next up service.</param>
|
/// <param name="nextUpService">The next up service.</param>
|
||||||
@@ -148,7 +146,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
Lazy<IProviderManager> providerManagerFactory,
|
Lazy<IProviderManager> providerManagerFactory,
|
||||||
Lazy<IUserViewManager> userViewManagerFactory,
|
Lazy<IUserViewManager> userViewManagerFactory,
|
||||||
IMediaEncoder mediaEncoder,
|
|
||||||
IItemRepository itemRepository,
|
IItemRepository itemRepository,
|
||||||
IItemPersistenceService persistenceService,
|
IItemPersistenceService persistenceService,
|
||||||
INextUpService nextUpService,
|
INextUpService nextUpService,
|
||||||
@@ -174,7 +171,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
_providerManagerFactory = providerManagerFactory;
|
_providerManagerFactory = providerManagerFactory;
|
||||||
_userViewManagerFactory = userViewManagerFactory;
|
_userViewManagerFactory = userViewManagerFactory;
|
||||||
_mediaEncoder = mediaEncoder;
|
|
||||||
_itemRepository = itemRepository;
|
_itemRepository = itemRepository;
|
||||||
_persistenceService = persistenceService;
|
_persistenceService = persistenceService;
|
||||||
_nextUpService = nextUpService;
|
_nextUpService = nextUpService;
|
||||||
@@ -189,6 +185,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
_pathManager = pathManager;
|
_pathManager = pathManager;
|
||||||
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||||
_localization = localization;
|
_localization = localization;
|
||||||
|
_directoryService = directoryService;
|
||||||
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
|
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
|
||||||
|
|
||||||
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
|
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
|
||||||
@@ -1209,6 +1206,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid GetPersonId(string name)
|
||||||
|
{
|
||||||
|
return GetItemByNameId<Person>(Person.GetPath(name));
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Person? GetPerson(string name)
|
public Person? GetPerson(string name)
|
||||||
{
|
{
|
||||||
@@ -1222,6 +1225,33 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return null;
|
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>
|
/// <summary>
|
||||||
/// Gets the studio.
|
/// Gets the studio.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1354,15 +1384,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return GetNewItemIdInternal(path, typeof(T), forceCaseInsensitiveId);
|
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>
|
/// <summary>
|
||||||
/// Reloads the root media folder.
|
/// Reloads the root media folder.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1489,6 +1510,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var numComplete = 0;
|
var numComplete = 0;
|
||||||
var numTasks = tasks.Count;
|
var numTasks = tasks.Count;
|
||||||
|
|
||||||
|
_logger.LogInformation("Running {TaskCount} post-scan task(s)", numTasks);
|
||||||
|
|
||||||
|
var phaseStart = Stopwatch.GetTimestamp();
|
||||||
|
|
||||||
foreach (var task in tasks)
|
foreach (var task in tasks)
|
||||||
{
|
{
|
||||||
// Prevent access to modified closure
|
// Prevent access to modified closure
|
||||||
@@ -1506,20 +1531,45 @@ namespace Emby.Server.Implementations.Library
|
|||||||
progress.Report(innerPercent);
|
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
|
try
|
||||||
{
|
{
|
||||||
await task.Run(innerProgress, cancellationToken).ConfigureAwait(false);
|
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)
|
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;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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++;
|
numComplete++;
|
||||||
@@ -1528,6 +1578,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
progress.Report(percent * 100);
|
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();
|
_persistenceService.UpdateInheritedValues();
|
||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
@@ -1745,9 +1801,21 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query);
|
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/>
|
/// <inheritdoc/>
|
||||||
@@ -1914,14 +1982,14 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Optimize by querying against top level views
|
// 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();
|
||||||
query.AncestorIds = [];
|
if (topParentIds.Length == 0)
|
||||||
|
|
||||||
// Prevent searching in all libraries due to empty filter
|
|
||||||
if (query.TopParentIds.Length == 0)
|
|
||||||
{
|
{
|
||||||
query.TopParentIds = [Guid.NewGuid()];
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query.TopParentIds = topParentIds;
|
||||||
|
query.AncestorIds = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query)
|
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))
|
if (parents.All(i => i is ICollectionFolder || i is UserView))
|
||||||
{
|
{
|
||||||
// Optimize by querying against top level views
|
// 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 (topParentIds.Length > 0)
|
||||||
if (query.TopParentIds.Length == 0)
|
|
||||||
{
|
{
|
||||||
query.TopParentIds = [Guid.NewGuid()];
|
query.TopParentIds = topParentIds;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SetAncestorIds(query, parents);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (parents.Count == 1 && parents.First() is Folder folder
|
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
|
// Playlists and BoxSets store their contents in LinkedChildren and never
|
||||||
// populate AncestorIds for those items, so a recursive AncestorIds query
|
// populate AncestorIds for those items, so a recursive AncestorIds query
|
||||||
// would return zero rows. Resolve to the linked child IDs up front and
|
// would return zero rows. Filter by the descendant set instead, which follows
|
||||||
// route through the existing indexed ItemIds filter.
|
// the links and keeps descending, so a linked folder contributes what is below
|
||||||
query.ItemIds = folder.LinkedChildren
|
// it as well - the episodes of a Series added to a collection, for example.
|
||||||
.Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
|
query.DescendantOfId = folder.Id;
|
||||||
.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()];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// We need to be able to query from any arbitrary ancestor up the tree
|
SetAncestorIds(query, parents);
|
||||||
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()];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
query.Parent = null;
|
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)
|
private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true)
|
||||||
{
|
{
|
||||||
if (query.User is null)
|
if (query.User is null)
|
||||||
@@ -2264,7 +2332,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
||||||
|
|
||||||
foreach (var orderBy in sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null))
|
var comparers = sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null).ToList();
|
||||||
|
items = PrefetchUserData(items, user, comparers);
|
||||||
|
|
||||||
|
foreach (var orderBy in comparers)
|
||||||
{
|
{
|
||||||
if (orderBy is RandomComparer)
|
if (orderBy is RandomComparer)
|
||||||
{
|
{
|
||||||
@@ -2296,14 +2367,14 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
||||||
|
|
||||||
foreach (var (name, sortOrder) in orderBy)
|
var comparers = orderBy
|
||||||
{
|
.Select(o => (Comparer: GetComparer(o.OrderBy, user), o.SortOrder))
|
||||||
var comparer = GetComparer(name, user);
|
.Where(c => c.Comparer is not null)
|
||||||
if (comparer is null)
|
.ToList();
|
||||||
{
|
items = PrefetchUserData(items, user, comparers.Select(c => c.Comparer).ToList());
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
foreach (var (comparer, sortOrder) in comparers)
|
||||||
|
{
|
||||||
if (comparer is RandomComparer)
|
if (comparer is RandomComparer)
|
||||||
{
|
{
|
||||||
var randomItems = items.ToArray();
|
var randomItems = items.ToArray();
|
||||||
@@ -2329,6 +2400,31 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return orderedItems ?? items;
|
return orderedItems ?? items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The user comparers read user data per item, so without one batched read up front an
|
||||||
|
// in-memory sort would issue a database round trip per comparison.
|
||||||
|
private IEnumerable<BaseItem> PrefetchUserData(IEnumerable<BaseItem> items, User? user, IReadOnlyList<IBaseItemComparer?> comparers)
|
||||||
|
{
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userComparers = comparers.OfType<IUserBaseItemComparer>().ToList();
|
||||||
|
if (userComparers.Count == 0)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemList = items as IReadOnlyList<BaseItem> ?? items.ToList();
|
||||||
|
var userData = _userDataManager.GetUserDataBatch(itemList, user);
|
||||||
|
foreach (var comparer in userComparers)
|
||||||
|
{
|
||||||
|
comparer.PrefetchedUserData = userData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemList;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the comparer.
|
/// Gets the comparer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2519,9 +2615,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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2919,7 +3021,8 @@ namespace Emby.Server.Implementations.Library
|
|||||||
"views",
|
"views",
|
||||||
_fileSystem.GetValidFilename(viewType.ToString()));
|
_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;
|
var item = GetItemById(id) as UserView;
|
||||||
|
|
||||||
@@ -2943,6 +3046,13 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
refresh = true;
|
refresh = true;
|
||||||
}
|
}
|
||||||
|
else if (!string.Equals(item.Name, name, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
item.Name = name;
|
||||||
|
item.ForcedSortName = sortName;
|
||||||
|
|
||||||
|
refresh = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (refresh)
|
if (refresh)
|
||||||
{
|
{
|
||||||
@@ -2963,7 +3073,9 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var parentIdString = parentId.IsEmpty()
|
var parentIdString = parentId.IsEmpty()
|
||||||
? null
|
? null
|
||||||
: parentId.ToString("N", CultureInfo.InvariantCulture);
|
: 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));
|
var id = GetNewItemId(idValues, typeof(UserView));
|
||||||
|
|
||||||
@@ -2993,6 +3105,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
isNew = true;
|
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 lastRefreshedUtc = item.DateLastRefreshed;
|
||||||
var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
|
var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
|
||||||
@@ -3094,7 +3211,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var parentIdString = parentId.IsEmpty()
|
var parentIdString = parentId.IsEmpty()
|
||||||
? null
|
? null
|
||||||
: parentId.ToString("N", CultureInfo.InvariantCulture);
|
: 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))
|
if (!string.IsNullOrEmpty(uniqueId))
|
||||||
{
|
{
|
||||||
idValues += uniqueId;
|
idValues += uniqueId;
|
||||||
@@ -3128,9 +3245,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
isNew = true;
|
isNew = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewType != item.ViewType)
|
if (viewType != item.ViewType || !string.Equals(item.Name, name, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
item.ViewType = viewType;
|
item.ViewType = viewType;
|
||||||
|
item.Name = name;
|
||||||
item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
|
item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3550,6 +3668,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return _peopleRepository.GetPeopleNames(query);
|
return _peopleRepository.GetPeopleNames(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public int DeleteOrphanedCredits()
|
||||||
|
{
|
||||||
|
return _peopleRepository.DeleteOrphanedCredits();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
|
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
|
||||||
{
|
{
|
||||||
@@ -3595,7 +3719,20 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
|
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)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
@@ -3617,7 +3754,13 @@ namespace Emby.Server.Implementations.Library
|
|||||||
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
|
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)
|
public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary)
|
||||||
@@ -3673,6 +3816,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
AddMediaPathInternal(name, path, false);
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -3699,27 +3846,14 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
var itemUpdateType = ItemUpdateType.MetadataDownload;
|
var itemUpdateType = ItemUpdateType.MetadataDownload;
|
||||||
var saveEntity = false;
|
var saveEntity = false;
|
||||||
var createEntity = false;
|
|
||||||
var personEntity = GetPerson(person.Name);
|
var personEntity = GetPerson(person.Name);
|
||||||
|
|
||||||
if (personEntity is null)
|
if (personEntity is null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var path = Person.GetPath(person.Name);
|
personEntity = GetOrCreatePerson(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();
|
|
||||||
saveEntity = true;
|
saveEntity = true;
|
||||||
createEntity = true;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -3753,11 +3887,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
if (saveEntity)
|
if (saveEntity)
|
||||||
{
|
{
|
||||||
if (createEntity)
|
|
||||||
{
|
|
||||||
CreateItems([personEntity], null, CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
|
await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
|
||||||
personEntity.DateLastSaved = DateTime.UtcNow;
|
personEntity.DateLastSaved = DateTime.UtcNow;
|
||||||
|
|
||||||
@@ -3797,7 +3926,9 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
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);
|
CreateShortcut(virtualFolderPath, pathInfo);
|
||||||
|
|
||||||
@@ -3818,7 +3949,9 @@ namespace Emby.Server.Implementations.Library
|
|||||||
ArgumentNullException.ThrowIfNull(mediaPath);
|
ArgumentNullException.ThrowIfNull(mediaPath);
|
||||||
|
|
||||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
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);
|
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
|
||||||
|
|
||||||
@@ -3857,9 +3990,9 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
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");
|
throw new FileNotFoundException("The media folder does not exist");
|
||||||
}
|
}
|
||||||
@@ -3869,6 +4002,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.Delete(path, true);
|
Directory.Delete(path, true);
|
||||||
|
_directoryService.Invalidate(path);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -3923,9 +4057,9 @@ namespace Emby.Server.Implementations.Library
|
|||||||
ArgumentException.ThrowIfNullOrEmpty(mediaPath);
|
ArgumentException.ThrowIfNullOrEmpty(mediaPath);
|
||||||
|
|
||||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
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(
|
throw new FileNotFoundException(
|
||||||
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
|
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
|
||||||
@@ -3938,6 +4072,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
if (!string.IsNullOrEmpty(shortcut))
|
if (!string.IsNullOrEmpty(shortcut))
|
||||||
{
|
{
|
||||||
_fileSystem.DeleteFile(shortcut);
|
_fileSystem.DeleteFile(shortcut);
|
||||||
|
_directoryService.Invalidate(shortcut);
|
||||||
}
|
}
|
||||||
|
|
||||||
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
|
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
|
||||||
@@ -3981,6 +4116,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
|
|
||||||
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
||||||
|
_directoryService.Invalidate(lnk);
|
||||||
RemoveContentTypeOverrides(path);
|
RemoveContentTypeOverrides(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||||||
args.LibraryOptions.SeasonZeroDisplayName :
|
args.LibraryOptions.SeasonZeroDisplayName :
|
||||||
string.Format(
|
string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
_localization.GetLocalizedString("NameSeasonNumber"),
|
_localization.GetServerLocalizedString("NameSeasonNumber"),
|
||||||
seasonNumber,
|
seasonNumber,
|
||||||
args.LibraryOptions.PreferredMetadataLanguage);
|
args.LibraryOptions.PreferredMetadataLanguage);
|
||||||
}
|
}
|
||||||
@@ -129,6 +129,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||||||
|
|
||||||
var tmdbId = justName.GetAttributeValue("tmdbid");
|
var tmdbId = justName.GetAttributeValue("tmdbid");
|
||||||
item.TrySetProviderId(MetadataProvider.Tmdb, 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;
|
return externalResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
var internalResults = await internalTask.ConfigureAwait(false);
|
|
||||||
if (_internalProviders.Length > 0)
|
if (_internalProviders.Length > 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("No results from external providers, using internal provider results");
|
_logger.LogDebug("No results from external providers, using internal provider results");
|
||||||
}
|
}
|
||||||
|
|
||||||
return internalResults;
|
return await internalTask.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
||||||
@@ -144,17 +143,16 @@ public class SearchManager : ISearchManager
|
|||||||
|
|
||||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
||||||
|
|
||||||
var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
if (allowedCount == candidates.Count)
|
|
||||||
{
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
var allowedIds = await baseQuery
|
var allowedIds = await baseQuery
|
||||||
.Select(e => e.Id)
|
.Select(e => e.Id)
|
||||||
.ToHashSetAsync(cancellationToken)
|
.ToHashSetAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (allowedIds.Count == candidates.Count)
|
||||||
|
{
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
|
var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
|
||||||
if (filtered.Count < candidates.Count)
|
if (filtered.Count < candidates.Count)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
|||||||
}
|
}
|
||||||
|
|
||||||
var candidateRows = await context.ItemValuesMap.AsNoTracking()
|
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 })
|
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
|
||||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -276,6 +276,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
|||||||
if (personSourceRows.Count > 0)
|
if (personSourceRows.Count > 0)
|
||||||
{
|
{
|
||||||
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
|
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
|
||||||
|
.Where(m => !m.Item.PrimaryVersionId.HasValue)
|
||||||
.Where(m => context.PeopleBaseItemMap
|
.Where(m => context.PeopleBaseItemMap
|
||||||
.Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
|
.Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
|
||||||
.Select(s => s.PeopleId)
|
.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;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using Jellyfin.Database.Implementations.Enums;
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
using Jellyfin.Extensions.Json;
|
using Jellyfin.Extensions.Json;
|
||||||
using MediaBrowser.Common.Extensions;
|
using MediaBrowser.Common.Extensions;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
@@ -16,11 +18,13 @@ using MediaBrowser.Controller.Configuration;
|
|||||||
using MediaBrowser.Controller.Dto;
|
using MediaBrowser.Controller.Dto;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Persistence;
|
||||||
using MediaBrowser.Model.Configuration;
|
using MediaBrowser.Model.Configuration;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using MediaBrowser.Model.Querying;
|
using MediaBrowser.Model.Querying;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||||
@@ -35,6 +39,8 @@ public class SimilarItemsManager : ISimilarItemsManager
|
|||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly IFileSystem _fileSystem;
|
private readonly IFileSystem _fileSystem;
|
||||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||||
|
private readonly IItemQueryHelpers _queryHelpers;
|
||||||
private ISimilarItemsProvider[] _similarItemsProviders = [];
|
private ISimilarItemsProvider[] _similarItemsProviders = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,18 +51,24 @@ public class SimilarItemsManager : ISimilarItemsManager
|
|||||||
/// <param name="libraryManager">The library manager.</param>
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
/// <param name="fileSystem">The file system.</param>
|
/// <param name="fileSystem">The file system.</param>
|
||||||
/// <param name="serverConfigurationManager">The server configuration manager.</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(
|
public SimilarItemsManager(
|
||||||
ILogger<SimilarItemsManager> logger,
|
ILogger<SimilarItemsManager> logger,
|
||||||
IServerApplicationPaths appPaths,
|
IServerApplicationPaths appPaths,
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
IServerConfigurationManager serverConfigurationManager)
|
IServerConfigurationManager serverConfigurationManager,
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
|
IItemQueryHelpers queryHelpers)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_appPaths = appPaths;
|
_appPaths = appPaths;
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
_serverConfigurationManager = serverConfigurationManager;
|
_serverConfigurationManager = serverConfigurationManager;
|
||||||
|
_dbProvider = dbProvider;
|
||||||
|
_queryHelpers = queryHelpers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -230,11 +242,64 @@ public class SimilarItemsManager : ISimilarItemsManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return allResults
|
var ordered = allResults
|
||||||
.OrderByDescending(x => x.Score)
|
.OrderByDescending(x => x.Score)
|
||||||
.Select(x => x.Item)
|
.Select(x => x.Item)
|
||||||
.Take(requestedLimit)
|
.Take(requestedLimit)
|
||||||
.ToList();
|
.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/>
|
/// <inheritdoc/>
|
||||||
@@ -376,19 +441,39 @@ public class SimilarItemsManager : ISimilarItemsManager
|
|||||||
|
|
||||||
var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
|
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);
|
var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
|
||||||
foreach (var baseline in baselineItems)
|
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
|
continue;
|
||||||
{
|
|
||||||
BaselineItemName = baseline.Name,
|
|
||||||
CategoryId = baseline.Id,
|
|
||||||
RecommendationType = recommendationType,
|
|
||||||
Items = similar
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return recommendations;
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using BitFaster.Caching.Lru;
|
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
@@ -27,7 +25,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
private readonly IServerConfigurationManager _config;
|
private readonly IServerConfigurationManager _config;
|
||||||
private readonly IDbContextFactory<JellyfinDbContext> _repository;
|
private readonly IDbContextFactory<JellyfinDbContext> _repository;
|
||||||
private readonly FastConcurrentLru<string, UserItemData> _cache;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="UserDataManager"/> class.
|
/// Initializes a new instance of the <see cref="UserDataManager"/> class.
|
||||||
@@ -40,7 +37,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
_config = config;
|
_config = config;
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_cache = new FastConcurrentLru<string, UserItemData>(Environment.ProcessorCount, _config.Configuration.CacheSize, StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -77,11 +73,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
dbContext.SaveChanges();
|
dbContext.SaveChanges();
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
|
|
||||||
var userId = user.InternalId;
|
|
||||||
var cacheKey = GetCacheKey(userId, item.Id);
|
|
||||||
_cache.AddOrUpdate(cacheKey, userData);
|
|
||||||
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); // rehydrate the cached userdata
|
|
||||||
|
|
||||||
UserDataSaved?.Invoke(this, new UserDataSaveEventArgs
|
UserDataSaved?.Invoke(this, new UserDataSaveEventArgs
|
||||||
{
|
{
|
||||||
Keys = keys,
|
Keys = keys,
|
||||||
@@ -180,64 +171,41 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
public Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
|
||||||
var result = new Dictionary<Guid, UserItemData>(items.Count);
|
var result = new Dictionary<Guid, UserItemData>(items.Count);
|
||||||
var itemsNeedingQuery = new List<(BaseItem Item, List<string> Keys)>();
|
if (items.Count == 0)
|
||||||
|
|
||||||
foreach (var item in items)
|
|
||||||
{
|
|
||||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
|
||||||
if (_cache.TryGet(cacheKey, out var cachedData))
|
|
||||||
{
|
|
||||||
result[item.Id] = cachedData;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
|
||||||
var userData = userDataRow is not null ? Map(userDataRow) : null;
|
|
||||||
if (userData is not null)
|
|
||||||
{
|
|
||||||
result[item.Id] = userData;
|
|
||||||
_cache.AddOrUpdate(cacheKey, userData);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var keys = item.GetUserDataKeys();
|
|
||||||
itemsNeedingQuery.Add((item, keys));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemsNeedingQuery.Count == 0)
|
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a single query for all missing items. Fetch rows by item alone so rows kept
|
// Fetch rows by item alone so rows kept under keys from older metadata resolve the same
|
||||||
// under keys from older metadata resolve the same way as the in-memory path.
|
// way as the single item path.
|
||||||
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
|
var itemIds = items.Select(e => e.Id).Distinct().ToList();
|
||||||
using var context = _repository.CreateDbContext();
|
using var context = _repository.CreateDbContext();
|
||||||
var userDataArray = context.UserData
|
var userDataByItem = context.UserData
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(e => e.UserId.Equals(user.Id))
|
.Where(e => e.UserId.Equals(user.Id))
|
||||||
.WhereOneOrMany(allItemIds, e => e.ItemId)
|
.WhereOneOrMany(itemIds, e => e.ItemId)
|
||||||
.ToArray();
|
.ToArray()
|
||||||
|
.GroupBy(e => e.ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToArray());
|
||||||
|
|
||||||
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
|
foreach (var item in items)
|
||||||
foreach (var (item, keys) in itemsNeedingQuery)
|
|
||||||
{
|
{
|
||||||
UserItemData userData;
|
if (result.ContainsKey(item.Id))
|
||||||
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
|
|
||||||
{
|
{
|
||||||
userData = Map(ResolveUserDataRow(item, itemUserData)!);
|
continue;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result[item.Id] = userData;
|
var row = userDataByItem.TryGetValue(item.Id, out var itemUserData)
|
||||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
? ResolveUserDataRow(item, itemUserData)
|
||||||
_cache.AddOrUpdate(cacheKey, userData);
|
: null;
|
||||||
|
|
||||||
|
result[item.Id] = row is not null
|
||||||
|
? Map(row)
|
||||||
|
: new UserItemData { Key = item.GetUserDataKeys().FirstOrDefault() ?? string.Empty };
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -340,20 +308,19 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the internal key.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>System.String.</returns>
|
|
||||||
private static string GetCacheKey(long internalUserId, Guid itemId)
|
|
||||||
{
|
|
||||||
return internalUserId.ToString(CultureInfo.InvariantCulture) + "-" + itemId.ToString("N", CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public UserItemData? GetUserData(User user, BaseItem item)
|
public UserItemData? GetUserData(User user, BaseItem item)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
|
||||||
|
using var dbContext = _repository.CreateDbContext();
|
||||||
|
var rows = dbContext.UserData
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.ItemId == item.Id && e.UserId == user.Id)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var row = ResolveUserDataRow(item, rows);
|
||||||
return row is not null ? Map(row) : new UserItemData()
|
return row is not null ? Map(row) : new UserItemData()
|
||||||
{
|
{
|
||||||
Key = item.GetUserDataKeys()[0],
|
Key = item.GetUserDataKeys()[0],
|
||||||
@@ -536,16 +503,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbContext.SaveChanges();
|
dbContext.SaveChanges();
|
||||||
|
|
||||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
|
||||||
if (_cache.TryGet(cacheKey, out var cached))
|
|
||||||
{
|
|
||||||
cached.AudioStreamIndex = null;
|
|
||||||
cached.SubtitleStreamIndex = null;
|
|
||||||
_cache.AddOrUpdate(cacheKey, cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
if (_config.Configuration.EnableFolderView)
|
if (_config.Configuration.EnableFolderView)
|
||||||
{
|
{
|
||||||
var name = _localizationManager.GetLocalizedString("Folders");
|
var name = _localizationManager.GetServerLocalizedString("Folders");
|
||||||
list.Add(_libraryManager.GetNamedView(name, CollectionType.folders, string.Empty));
|
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)
|
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);
|
return GetUserSubViewWithName(name, parentId, type, sortName);
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return GetUserView((Folder)parents[0], viewType, string.Empty);
|
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);
|
return _libraryManager.GetNamedView(user, name, viewType, sortName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +396,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
query.Limit = limit;
|
query.Limit = limit;
|
||||||
return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies);
|
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);
|
return _libraryManager.GetItemList(query, parents);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -61,6 +62,9 @@ public class ArtistsValidator
|
|||||||
var count = names.Count;
|
var count = names.Count;
|
||||||
var refreshed = 0;
|
var refreshed = 0;
|
||||||
|
|
||||||
|
var liveIds = new HashSet<Guid>();
|
||||||
|
var unresolved = 0;
|
||||||
|
|
||||||
foreach (var name in names)
|
foreach (var name in names)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -73,13 +77,20 @@ public class ArtistsValidator
|
|||||||
|
|
||||||
// Fall back to GetArtist if not found (creates new item if needed)
|
// Fall back to GetArtist if not found (creates new item if needed)
|
||||||
item ??= _libraryManager.GetArtist(name);
|
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);
|
liveIds.Add(item.Id);
|
||||||
refreshed++;
|
|
||||||
|
var isNew = !existingArtistIds.Contains(item.Id);
|
||||||
|
var neverRefreshed = item.DateLastRefreshed == default;
|
||||||
|
|
||||||
|
if (isNew || neverRefreshed)
|
||||||
|
{
|
||||||
|
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||||
|
refreshed++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -88,6 +99,7 @@ public class ArtistsValidator
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
unresolved++;
|
||||||
_logger.LogError(ex, "Error refreshing {ArtistName}", name);
|
_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);
|
_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
|
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
{
|
{
|
||||||
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
||||||
IsDeadArtist = true,
|
|
||||||
IsLocked = false
|
IsLocked = false
|
||||||
}).Cast<MusicArtist>()
|
}).OfType<MusicArtist>()
|
||||||
.Where(item => item.IsAccessedByName)
|
.Where(item => item.IsAccessedByName && !liveIds.Contains(item.Id))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
foreach (var item in deadEntities)
|
foreach (var item in deadEntities)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Providers;
|
|
||||||
using MediaBrowser.Model.IO;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Library.Validators;
|
namespace Emby.Server.Implementations.Library.Validators;
|
||||||
@@ -17,104 +17,143 @@ namespace Emby.Server.Implementations.Library.Validators;
|
|||||||
public class PeopleValidator
|
public class PeopleValidator
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The _library manager.
|
/// The library manager.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The _logger.
|
/// The logger.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger<PeopleValidator> _logger;
|
||||||
|
|
||||||
private readonly IFileSystem _fileSystem;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="PeopleValidator" /> class.
|
/// Initializes a new instance of the <see cref="PeopleValidator" /> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="libraryManager">The library manager.</param>
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
/// <param name="logger">The logger.</param>
|
/// <param name="logger">The logger.</param>
|
||||||
/// <param name="fileSystem">The file system.</param>
|
public PeopleValidator(ILibraryManager libraryManager, ILogger<PeopleValidator> logger)
|
||||||
public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem)
|
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_fileSystem = fileSystem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates the people.
|
/// Validates the people.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
|
||||||
/// <param name="progress">The progress.</param>
|
/// <param name="progress">The progress.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>Task.</returns>
|
/// <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 numComplete = 0;
|
||||||
|
var count = names.Count;
|
||||||
|
var refreshed = 0;
|
||||||
|
|
||||||
var numPeople = people.Count;
|
foreach (var name in names)
|
||||||
|
|
||||||
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
|
||||||
|
|
||||||
_logger.LogDebug("Will refresh {Amount} people", numPeople);
|
|
||||||
|
|
||||||
foreach (var person in people)
|
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var item = _libraryManager.GetPerson(person);
|
var item = _libraryManager.GetOrCreatePerson(name);
|
||||||
if (item is null)
|
var isNew = !existingPersonIds.Contains(item.Id);
|
||||||
|
var neverRefreshed = item.DateLastRefreshed == default;
|
||||||
|
|
||||||
|
if (isNew || neverRefreshed)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Failed to get person: {Name}", person);
|
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||||
continue;
|
refreshed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
|
||||||
{
|
|
||||||
ImageRefreshMode = MetadataRefreshMode.ValidationOnly,
|
|
||||||
MetadataRefreshMode = MetadataRefreshMode.ValidationOnly
|
|
||||||
};
|
|
||||||
|
|
||||||
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
// Don't clutter the log
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error validating IBN entry {Person}", person);
|
_logger.LogError(ex, "Error refreshing {PersonName}", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress
|
|
||||||
numComplete++;
|
numComplete++;
|
||||||
double percent = numComplete;
|
double percent = numComplete;
|
||||||
percent /= numPeople;
|
percent /= count;
|
||||||
|
percent *= 100;
|
||||||
|
|
||||||
subProgress.Report(100 * percent);
|
progress.Report(percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
_logger.LogInformation(
|
||||||
{
|
"Refreshed metadata for {RefreshedCount} people out of {TotalCount} total, {NewCount} of which had no item yet",
|
||||||
IncludeItemTypes = [BaseItemKind.Person],
|
refreshed,
|
||||||
IsDeadPerson = true,
|
count,
|
||||||
IsLocked = false
|
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)
|
||||||
foreach (var item in deadEntities.Chunk(500))
|
|
||||||
{
|
{
|
||||||
_libraryManager.DeleteItemsUnsafeFast(item, true);
|
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
||||||
subProgress.Report(100f / deadEntities.Count * (i++ * 100));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
|
||||||
|
|
||||||
progress.Report(100);
|
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": "Сканіраванне медыя-сегмента",
|
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
|
||||||
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
|
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
|
||||||
"CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка",
|
"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 изображения спрямо настройките на библиотеката.",
|
"TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.",
|
||||||
"TaskExtractMediaSegments": "Сканиране за сегменти",
|
"TaskExtractMediaSegments": "Сканиране за сегменти",
|
||||||
"CleanupUserDataTask": "Задача за почистване на потребителски данни",
|
"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",
|
"TaskMoveTrickplayImages": "Migracija lokacije slike Trickplay",
|
||||||
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke trik-igara prema postavkama biblioteke.",
|
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke trik-igara prema postavkama biblioteke.",
|
||||||
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
|
"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",
|
"Shows": "Serier",
|
||||||
"StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.",
|
"StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.",
|
||||||
"SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}",
|
"SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}",
|
||||||
"TvShows": "TV-serier",
|
"TvShows": "Serier",
|
||||||
"UserCreatedWithName": "Bruger {0} er blevet oprettet",
|
"UserCreatedWithName": "Bruger {0} er blevet oprettet",
|
||||||
"UserDeletedWithName": "Brugeren {0} er nu slettet",
|
"UserDeletedWithName": "Brugeren {0} er nu slettet",
|
||||||
"UserDownloadingItemWithValues": "{0} henter {1}",
|
"UserDownloadingItemWithValues": "{0} henter {1}",
|
||||||
@@ -80,8 +80,8 @@
|
|||||||
"TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.",
|
"TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.",
|
||||||
"TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.",
|
"TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.",
|
||||||
"TaskRefreshChannels": "Opdatér kanaler",
|
"TaskRefreshChannels": "Opdatér kanaler",
|
||||||
"TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.",
|
"TaskCleanTranscodeDescription": "Fjerner transkodningsfiler, som er mere end 1 dag gamle.",
|
||||||
"TaskCleanTranscode": "Tøm omkodningsmappen",
|
"TaskCleanTranscode": "Tøm transkodningsmappen",
|
||||||
"TaskRefreshPeople": "Opdatér personer",
|
"TaskRefreshPeople": "Opdatér personer",
|
||||||
"TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.",
|
"TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.",
|
||||||
"TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.",
|
"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.",
|
"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}",
|
"LyricDownloadFailureFromForItem": "Sangtekster kunne ikke downloades fra {0} til {1}",
|
||||||
"Original": "Original",
|
"Original": "Original",
|
||||||
"NameExtraBehindTheScenes": "Bag Scenerne",
|
"NameExtraBehindTheScenes": "Bag scenerne",
|
||||||
"NameExtraClip": "Klip",
|
"NameExtraClip": "Klip",
|
||||||
"NameExtraDeletedScene": "Slettet Scene",
|
"NameExtraDeletedScene": "Slettet scene",
|
||||||
"NameExtraFeaturette": "Featurette",
|
"NameExtraFeaturette": "Featurette",
|
||||||
"NameExtraInterview": "Interview",
|
"NameExtraInterview": "Interview",
|
||||||
"NameExtraNumbered": "{0} {1}",
|
"NameExtraNumbered": "{0} {1}",
|
||||||
"NameExtraSample": "Smagsprøve",
|
"NameExtraSample": "Smagsprøve",
|
||||||
"NameExtraScene": "Scene",
|
"NameExtraScene": "Scene",
|
||||||
"NameExtraShort": "Kort",
|
"NameExtraShort": "Kort",
|
||||||
"NameExtraThemeSong": "Tema Sang",
|
"NameExtraThemeSong": "Temasang",
|
||||||
"NameExtraThemeVideo": "Tema Video",
|
"NameExtraThemeVideo": "Temavideo",
|
||||||
"NameExtraTrailer": "Trailer",
|
"NameExtraTrailer": "Trailer",
|
||||||
"NameExtraUnknown": "Ekstra"
|
"NameExtraUnknown": "Ekstra"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"AppDeviceValues": "App: {0}, Gerät: {1}",
|
"AppDeviceValues": "App: {0}, Gerät: {1}",
|
||||||
"Artists": "Interpreten",
|
"Artists": "Künstler",
|
||||||
"AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert",
|
"AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert",
|
||||||
"Books": "Bücher",
|
"Books": "Bücher",
|
||||||
"ChapterNameValue": "Kapitel {0}",
|
"ChapterNameValue": "Kapitel {0}",
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
|
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
|
||||||
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
|
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
|
||||||
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
|
"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",
|
"Music": "Music",
|
||||||
"MusicVideos": "Music Videos",
|
"MusicVideos": "Music Videos",
|
||||||
"NameInstallFailed": "{0} installation failed",
|
"NameInstallFailed": "{0} installation failed",
|
||||||
"NameSeasonNumber": "Season {0}",
|
"NameSeasonNumber": "Series {0}",
|
||||||
"NameSeasonUnknown": "Season Unknown",
|
"NameSeasonUnknown": "Series Unknown",
|
||||||
"NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.",
|
"NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.",
|
||||||
"NotificationOptionApplicationUpdateAvailable": "Application update available",
|
"NotificationOptionApplicationUpdateAvailable": "Application update available",
|
||||||
"NotificationOptionApplicationUpdateInstalled": "Application update installed",
|
"NotificationOptionApplicationUpdateInstalled": "Application update installed",
|
||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTask": "User data cleanup task",
|
"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.",
|
"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}",
|
"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.",
|
"TaskAudioNormalizationDescription": "Skanas dosierojn por sonnivelaj normaligaj datumoj.",
|
||||||
"TaskRefreshTrickplayImages": "Generi la bildojn por TrickPlay (Antaŭrigardo rapida antaŭen)",
|
"TaskRefreshTrickplayImages": "Generi la bildojn por TrickPlay (Antaŭrigardo rapida antaŭen)",
|
||||||
"TaskAudioNormalization": "Normaligo Sonnivela",
|
"TaskAudioNormalization": "Normaligo Sonnivela",
|
||||||
"HearingImpaired": "Surda"
|
"HearingImpaired": "Surda",
|
||||||
|
"NameExtraDeletedScene": "Forigita sceno",
|
||||||
|
"NameExtraScene": "Sceno",
|
||||||
|
"NameExtraThemeSong": "Tema Kanto",
|
||||||
|
"Original": "Originala"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,5 +114,12 @@
|
|||||||
"NameExtraScene": "Eszena",
|
"NameExtraScene": "Eszena",
|
||||||
"NameExtraShort": "Laburra",
|
"NameExtraShort": "Laburra",
|
||||||
"NameExtraThemeSong": "Gai-abestia",
|
"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ä",
|
"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.",
|
"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",
|
"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",
|
"Artists": "Tónlistafólk",
|
||||||
"Collections": "Søvn",
|
"Collections": "Samlingar",
|
||||||
"Default": "Forsett",
|
"Default": "Forsett",
|
||||||
"External": "Ytri",
|
"External": "Ytri",
|
||||||
"Genres": "Greinar",
|
"Genres": "Greinar",
|
||||||
"AppDeviceValues": "App: {0}, Eind: {1}",
|
"AppDeviceValues": "App: {0}, Eind: {1}",
|
||||||
"Books": "Bøkur",
|
"Books": "Bøkur",
|
||||||
"ChapterNameValue": "Kapittul {0}",
|
"ChapterNameValue": "Kapittul {0}",
|
||||||
"Favorites": "Yndis",
|
"Favorites": "Yndislista",
|
||||||
"Folders": "Mappur",
|
"Folders": "Skjáttur",
|
||||||
"Forced": "Kravt",
|
"Forced": "Kravt",
|
||||||
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
|
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
|
||||||
"HeaderFavoriteEpisodes": "Yndispartar",
|
"HeaderFavoriteEpisodes": "Yndispartar",
|
||||||
"LabelIpAddressValue": "IP-atsetur: {0}",
|
"LabelIpAddressValue": "IP-atsetur: {0}",
|
||||||
"AuthenticationSucceededWithUserName": "{0} varð samgildur",
|
"AuthenticationSucceededWithUserName": "{0} var samgildur",
|
||||||
"HeaderFavoriteShows": "Yndisrøðir",
|
"HeaderFavoriteShows": "Yndisrøðir",
|
||||||
"HeaderLiveTV": "Beinleiðis sjónvarp",
|
"HeaderLiveTV": "Beinleiðis sjónvarp",
|
||||||
"HearingImpaired": "Hoyrnarveik",
|
"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.",
|
"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.",
|
"TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.",
|
||||||
"TaskOptimizeDatabase": "Albøt dátugrunn",
|
"TaskOptimizeDatabase": "Albøt dátugrunn",
|
||||||
"NameSeasonNumber": "Sesong {0}",
|
"NameSeasonNumber": "Umfar {0}",
|
||||||
"NameSeasonUnknown": "Ókend sesong",
|
"NameSeasonUnknown": "Ókent umfar",
|
||||||
"ScheduledTaskFailedWithName": "{0} miseydnaðist",
|
"ScheduledTaskFailedWithName": "{0} miseydnaðist",
|
||||||
"Undefined": "Óskilmarkað",
|
"Undefined": "Óskilmarkað",
|
||||||
"TasksMaintenanceCategory": "Viðlíkahald",
|
"TasksMaintenanceCategory": "Viðlíkahald",
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
"NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan",
|
"NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan",
|
||||||
"TasksApplicationCategory": "Nýtsluskipan",
|
"TasksApplicationCategory": "Nýtsluskipan",
|
||||||
"NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk",
|
"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}",
|
"UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}",
|
||||||
"HomeVideos": "Heimaupptøkur",
|
"HomeVideos": "Heimaupptøkur",
|
||||||
"StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.",
|
"StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.",
|
||||||
@@ -104,12 +104,22 @@
|
|||||||
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
|
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
|
||||||
"NameExtraShort": "Stuttfilmur",
|
"NameExtraShort": "Stuttfilmur",
|
||||||
"NameExtraThemeSong": "Eyðkennislag",
|
"NameExtraThemeSong": "Eyðkennislag",
|
||||||
"NameExtraTrailer": "Forfilmur",
|
"NameExtraTrailer": "Brellbiti",
|
||||||
"NameExtraInterview": "Samrøða",
|
"NameExtraInterview": "Samrøða",
|
||||||
"NameExtraBehindTheScenes": "Aftanfyri leiktjøldini",
|
"NameExtraBehindTheScenes": "Aftanfyri leiktjøldini",
|
||||||
"NameExtraClip": "Klipp",
|
"NameExtraClip": "Klipp",
|
||||||
"NameExtraNumbered": "{0} {1}",
|
"NameExtraNumbered": "{0} {1}",
|
||||||
"NameExtraFeaturette": "Stuttur heimildarfilmur",
|
"NameExtraFeaturette": "Stuttur heimildarfilmur",
|
||||||
"TaskAudioNormalization": "Ljóðjavnan",
|
"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",
|
"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.",
|
"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",
|
"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.",
|
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
|
||||||
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
|
"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.",
|
"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 יום.",
|
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
|
||||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש",
|
"CleanupUserDataTask": "משימת ניקוי מידע משתמש",
|
||||||
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
|
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
|
||||||
"Original": "מקור"
|
"Original": "מקור",
|
||||||
|
"NameExtraBehindTheScenes": "מאחורי הקלעים",
|
||||||
|
"NameExtraClip": "קליפ",
|
||||||
|
"NameExtraDeletedScene": "סצנה שנמחקה",
|
||||||
|
"NameExtraFeaturette": "סרט קצר",
|
||||||
|
"NameExtraInterview": "ריאיון",
|
||||||
|
"NameExtraSample": "דגימה",
|
||||||
|
"NameExtraScene": "סצנה"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,5 +106,20 @@
|
|||||||
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
|
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
|
||||||
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
|
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
|
||||||
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य",
|
"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",
|
"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.",
|
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.",
|
||||||
"Original": "Original",
|
"Original": "Original",
|
||||||
"LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo"
|
"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",
|
"NameExtraThemeSong": "Főcímdal",
|
||||||
"NameExtraThemeVideo": "Főcímvideó",
|
"NameExtraThemeVideo": "Főcímvideó",
|
||||||
"NameExtraTrailer": "Előzetes",
|
"NameExtraTrailer": "Előzetes",
|
||||||
"NameExtraUnknown": "Extra"
|
"NameExtraUnknown": "Extra",
|
||||||
|
"NameExtraNumbered": "{0} {1}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,5 +24,10 @@
|
|||||||
"TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը",
|
"TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը",
|
||||||
"AppDeviceValues": "Հավելված` {0}, Սարք `{1}",
|
"AppDeviceValues": "Հավելված` {0}, Սարք `{1}",
|
||||||
"ChapterNameValue": "Գլուխ {0}",
|
"ChapterNameValue": "Գլուխ {0}",
|
||||||
"Collections": "Հավաքածուներ"
|
"Collections": "Հավաքածուներ",
|
||||||
|
"Artists": "Երաժիշտներ",
|
||||||
|
"Default": "Լռելյայն",
|
||||||
|
"Favorites": "Ընտրյալ",
|
||||||
|
"Forced": "Ստիպուած",
|
||||||
|
"Genres": "Ոճ"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,5 +118,8 @@
|
|||||||
"NameExtraSample": "Campione",
|
"NameExtraSample": "Campione",
|
||||||
"NameExtraShort": "Corto",
|
"NameExtraShort": "Corto",
|
||||||
"NameExtraThemeSong": "Sigla musicale",
|
"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}",
|
"LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}",
|
||||||
"Original": "Original",
|
"Original": "Original",
|
||||||
"CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten",
|
"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}",
|
"UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}",
|
||||||
"UserOnlineFromDevice": "{0} prisijungęs iš {1}",
|
"UserOnlineFromDevice": "{0} prisijungęs iš {1}",
|
||||||
"UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}",
|
"UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}",
|
||||||
"UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}",
|
"UserStartedPlayingItemWithValues": "{0} atkuriama {1} į {2}",
|
||||||
"UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}",
|
"UserStoppedPlayingItemWithValues": "{0} baigė atkūrimą {1} į {2}",
|
||||||
"VersionNumber": "Versija {0}",
|
"VersionNumber": "Versija {0}",
|
||||||
"TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.",
|
"TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.",
|
||||||
"TaskUpdatePlugins": "Atnaujinti įskieius",
|
"TaskUpdatePlugins": "Atnaujinti įskieius",
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
"TaskCleanActivityLog": "Išvalyti veiklos žurnalą",
|
"TaskCleanActivityLog": "Išvalyti veiklos žurnalą",
|
||||||
"Undefined": "Neapibrėžtas",
|
"Undefined": "Neapibrėžtas",
|
||||||
"Forced": "Priverstinis",
|
"Forced": "Priverstinis",
|
||||||
"Default": "Numatytas",
|
"Default": "Numatytasis",
|
||||||
"TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.",
|
"TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.",
|
||||||
"TaskOptimizeDatabase": "Optimizuoti duomenų bazę",
|
"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.",
|
"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",
|
"TaskAudioNormalization": "Garso normalizavimas",
|
||||||
"TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
|
"TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
|
||||||
"TaskExtractMediaSegments": "Medijos segmentų nuskaitymas",
|
"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ų.",
|
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
|
||||||
"TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą",
|
"TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą",
|
||||||
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.",
|
"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",
|
"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.).",
|
"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",
|
"NameExtraBehindTheScenes": "Užkulisiuose",
|
||||||
"NameExtraClip": "Klipas",
|
"NameExtraClip": "Klipas",
|
||||||
"NameExtraDeletedScene": "Ištrinta scena",
|
"NameExtraDeletedScene": "Ištrinta scena",
|
||||||
|
|||||||
@@ -108,5 +108,17 @@
|
|||||||
"CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums",
|
"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.",
|
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.",
|
||||||
"Original": "Oriģināls",
|
"Original": "Oriģināls",
|
||||||
"LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}"
|
"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",
|
"TaskAudioNormalization": "Normalisasi Audio",
|
||||||
"TaskAudioNormalizationDescription": "Mengimbas fail-fail untuk data 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.",
|
"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.",
|
"TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.",
|
||||||
"TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.",
|
"TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.",
|
||||||
"CleanupUserDataTaskDescription": "Sletter all brukerdata (avspillings-status, favoritter osv.) fra innhold som har vært utilgjengelig i minst 90 dager.",
|
"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",
|
"NameExtraThemeVideo": "Vídeo de Abertura",
|
||||||
"NameExtraTrailer": "Trailer",
|
"NameExtraTrailer": "Trailer",
|
||||||
"NameExtraUnknown": "Extra",
|
"NameExtraUnknown": "Extra",
|
||||||
"NameExtraFeaturette": "Nos Bastidores"
|
"NameExtraFeaturette": "Nos Bastidores",
|
||||||
|
"NameExtraInterview": "Entrevista"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,108 +5,121 @@
|
|||||||
"Books": "Livros",
|
"Books": "Livros",
|
||||||
"ChapterNameValue": "Capítulo {0}",
|
"ChapterNameValue": "Capítulo {0}",
|
||||||
"Collections": "Coleções",
|
"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",
|
"Favorites": "Favoritos",
|
||||||
"Folders": "Pastas",
|
"Folders": "Pastas",
|
||||||
"Genres": "Géneros",
|
"Genres": "Géneros",
|
||||||
"HeaderContinueWatching": "Continuar a ver",
|
"HeaderContinueWatching": "Continuar a ver",
|
||||||
"HeaderFavoriteEpisodes": "Episódios Favoritos",
|
"HeaderFavoriteEpisodes": "Episódios favoritos",
|
||||||
"HeaderFavoriteShows": "Séries Favoritas",
|
"HeaderFavoriteShows": "Séries favoritas",
|
||||||
"HeaderLiveTV": "TV em Direto",
|
"HeaderLiveTV": "TV em direto",
|
||||||
"HeaderNextUp": "A Seguir",
|
"HeaderNextUp": "A seguir",
|
||||||
"HomeVideos": "Vídeos Caseiros",
|
"HomeVideos": "Vídeos caseiros",
|
||||||
"Inherit": "Herdar",
|
"Inherit": "Herdar",
|
||||||
"LabelIpAddressValue": "Endereço IP: {0}",
|
"LabelIpAddressValue": "Endereço IP: {0}",
|
||||||
"LabelRunningTimeValue": "Duração: {0}",
|
"LabelRunningTimeValue": "Duração: {0}",
|
||||||
"Latest": "Mais Recente",
|
"Latest": "Mais recente",
|
||||||
"MixedContent": "Conteúdo Misto",
|
"MixedContent": "Conteúdo misto",
|
||||||
"Movies": "Filmes",
|
"Movies": "Filmes",
|
||||||
"Music": "Música",
|
"Music": "Música",
|
||||||
"MusicVideos": "Videoclipes",
|
"MusicVideos": "Videoclipes",
|
||||||
"NameInstallFailed": "{0} falha na instalação",
|
"NameInstallFailed": "Falha na instalação de {0}",
|
||||||
"NameSeasonNumber": "Temporada {0}",
|
"NameSeasonNumber": "Temporada {0}",
|
||||||
"NameSeasonUnknown": "Temporada Desconhecida",
|
"NameSeasonUnknown": "Temporada desconhecida",
|
||||||
"NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.",
|
"NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.",
|
||||||
"NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível",
|
"NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível",
|
||||||
"NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada",
|
"NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada",
|
||||||
"NotificationOptionAudioPlayback": "Reprodução Iniciada",
|
"NotificationOptionAudioPlayback": "Reprodução de áudio iniciada",
|
||||||
"NotificationOptionAudioPlaybackStopped": "Reprodução Parada",
|
"NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida",
|
||||||
"NotificationOptionCameraImageUploaded": "Imagem da câmara enviada",
|
"NotificationOptionCameraImageUploaded": "Imagem da câmara enviada",
|
||||||
"NotificationOptionInstallationFailed": "Falha na instalação",
|
"NotificationOptionInstallationFailed": "Falha na instalação",
|
||||||
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
|
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
|
||||||
"NotificationOptionPluginError": "Falha na extensão",
|
"NotificationOptionPluginError": "Falha no plugin",
|
||||||
"NotificationOptionPluginInstalled": "Extensão instalada",
|
"NotificationOptionPluginInstalled": "Plugin instalado",
|
||||||
"NotificationOptionPluginUninstalled": "Extensão desinstalada",
|
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
|
||||||
"NotificationOptionPluginUpdateInstalled": "Extensão atualizada",
|
"NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada",
|
||||||
"NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor",
|
"NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor",
|
||||||
"NotificationOptionTaskFailed": "Falha em tarefa agendada",
|
"NotificationOptionTaskFailed": "Falha em tarefa agendada",
|
||||||
"NotificationOptionUserLockedOut": "Utilizador bloqueado",
|
"NotificationOptionUserLockedOut": "Utilizador bloqueado",
|
||||||
"NotificationOptionVideoPlayback": "Reprodução do vídeo iniciada",
|
"NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada",
|
||||||
"NotificationOptionVideoPlaybackStopped": "Reprodução do vídeo parada",
|
"NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida",
|
||||||
"Photos": "Fotografias",
|
"Photos": "Fotografias",
|
||||||
"PluginInstalledWithName": "{0} foi instalado",
|
"PluginInstalledWithName": "{0} foi instalado",
|
||||||
"PluginUninstalledWithName": "{0} foi desinstalado",
|
"PluginUninstalledWithName": "{0} foi desinstalado",
|
||||||
"PluginUpdatedWithName": "{0} foi atualizado",
|
"PluginUpdatedWithName": "{0} foi atualizado",
|
||||||
"ScheduledTaskFailedWithName": "{0} falhou",
|
"ScheduledTaskFailedWithName": "{0} falhou",
|
||||||
"Shows": "Séries",
|
"Shows": "Séries",
|
||||||
"StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.",
|
"StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.",
|
||||||
"SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}",
|
"SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}",
|
||||||
"TvShows": "Séries",
|
"TvShows": "Séries de TV",
|
||||||
"UserCreatedWithName": "Utilizador {0} criado",
|
"UserCreatedWithName": "Utilizador {0} criado",
|
||||||
"UserDeletedWithName": "Utilizador {0} apagado",
|
"UserDeletedWithName": "Utilizador {0} eliminado",
|
||||||
"UserDownloadingItemWithValues": "{0} está a transferir {1}",
|
"UserDownloadingItemWithValues": "{0} está a transferir {1}",
|
||||||
"UserLockedOutWithName": "Utilizador {0} bloqueado",
|
"UserLockedOutWithName": "Utilizador {0} bloqueado",
|
||||||
"UserOfflineFromDevice": "{0} desligou-se a partir de {1}",
|
"UserOfflineFromDevice": "{0} desligou-se de {1}",
|
||||||
"UserOnlineFromDevice": "{0} ligou-se a partir de {1}",
|
"UserOnlineFromDevice": "{0} está online em {1}",
|
||||||
"UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}",
|
"UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}",
|
||||||
"UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}",
|
"UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}",
|
||||||
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
|
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
|
||||||
"VersionNumber": "Versão {0}",
|
"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",
|
"TaskDownloadMissingSubtitles": "Transferir legendas em falta",
|
||||||
"TaskRefreshChannelsDescription": "Atualizar informação sobre canais da Internet.",
|
"TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.",
|
||||||
"TaskRefreshChannels": "Atualizar Canais",
|
"TaskRefreshChannels": "Atualizar canais",
|
||||||
"TaskCleanTranscodeDescription": "Apagar ficheiros de transcode com mais de um dia.",
|
"TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.",
|
||||||
"TaskCleanTranscode": "Limpar a Diretoria de Transcode",
|
"TaskCleanTranscode": "Limpar pasta de transcodificação",
|
||||||
"TaskUpdatePluginsDescription": "Faz o download e instala updates para os plugins que estão configurados para atualizar automaticamente.",
|
"TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.",
|
||||||
"TaskUpdatePlugins": "Atualizar Plugins",
|
"TaskUpdatePlugins": "Atualizar plugins",
|
||||||
"TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.",
|
"TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.",
|
||||||
"TaskRefreshPeople": "Atualizar Pessoas",
|
"TaskRefreshPeople": "Atualizar pessoas",
|
||||||
"TaskCleanLogsDescription": "Apagar ficheiros de log que têm mais de {0} dias.",
|
"TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.",
|
||||||
"TaskCleanLogs": "Limpar a Diretoria de Logs",
|
"TaskCleanLogs": "Limpar pasta de registos",
|
||||||
"TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.",
|
"TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.",
|
||||||
"TaskRefreshLibrary": "Analisar mediateca",
|
"TaskRefreshLibrary": "Analisar biblioteca multimédia",
|
||||||
"TaskRefreshChapterImagesDescription": "Criar thumbnails para os vídeos que têm capítulos.",
|
"TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.",
|
||||||
"TaskRefreshChapterImages": "Extrair Imagens dos Capítulos",
|
"TaskRefreshChapterImages": "Extrair imagens dos capítulos",
|
||||||
"TaskCleanCacheDescription": "Apagar ficheiros em cache que já não são necessários.",
|
"TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.",
|
||||||
"TaskCleanCache": "Limpar Cache",
|
"TaskCleanCache": "Limpar pasta de cache",
|
||||||
"TasksChannelsCategory": "Canais da Internet",
|
"TasksChannelsCategory": "Canais da Internet",
|
||||||
"TasksApplicationCategory": "Aplicação",
|
"TasksApplicationCategory": "Aplicação",
|
||||||
"TasksLibraryCategory": "Mediateca",
|
"TasksLibraryCategory": "Biblioteca",
|
||||||
"TasksMaintenanceCategory": "Manutenção",
|
"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",
|
"TaskCleanActivityLog": "Limpar registo de atividade",
|
||||||
"Undefined": "Indefinido",
|
"Undefined": "Indefinido",
|
||||||
"Forced": "Forçado",
|
"Forced": "Forçado",
|
||||||
"Default": "Predefinição",
|
"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",
|
"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.",
|
"TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.",
|
||||||
"TaskKeyframeExtractor": "Extrator de Quadros-chave",
|
"TaskKeyframeExtractor": "Extrator de fotogramas-chave",
|
||||||
"External": "Externo",
|
"External": "Externo",
|
||||||
"HearingImpaired": "Surdo",
|
"HearingImpaired": "Deficiência auditiva",
|
||||||
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
|
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
|
||||||
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
|
"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.",
|
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
|
||||||
"TaskAudioNormalization": "Normalização de áudio",
|
"TaskAudioNormalization": "Normalização de áudio",
|
||||||
"TaskExtractMediaSegments": "Analisar segmentos de multimédia",
|
"TaskExtractMediaSegments": "Analisar segmentos multimédia",
|
||||||
"TaskDownloadMissingLyrics": "Transferir letra em falta",
|
"TaskDownloadMissingLyrics": "Transferir letras em falta",
|
||||||
"TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
|
"TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay",
|
||||||
"TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
|
"TaskDownloadMissingLyricsDescription": "Transfere letras de músicas",
|
||||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
|
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.",
|
||||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
|
"TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.",
|
||||||
"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.",
|
"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",
|
"CleanupUserDataTask": "Limpeza de dados de utilizador",
|
||||||
"Original": "Original",
|
"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",
|
"Collections": "Coleções",
|
||||||
"Books": "Livros",
|
"Books": "Livros",
|
||||||
"Artists": "Artistas",
|
"Artists": "Artistas",
|
||||||
"HeaderNextUp": "A Seguir",
|
"HeaderNextUp": "A seguir",
|
||||||
"HeaderFavoriteEpisodes": "Episódios Favoritos",
|
"HeaderFavoriteEpisodes": "Episódios favoritos",
|
||||||
"HeaderFavoriteShows": "Séries Favoritas",
|
"HeaderFavoriteShows": "Séries favoritas",
|
||||||
"HeaderContinueWatching": "Continuar a ver",
|
"HeaderContinueWatching": "Continuar a ver",
|
||||||
"Genres": "Géneros",
|
"Genres": "Géneros",
|
||||||
"Folders": "Pastas",
|
"Folders": "Pastas",
|
||||||
"Favorites": "Favoritos",
|
"Favorites": "Favoritos",
|
||||||
"UserDownloadingItemWithValues": "{0} está sendo baixado {1}",
|
"UserDownloadingItemWithValues": "{0} está a transferir {1}",
|
||||||
"VersionNumber": "Versão {0}",
|
"VersionNumber": "Versão {0}",
|
||||||
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
|
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
|
||||||
"UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}",
|
"UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}",
|
||||||
"UserPasswordChangedWithName": "A senha do usuário {0} foi alterada",
|
"UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}",
|
||||||
"UserOnlineFromDevice": "{0} está online a partir de {1}",
|
"UserOnlineFromDevice": "{0} está online em {1}",
|
||||||
"UserOfflineFromDevice": "{0} desconectou-se a partir de {1}",
|
"UserOfflineFromDevice": "{0} desligou-se de {1}",
|
||||||
"UserLockedOutWithName": "O usuário {0} foi bloqueado",
|
"UserLockedOutWithName": "Utilizador {0} bloqueado",
|
||||||
"UserDeletedWithName": "O usuário {0} foi removido",
|
"UserDeletedWithName": "Utilizador {0} eliminado",
|
||||||
"UserCreatedWithName": "O usuário {0} foi criado",
|
"UserCreatedWithName": "Utilizador {0} criado",
|
||||||
"TvShows": "Séries",
|
"TvShows": "Séries de TV",
|
||||||
"SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}",
|
"SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}",
|
||||||
"StartupEmbyServerIsLoading": "O servidor Jellyfin está iniciando. Tente novamente dentro de momentos.",
|
"StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.",
|
||||||
"ScheduledTaskFailedWithName": "{0} falhou",
|
"ScheduledTaskFailedWithName": "{0} falhou",
|
||||||
"PluginUpdatedWithName": "{0} foi atualizado",
|
"PluginUpdatedWithName": "{0} foi atualizado",
|
||||||
"PluginUninstalledWithName": "{0} foi desinstalado",
|
"PluginUninstalledWithName": "{0} foi desinstalado",
|
||||||
"PluginInstalledWithName": "{0} foi instalado",
|
"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",
|
"NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada",
|
||||||
"NotificationOptionUserLockedOut": "Usuário bloqueado",
|
"NotificationOptionUserLockedOut": "Utilizador bloqueado",
|
||||||
"NotificationOptionTaskFailed": "Falha na tarefa agendada",
|
"NotificationOptionTaskFailed": "Falha em tarefa agendada",
|
||||||
"NotificationOptionServerRestartRequired": "É necessário reiniciar o servidor",
|
"NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor",
|
||||||
"NotificationOptionPluginUpdateInstalled": "Plugin atualizado",
|
"NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada",
|
||||||
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
|
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
|
||||||
"NotificationOptionPluginInstalled": "Plugin instalado",
|
"NotificationOptionPluginInstalled": "Plugin instalado",
|
||||||
"NotificationOptionPluginError": "Falha no plugin",
|
"NotificationOptionPluginError": "Falha no plugin",
|
||||||
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
|
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
|
||||||
"NotificationOptionInstallationFailed": "Falha de instalação",
|
"NotificationOptionInstallationFailed": "Falha na instalação",
|
||||||
"NotificationOptionCameraImageUploaded": "Imagem de câmera enviada",
|
"NotificationOptionCameraImageUploaded": "Imagem da câmara enviada",
|
||||||
"NotificationOptionAudioPlaybackStopped": "Reprodução Parada",
|
"NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida",
|
||||||
"NotificationOptionAudioPlayback": "Reprodução Iniciada",
|
"NotificationOptionAudioPlayback": "Reprodução de áudio iniciada",
|
||||||
"NotificationOptionApplicationUpdateInstalled": "A atualização do aplicativo foi instalada",
|
"NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada",
|
||||||
"NotificationOptionApplicationUpdateAvailable": "Uma atualização do aplicativo está disponível",
|
"NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível",
|
||||||
"NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para download.",
|
"NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.",
|
||||||
"NameSeasonUnknown": "Temporada Desconhecida",
|
"NameSeasonUnknown": "Temporada desconhecida",
|
||||||
"NameSeasonNumber": "Temporada {0}",
|
"NameSeasonNumber": "Temporada {0}",
|
||||||
"NameInstallFailed": "Falha na instalação de {0}",
|
"NameInstallFailed": "Falha na instalação de {0}",
|
||||||
"MusicVideos": "Videoclipes",
|
"MusicVideos": "Videoclipes",
|
||||||
"Music": "Música",
|
"Music": "Música",
|
||||||
"MixedContent": "Conteúdo diverso",
|
"MixedContent": "Conteúdo misto",
|
||||||
"Latest": "Mais Recente",
|
"Latest": "Mais recente",
|
||||||
"LabelRunningTimeValue": "Duração: {0}",
|
"LabelRunningTimeValue": "Duração: {0}",
|
||||||
"LabelIpAddressValue": "Endereço de IP: {0}",
|
"LabelIpAddressValue": "Endereço IP: {0}",
|
||||||
"Inherit": "Herdar",
|
"Inherit": "Herdar",
|
||||||
"HomeVideos": "Vídeos Caseiros",
|
"HomeVideos": "Vídeos caseiros",
|
||||||
"Shows": "Séries",
|
"Shows": "Séries",
|
||||||
"Photos": "Fotografias",
|
"Photos": "Fotografias",
|
||||||
"Movies": "Filmes",
|
"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}",
|
"ChapterNameValue": "Capítulo {0}",
|
||||||
"AuthenticationSucceededWithUserName": "{0} autenticado com sucesso",
|
"AuthenticationSucceededWithUserName": "{0} autenticado com sucesso",
|
||||||
"AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}",
|
"AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}",
|
||||||
"TaskCleanCache": "Limpar Diretório de Cache",
|
"TaskCleanCache": "Limpar pasta de cache",
|
||||||
"TasksApplicationCategory": "Aplicação",
|
"TasksApplicationCategory": "Aplicação",
|
||||||
"TasksLibraryCategory": "Mediateca",
|
"TasksLibraryCategory": "Biblioteca",
|
||||||
"TasksMaintenanceCategory": "Manutenção",
|
"TasksMaintenanceCategory": "Manutenção",
|
||||||
"TaskRefreshChannels": "Atualizar Canais",
|
"TaskRefreshChannels": "Atualizar canais",
|
||||||
"TaskUpdatePlugins": "Atualizar Plugins",
|
"TaskUpdatePlugins": "Atualizar plugins",
|
||||||
"TaskCleanLogsDescription": "Deletar arquivos de log que existe a mais de {0} dias.",
|
"TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.",
|
||||||
"TaskCleanLogs": "Limpar diretório de logs",
|
"TaskCleanLogs": "Limpar pasta de registos",
|
||||||
"TaskRefreshLibrary": "Analisar mediateca",
|
"TaskRefreshLibrary": "Analisar biblioteca multimédia",
|
||||||
"TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.",
|
"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.",
|
"TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.",
|
||||||
"TasksChannelsCategory": "Canais de Internet",
|
"TasksChannelsCategory": "Canais da Internet",
|
||||||
"TaskRefreshChapterImages": "Extrair Imagens do Capítulo",
|
"TaskRefreshChapterImages": "Extrair imagens dos capítulos",
|
||||||
"TaskDownloadMissingSubtitlesDescription": "Pesquisa na Internet as legendas em falta com base na configuração de metadados.",
|
"TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.",
|
||||||
"TaskDownloadMissingSubtitles": "Transferir legendas em falta",
|
"TaskDownloadMissingSubtitles": "Transferir legendas em falta",
|
||||||
"TaskRefreshChannelsDescription": "Atualiza as informações do canal da Internet.",
|
"TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.",
|
||||||
"TaskCleanTranscodeDescription": "Apagar os ficheiros com mais de um dia, de Transcode.",
|
"TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.",
|
||||||
"TaskCleanTranscode": "Limpar o diretório de Transcode",
|
"TaskCleanTranscode": "Limpar pasta de transcodificação",
|
||||||
"TaskUpdatePluginsDescription": "Baixa e instala as atualizações para plug-ins configurados para atualização automática.",
|
"TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.",
|
||||||
"TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.",
|
"TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.",
|
||||||
"TaskRefreshPeople": "Atualizar pessoas",
|
"TaskRefreshPeople": "Atualizar pessoas",
|
||||||
"TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.",
|
"TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.",
|
||||||
"TaskCleanActivityLog": "Limpar registro de atividade",
|
"TaskCleanActivityLog": "Limpar registo de atividade",
|
||||||
"Undefined": "Indefinido",
|
"Undefined": "Indefinido",
|
||||||
"Forced": "Forçado",
|
"Forced": "Forçado",
|
||||||
"Default": "Predefinição",
|
"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",
|
"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",
|
"External": "Externo",
|
||||||
"HearingImpaired": "Problemas auditivos",
|
"HearingImpaired": "Deficiência auditiva",
|
||||||
"TaskKeyframeExtractor": "Extrator de quadro-chave",
|
"TaskKeyframeExtractor": "Extrator de fotogramas-chave",
|
||||||
"TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.",
|
"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",
|
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
|
||||||
"TaskRefreshTrickplayImagesDescription": "Cria miniaturas de pré-visualização (Trickplay) para vídeos nas bibliotecas ativadas.",
|
"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.",
|
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
|
||||||
"TaskAudioNormalization": "Normalização de áudio",
|
"TaskAudioNormalization": "Normalização de áudio",
|
||||||
"TaskDownloadMissingLyrics": "Transferir letra em falta",
|
"TaskDownloadMissingLyrics": "Transferir letras em falta",
|
||||||
"TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
|
"TaskDownloadMissingLyricsDescription": "Transfere letras de músicas",
|
||||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros Trickplay existentes de acordo com as definições da mediateca.",
|
"TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.",
|
||||||
"TaskExtractMediaSegments": "Analisar segmentos de multimédia",
|
"TaskExtractMediaSegments": "Analisar segmentos multimédia",
|
||||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
|
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.",
|
||||||
"TaskMoveTrickplayImages": "Migrar a localização das imagens de Trickplay",
|
"TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay",
|
||||||
"CleanupUserDataTask": "Task de limpeza de dados do usuário",
|
"CleanupUserDataTask": "Limpeza de dados de utilizador",
|
||||||
"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.",
|
"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",
|
"Original": "Original",
|
||||||
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}",
|
"LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}",
|
||||||
"NameExtraBehindTheScenes": "Bastidores",
|
"NameExtraBehindTheScenes": "Bastidores",
|
||||||
"NameExtraClip": "Clip",
|
"NameExtraClip": "Excerto",
|
||||||
"NameExtraDeletedScene": "Cena Eliminada",
|
"NameExtraDeletedScene": "Cena eliminada",
|
||||||
"NameExtraFeaturette": "Média-metragem",
|
"NameExtraFeaturette": "Minidocumentário",
|
||||||
"NameExtraInterview": "Entrevista",
|
"NameExtraInterview": "Entrevista",
|
||||||
"NameExtraSample": "Amostra",
|
"NameExtraSample": "Amostra",
|
||||||
"NameExtraShort": "Curta-metragem",
|
"NameExtraShort": "Curta-metragem",
|
||||||
"NameExtraThemeSong": "Tema Principal",
|
"NameExtraThemeSong": "Tema musical",
|
||||||
"NameExtraThemeVideo": "Vídeo de Abertura",
|
"NameExtraThemeVideo": "Vídeo temático",
|
||||||
"NameExtraScene": "Cena",
|
"NameExtraScene": "Cena",
|
||||||
"NameExtraUnknown": "Extra",
|
"NameExtraUnknown": "Extra",
|
||||||
"NameExtraTrailer": "Trailer"
|
"NameExtraTrailer": "Trailer",
|
||||||
|
"NameExtraNumbered": "{0} {1}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,17 @@
|
|||||||
"CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului",
|
"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.",
|
"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}",
|
"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 дней.",
|
"CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.",
|
||||||
"Original": "Оригинальный",
|
"Original": "Оригинальный",
|
||||||
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}",
|
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}",
|
||||||
"NameExtraBehindTheScenes": "За кулисами",
|
"NameExtraBehindTheScenes": "За кадром",
|
||||||
"NameExtraClip": "Отрывок",
|
"NameExtraClip": "Отрывок",
|
||||||
"NameExtraDeletedScene": "Удалённая сцена",
|
"NameExtraDeletedScene": "Удалённая сцена",
|
||||||
"NameExtraFeaturette": "Короткометражка",
|
"NameExtraFeaturette": "Короткометражка",
|
||||||
@@ -119,5 +119,7 @@
|
|||||||
"NameExtraThemeSong": "Заглавная песня",
|
"NameExtraThemeSong": "Заглавная песня",
|
||||||
"NameExtraThemeVideo": "Заглавное видео",
|
"NameExtraThemeVideo": "Заглавное видео",
|
||||||
"NameExtraTrailer": "Трейлер",
|
"NameExtraTrailer": "Трейлер",
|
||||||
"NameExtraUnknown": "Дополнительный материал"
|
"NameExtraUnknown": "Дополнительный материал",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraShort": "Короткометражка"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
"Default": "Predvolené",
|
"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.",
|
"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",
|
"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",
|
"TaskKeyframeExtractor": "Extraktor kľúčových snímkov",
|
||||||
"External": "Externé",
|
"External": "Externé",
|
||||||
"HearingImpaired": "Sluchovo postihnutí",
|
"HearingImpaired": "Sluchovo postihnutí",
|
||||||
|
|||||||
@@ -108,5 +108,17 @@
|
|||||||
"CleanupUserDataTask": "Čiščenje uporabniških podatkov",
|
"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.",
|
"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}",
|
"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ë.",
|
"CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.",
|
||||||
"CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve",
|
"CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve",
|
||||||
"LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}",
|
"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": "Промени локацију сличица за визуелно премотавање",
|
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
|
||||||
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
|
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
|
||||||
"LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
|
"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",
|
"NameExtraScene": "Scen",
|
||||||
"NameExtraShort": "Kortfilm",
|
"NameExtraShort": "Kortfilm",
|
||||||
"NameExtraThemeSong": "Signaturmelodi",
|
"NameExtraThemeSong": "Signaturmelodi",
|
||||||
"NameExtraTrailer": "Trailer"
|
"NameExtraTrailer": "Trailer",
|
||||||
|
"NameExtraClip": "Klipp",
|
||||||
|
"NameExtraFeaturette": "Kortfilm",
|
||||||
|
"NameExtraSample": "Prov",
|
||||||
|
"NameExtraThemeVideo": "Signaturvideo",
|
||||||
|
"NameExtraUnknown": "Extra"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,5 +106,20 @@
|
|||||||
"TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்",
|
"TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்",
|
||||||
"TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.",
|
"TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.",
|
||||||
"CleanupUserDataTaskDescription": "குறைந்தது 90 நாட்களுக்கு இல்லாத மீடியாவிலிருந்து அனைத்து பயனர் தரவையும் (கண்காணிப்பு நிலை, பிடித்த நிலை போன்றவை) சுத்தம் செய்கிறது.",
|
"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 พร้อมให้ดาวน์โหลดแล้ว",
|
"NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว",
|
||||||
"NameSeasonUnknown": "ไม่ทราบซีซัน",
|
"NameSeasonUnknown": "ไม่ทราบซีซัน",
|
||||||
"NameSeasonNumber": "ซีซัน {0}",
|
"NameSeasonNumber": "ซีซัน {0}",
|
||||||
"NameInstallFailed": "การติดตั้ง {0} ล้มเหลว",
|
"NameInstallFailed": "ติดตั้ง {0} ไม่สำเร็จ",
|
||||||
"MusicVideos": "มิวสิควิดีโอ",
|
"MusicVideos": "มิวสิควิดีโอ",
|
||||||
"Music": "ดนตรี",
|
"Music": "เพลง",
|
||||||
"Movies": "ภาพยนตร์",
|
"Movies": "ภาพยนตร์",
|
||||||
"MixedContent": "เนื้อหาผสม",
|
"MixedContent": "เนื้อหาหลากหลายประเภท",
|
||||||
"Latest": "ล่าสุด",
|
"Latest": "มาใหม่ล่าสุด",
|
||||||
"LabelRunningTimeValue": "ผ่านไปแล้ว: {0}",
|
"LabelRunningTimeValue": "ความยาว: {0}",
|
||||||
"LabelIpAddressValue": "ที่อยู่ IP: {0}",
|
"LabelIpAddressValue": "หมายเลข IP: {0}",
|
||||||
"Inherit": "สืบทอด",
|
"Inherit": "ใช้ค่าเริ่มต้น",
|
||||||
"HomeVideos": "โฮมวิดีโอ",
|
"HomeVideos": "วิดีโอส่วนตัว",
|
||||||
"HeaderNextUp": "ถัดไป",
|
"HeaderNextUp": "รายการถัดไป",
|
||||||
"HeaderLiveTV": "ทีวีสด",
|
"HeaderLiveTV": "ทีวีถ่ายทอดสด",
|
||||||
"HeaderFavoriteShows": "รายการที่ชื่นชอบ",
|
"HeaderFavoriteShows": "รายการที่ชอบ",
|
||||||
"HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ",
|
"HeaderFavoriteEpisodes": "ตอนที่ชอบ",
|
||||||
"HeaderContinueWatching": "ดูต่อ",
|
"HeaderContinueWatching": "ดูต่อ",
|
||||||
"Genres": "ประเภท",
|
"Genres": "ประเภท",
|
||||||
"Folders": "โฟลเดอร์",
|
"Folders": "โฟลเดอร์",
|
||||||
@@ -107,6 +107,19 @@
|
|||||||
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
|
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
|
||||||
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
|
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
|
||||||
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน",
|
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน",
|
||||||
"LyricDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1}",
|
"LyricDownloadFailureFromForItem": "ดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1} ไม่สำเร็จ",
|
||||||
"Original": "ต้นฉบับ"
|
"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",
|
"Artists": "Sanatçılar",
|
||||||
"AuthenticationSucceededWithUserName": "{0} kimliği başarıyla doğrulandı",
|
"AuthenticationSucceededWithUserName": "{0} kimliği başarıyla doğrulandı",
|
||||||
"Books": "Kitaplar",
|
"Books": "Kitaplar",
|
||||||
|
|||||||
@@ -115,11 +115,11 @@
|
|||||||
"NameExtraFeaturette": "花絮",
|
"NameExtraFeaturette": "花絮",
|
||||||
"NameExtraInterview": "采访",
|
"NameExtraInterview": "采访",
|
||||||
"NameExtraNumbered": "{0} {1}",
|
"NameExtraNumbered": "{0} {1}",
|
||||||
"NameExtraSample": "样本",
|
"NameExtraSample": "试看",
|
||||||
"NameExtraScene": "场景",
|
"NameExtraScene": "精选片段",
|
||||||
"NameExtraShort": "短片",
|
"NameExtraShort": "短篇",
|
||||||
"NameExtraThemeSong": "主题曲",
|
"NameExtraThemeSong": "主题曲",
|
||||||
"NameExtraThemeVideo": "主题视频",
|
"NameExtraThemeVideo": "主题视频",
|
||||||
"NameExtraTrailer": "预告片",
|
"NameExtraTrailer": "预告片",
|
||||||
"NameExtraUnknown": "额外"
|
"NameExtraUnknown": "额外内容"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
"Folders": "資料夾",
|
"Folders": "資料夾",
|
||||||
"Genres": "風格",
|
"Genres": "風格",
|
||||||
"HeaderContinueWatching": "繼續睇返",
|
"HeaderContinueWatching": "繼續睇返",
|
||||||
"HeaderFavoriteEpisodes": "心水嘅劇集",
|
"HeaderFavoriteEpisodes": "心水劇集",
|
||||||
"HeaderFavoriteShows": "心水嘅節目",
|
"HeaderFavoriteShows": "心水節目",
|
||||||
"HeaderLiveTV": "電視直播",
|
"HeaderLiveTV": "電視直播",
|
||||||
"HeaderNextUp": "跟住落嚟",
|
"HeaderNextUp": "跟住落嚟",
|
||||||
"HomeVideos": "家庭影片",
|
"HomeVideos": "家庭影片",
|
||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTask": "清理使用者資料嘅任務",
|
"CleanupUserDataTask": "清理使用者資料嘅任務",
|
||||||
"CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。",
|
"CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。",
|
||||||
"LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞",
|
"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": "用戶資料清理工作",
|
"CleanupUserDataTask": "用戶資料清理工作",
|
||||||
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
|
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
|
||||||
"Original": "原作",
|
"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);
|
var url = new Uri(packageInfo.ImageUrl);
|
||||||
imagePath = Path.Join(path, url.Segments[^1]);
|
imagePath = Path.Join(path, url.Segments[^1]);
|
||||||
|
|
||||||
var fileStream = AsyncFile.OpenWrite(imagePath);
|
// The catalog is refreshed on every dashboard visit and rewrites the manifest of
|
||||||
Stream? downloadStream = null;
|
// every installed plugin, so only fetch an image that is actually missing.
|
||||||
try
|
if (!ImageExists(imagePath))
|
||||||
{
|
{
|
||||||
downloadStream = await HttpClientFactory
|
imagePath = await DownloadImage(url, imagePath).ConfigureAwait(false);
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
/// <summary>
|
||||||
/// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path.
|
/// 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.
|
/// 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 Jellyfin.Extensions.Json;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Extensions;
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller.ScheduledTasks;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
|||||||
private readonly IApplicationPaths _applicationPaths;
|
private readonly IApplicationPaths _applicationPaths;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly ITaskManager _taskManager;
|
private readonly ITaskManager _taskManager;
|
||||||
|
private readonly IScanLeaderLease _scanLeaderLease;
|
||||||
|
private readonly ScanLeaderOptions _scanLeaderOptions;
|
||||||
private readonly Lock _lastExecutionResultSyncLock = new();
|
private readonly Lock _lastExecutionResultSyncLock = new();
|
||||||
private bool _readFromFile;
|
private bool _readFromFile;
|
||||||
private TaskResult _lastExecutionResult;
|
private TaskResult _lastExecutionResult;
|
||||||
@@ -41,6 +44,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
|||||||
/// <param name="applicationPaths">The application paths.</param>
|
/// <param name="applicationPaths">The application paths.</param>
|
||||||
/// <param name="taskManager">The task manager.</param>
|
/// <param name="taskManager">The task manager.</param>
|
||||||
/// <param name="logger">The logger.</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">
|
/// <exception cref="ArgumentNullException">
|
||||||
/// scheduledTask
|
/// scheduledTask
|
||||||
/// or
|
/// or
|
||||||
@@ -52,7 +57,13 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
|||||||
/// or
|
/// or
|
||||||
/// logger.
|
/// logger.
|
||||||
/// </exception>
|
/// </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(scheduledTask);
|
||||||
ArgumentNullException.ThrowIfNull(applicationPaths);
|
ArgumentNullException.ThrowIfNull(applicationPaths);
|
||||||
@@ -63,6 +74,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
|||||||
_applicationPaths = applicationPaths;
|
_applicationPaths = applicationPaths;
|
||||||
_taskManager = taskManager;
|
_taskManager = taskManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_scanLeaderLease = scanLeaderLease;
|
||||||
|
_scanLeaderOptions = scanLeaderOptions;
|
||||||
|
|
||||||
InitTriggerEvents();
|
InitTriggerEvents();
|
||||||
}
|
}
|
||||||
@@ -268,6 +281,20 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
|||||||
|
|
||||||
trigger.Stop();
|
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);
|
_taskManager.QueueScheduledTask(ScheduledTask, trigger.TaskOptions);
|
||||||
|
|
||||||
await Task.Delay(1000).ConfigureAwait(false);
|
await Task.Delay(1000).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data.Events;
|
using Jellyfin.Data.Events;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.ScheduledTasks;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.ScheduledTasks;
|
namespace Emby.Server.Implementations.ScheduledTasks;
|
||||||
|
|
||||||
@@ -23,18 +25,26 @@ public class TaskManager : ITaskManager
|
|||||||
|
|
||||||
private readonly IApplicationPaths _applicationPaths;
|
private readonly IApplicationPaths _applicationPaths;
|
||||||
private readonly ILogger<TaskManager> _logger;
|
private readonly ILogger<TaskManager> _logger;
|
||||||
|
private readonly IScanLeaderLease? _scanLeaderLease;
|
||||||
|
private readonly ScanLeaderOptions? _scanLeaderOptions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="TaskManager" /> class.
|
/// Initializes a new instance of the <see cref="TaskManager" /> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="applicationPaths">The application paths.</param>
|
/// <param name="applicationPaths">The application paths.</param>
|
||||||
/// <param name="logger">The logger.</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(
|
public TaskManager(
|
||||||
IApplicationPaths applicationPaths,
|
IApplicationPaths applicationPaths,
|
||||||
ILogger<TaskManager> logger)
|
ILogger<TaskManager> logger,
|
||||||
|
IScanLeaderLease? scanLeaderLease = null,
|
||||||
|
IOptions<ScanLeaderOptions>? scanLeaderOptions = null)
|
||||||
{
|
{
|
||||||
_applicationPaths = applicationPaths;
|
_applicationPaths = applicationPaths;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_scanLeaderLease = scanLeaderLease;
|
||||||
|
_scanLeaderOptions = scanLeaderOptions?.Value;
|
||||||
|
|
||||||
ScheduledTasks = [];
|
ScheduledTasks = [];
|
||||||
}
|
}
|
||||||
@@ -175,7 +185,7 @@ public class TaskManager : ITaskManager
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void AddTasks(IEnumerable<IScheduledTask> tasks)
|
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();
|
ScheduledTasks = ScheduledTasks.Concat(list).ToArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||||||
if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol)
|
if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol)
|
||||||
{
|
{
|
||||||
t.LUFS = await CalculateLUFSAsync(
|
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,
|
false,
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
toSaveDbItems.Add(t);
|
toSaveDbItems.Add(t);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
using MediaBrowser.Model.Globalization;
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
@@ -21,6 +22,7 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
private readonly IConfigurationManager _configurationManager;
|
private readonly IConfigurationManager _configurationManager;
|
||||||
private readonly IFileSystem _fileSystem;
|
private readonly IFileSystem _fileSystem;
|
||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
|
private readonly ITranscodeSessionStore _sessionStore;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
|
/// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
|
||||||
@@ -29,16 +31,19 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||||
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
|
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
|
||||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||||
|
/// <param name="sessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface.</param>
|
||||||
public DeleteTranscodeFileTask(
|
public DeleteTranscodeFileTask(
|
||||||
ILogger<DeleteTranscodeFileTask> logger,
|
ILogger<DeleteTranscodeFileTask> logger,
|
||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
IConfigurationManager configurationManager,
|
IConfigurationManager configurationManager,
|
||||||
ILocalizationManager localization)
|
ILocalizationManager localization,
|
||||||
|
ITranscodeSessionStore sessionStore)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
_configurationManager = configurationManager;
|
_configurationManager = configurationManager;
|
||||||
_localization = localization;
|
_localization = localization;
|
||||||
|
_sessionStore = sessionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -78,25 +83,39 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var minDateModified = DateTime.UtcNow.AddDays(-1);
|
var minDateModified = DateTime.UtcNow.AddDays(-1);
|
||||||
progress.Report(50);
|
progress.Report(50);
|
||||||
|
|
||||||
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, progress, cancellationToken);
|
IEnumerable<TranscodeSession> activeSessions;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
activeSessions = await _sessionStore.GetActiveSessionsAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to retrieve active transcode sessions. Skipping deletion to avoid removing files in use.");
|
||||||
|
progress.Report(100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, activeSessions, progress, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes the transcoded temp files from directory with a last write time less than a given date.
|
/// Deletes the transcoded temp files from directory with a last write time less than a given date,
|
||||||
|
/// skipping any files that belong to an active transcode session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="directory">The directory.</param>
|
/// <param name="directory">The directory.</param>
|
||||||
/// <param name="minDateModified">The min date modified.</param>
|
/// <param name="minDateModified">The min date modified.</param>
|
||||||
|
/// <param name="activeSessions">The currently active transcode sessions.</param>
|
||||||
/// <param name="progress">The progress.</param>
|
/// <param name="progress">The progress.</param>
|
||||||
/// <param name="cancellationToken">The task cancellation token.</param>
|
/// <param name="cancellationToken">The task cancellation token.</param>
|
||||||
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
|
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IEnumerable<TranscodeSession> activeSessions, IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var activeSessionList = activeSessions.ToList();
|
||||||
|
|
||||||
var filesToDelete = _fileSystem.GetFiles(directory, true)
|
var filesToDelete = _fileSystem.GetFiles(directory, true)
|
||||||
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
|
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -112,6 +131,13 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (IsFileProtectedByActiveSession(file.FullName, activeSessionList))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping deletion of {FilePath} as it belongs to an active transcode session.", file.FullName);
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
|
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
|
||||||
|
|
||||||
index++;
|
index++;
|
||||||
@@ -121,4 +147,24 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsFileProtectedByActiveSession(string filePath, IList<TranscodeSession> activeSessions)
|
||||||
|
{
|
||||||
|
foreach (var session in activeSessions)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(session.ManifestPath) &&
|
||||||
|
string.Equals(filePath, session.ManifestPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(session.SegmentPathPrefix) &&
|
||||||
|
filePath.StartsWith(session.SegmentPathPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
|
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optimizes Jellyfin's database by issuing a VACUUM command.
|
/// Optimizes Jellyfin's database by issuing VACUUM and ANALYZE commands.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
||||||
{
|
{
|
||||||
@@ -82,7 +82,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
|
_logger.LogInformation("Vacuuming and analyzing jellyfin.db...");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Emby.Server.Implementations.Library.Validators;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
@@ -29,6 +30,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||||
private readonly IFileSystem _fileSystem;
|
private readonly IFileSystem _fileSystem;
|
||||||
private readonly ILogger<PeopleValidationTask> _logger;
|
private readonly ILogger<PeopleValidationTask> _logger;
|
||||||
|
private readonly ILogger<PeopleValidator> _validatorLogger;
|
||||||
private readonly IItemTypeLookup _itemTypeLookup;
|
private readonly IItemTypeLookup _itemTypeLookup;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -39,6 +41,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
|
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
|
||||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||||
/// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> 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>
|
/// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
|
||||||
public PeopleValidationTask(
|
public PeopleValidationTask(
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
@@ -46,6 +49,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
ILogger<PeopleValidationTask> logger,
|
ILogger<PeopleValidationTask> logger,
|
||||||
|
ILogger<PeopleValidator> validatorLogger,
|
||||||
IItemTypeLookup itemTypeLookup)
|
IItemTypeLookup itemTypeLookup)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
@@ -53,6 +57,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
_dbContextFactory = dbContextFactory;
|
_dbContextFactory = dbContextFactory;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_validatorLogger = validatorLogger;
|
||||||
_itemTypeLookup = itemTypeLookup;
|
_itemTypeLookup = itemTypeLookup;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +114,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
var dupQuery = context.Peoples
|
var dupQuery = context.Peoples
|
||||||
.GroupBy(e => new { e.Name, e.PersonType })
|
.GroupBy(e => new { e.Name, e.PersonType })
|
||||||
.Where(e => e.Count() > 1)
|
.Where(e => e.Count() > 1)
|
||||||
|
.OrderBy(e => e.Key.Name)
|
||||||
|
.ThenBy(e => e.Key.PersonType)
|
||||||
.Select(e => e.Select(f => f.Id).ToArray());
|
.Select(e => e.Select(f => f.Id).ToArray());
|
||||||
|
|
||||||
var total = dupQuery.Count();
|
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
|
// 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.
|
// 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));
|
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%)
|
// Phase 3: Refresh images for people missing them (66-100%)
|
||||||
IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66));
|
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 thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
|
||||||
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
||||||
|
|
||||||
|
List<Guid> peopleIds;
|
||||||
|
|
||||||
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await using (context.ConfigureAwait(false))
|
await using (context.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
const int PartitionSize = 100;
|
// 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.
|
||||||
var numPeople = await context.BaseItems
|
peopleIds = await context.BaseItems
|
||||||
.AsNoTracking()
|
|
||||||
.Where(b => b.Type == personTypeName)
|
|
||||||
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
|
|
||||||
.Where(b =>
|
|
||||||
!b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
|
|
||||||
string.IsNullOrEmpty(b.Overview))
|
|
||||||
.CountAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
_logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople);
|
|
||||||
|
|
||||||
if (numPeople == 0)
|
|
||||||
{
|
|
||||||
progress.Report(100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var numComplete = 0;
|
|
||||||
var numRefreshed = 0;
|
|
||||||
|
|
||||||
await foreach (var entry in context.BaseItems
|
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(b => b.Type == personTypeName)
|
.Where(b => b.Type == personTypeName)
|
||||||
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
|
.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) ||
|
!b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
|
||||||
string.IsNullOrEmpty(b.Overview))
|
string.IsNullOrEmpty(b.Overview))
|
||||||
.OrderBy(b => b.Id)
|
.OrderBy(b => b.Id)
|
||||||
.WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition))
|
.Select(b => b.Id)
|
||||||
.PartitionEagerAsync(PartitionSize, cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
.WithCancellation(cancellationToken)
|
.ConfigureAwait(false);
|
||||||
.ConfigureAwait(false))
|
}
|
||||||
{
|
|
||||||
if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
numRefreshed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
numComplete++;
|
_logger.LogDebug("Found {Count} people needing image/overview refresh", peopleIds.Count);
|
||||||
progress.Report(100.0 * numComplete / numPeople);
|
|
||||||
|
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)
|
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))
|
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||||
{
|
{
|
||||||
ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default,
|
ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh,
|
||||||
MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default
|
MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh
|
||||||
};
|
};
|
||||||
|
|
||||||
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
|
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error updating {Name}", package.Name);
|
_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)
|
catch (InvalidDataException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error updating {Name}", package.Name);
|
_logger.LogError(ex, "Error updating {Name}", package.Name);
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||||
|
|
||||||
var activityDate = DateTime.UtcNow;
|
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;
|
var lastActivityDate = session.LastActivityDate;
|
||||||
session.LastActivityDate = activityDate;
|
session.LastActivityDate = activityDate;
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
{
|
{
|
||||||
if (!session.SessionControllers.Any(i => i.IsSessionActive))
|
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 _);
|
_activeConnections.TryRemove(key, out _);
|
||||||
if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId))
|
if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId))
|
||||||
@@ -369,7 +369,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
|
|
||||||
if (session is not null)
|
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 _);
|
_activeConnections.TryRemove(key, out _);
|
||||||
|
|
||||||
@@ -475,8 +475,11 @@ namespace Emby.Server.Implementations.Session
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetSessionKey(string appName, string deviceId)
|
// The user is part of the key because the client name and the device id are taken from the
|
||||||
=> appName + deviceId;
|
// 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>
|
/// <summary>
|
||||||
/// Gets the connection.
|
/// Gets the connection.
|
||||||
@@ -488,7 +491,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
/// <param name="remoteEndPoint">The remote end point.</param>
|
/// <param name="remoteEndPoint">The remote end point.</param>
|
||||||
/// <param name="user">The user.</param>
|
/// <param name="user">The user.</param>
|
||||||
/// <returns>SessionInfo.</returns>
|
/// <returns>SessionInfo.</returns>
|
||||||
private SessionInfo GetSessionInfo(
|
private async Task<SessionInfo> GetSessionInfo(
|
||||||
string appName,
|
string appName,
|
||||||
string appVersion,
|
string appVersion,
|
||||||
string deviceId,
|
string deviceId,
|
||||||
@@ -500,8 +503,8 @@ namespace Emby.Server.Implementations.Session
|
|||||||
|
|
||||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||||
|
|
||||||
var key = GetSessionKey(appName, deviceId);
|
var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty);
|
||||||
SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
|
SessionInfo newSession = await CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
|
||||||
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
|
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
|
||||||
if (ReferenceEquals(newSession, sessionInfo))
|
if (ReferenceEquals(newSession, sessionInfo))
|
||||||
{
|
{
|
||||||
@@ -529,7 +532,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
return sessionInfo;
|
return sessionInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SessionInfo CreateSessionInfo(
|
private async Task<SessionInfo> CreateSessionInfo(
|
||||||
string key,
|
string key,
|
||||||
string appName,
|
string appName,
|
||||||
string appVersion,
|
string appVersion,
|
||||||
@@ -559,7 +562,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
deviceName = "Network Device";
|
deviceName = "Network Device";
|
||||||
}
|
}
|
||||||
|
|
||||||
var deviceOptions = _deviceManager.GetDeviceOptions(deviceId) ?? new()
|
var deviceOptions = await _deviceManager.GetDeviceOptions(deviceId).ConfigureAwait(false) ?? new()
|
||||||
{
|
{
|
||||||
DeviceId = deviceId
|
DeviceId = deviceId
|
||||||
};
|
};
|
||||||
@@ -655,7 +658,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
ItemId = session.NowPlayingItem is null ? Guid.Empty : session.NowPlayingItem.Id,
|
ItemId = session.NowPlayingItem is null ? Guid.Empty : session.NowPlayingItem.Id,
|
||||||
SessionId = session.Id,
|
SessionId = session.Id,
|
||||||
MediaSourceId = session.PlayState?.MediaSourceId,
|
MediaSourceId = session.PlayState?.MediaSourceId,
|
||||||
PositionTicks = session.PlayState?.PositionTicks
|
PositionTicks = session.LastPlaybackCheckInPositionTicks
|
||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -1537,11 +1540,52 @@ namespace Emby.Server.Implementations.Session
|
|||||||
return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken);
|
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(session);
|
||||||
|
|
||||||
ArgumentNullException.ThrowIfNull(controllingSession);
|
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>
|
/// <summary>
|
||||||
@@ -1559,16 +1603,24 @@ namespace Emby.Server.Implementations.Session
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds the additional user.
|
/// Adds the additional user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
/// <param name="userId">The user 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>
|
/// <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();
|
CheckDisposed();
|
||||||
|
|
||||||
var session = GetSession(sessionId);
|
var session = GetSession(sessionId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||||
|
{
|
||||||
|
var controllingSession = GetSession(controllingSessionId);
|
||||||
|
AssertCanControl(session, controllingSession);
|
||||||
|
AssertCanAttachUser(controllingSession, userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (session.UserId.Equals(userId))
|
if (session.UserId.Equals(userId))
|
||||||
{
|
{
|
||||||
throw new ArgumentException("The requested user is already the primary user of the session.");
|
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)))
|
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
|
var newUser = new SessionUserInfo
|
||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
@@ -1590,16 +1643,22 @@ namespace Emby.Server.Implementations.Session
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes the additional user.
|
/// Removes the additional user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
/// <param name="userId">The user 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>
|
/// <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();
|
CheckDisposed();
|
||||||
|
|
||||||
var session = GetSession(sessionId);
|
var session = GetSession(sessionId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||||
|
{
|
||||||
|
AssertCanControl(session, GetSession(controllingSessionId));
|
||||||
|
}
|
||||||
|
|
||||||
if (session.UserId.Equals(userId))
|
if (session.UserId.Equals(userId))
|
||||||
{
|
{
|
||||||
throw new ArgumentException("The requested user is already the primary user of the session.");
|
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.
|
// This should be validated above, but if it isn't don't delete all tokens.
|
||||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||||
|
|
||||||
var existing = _deviceManager.GetDevices(
|
var existing = (await _deviceManager.GetDevices(
|
||||||
new DeviceQuery
|
new DeviceQuery
|
||||||
{
|
{
|
||||||
DeviceId = deviceId,
|
DeviceId = deviceId,
|
||||||
UserId = user.Id
|
UserId = user.Id
|
||||||
}).Items;
|
}).ConfigureAwait(false)).Items;
|
||||||
|
|
||||||
foreach (var auth in existing)
|
foreach (var auth in existing)
|
||||||
{
|
{
|
||||||
@@ -1742,12 +1801,12 @@ namespace Emby.Server.Implementations.Session
|
|||||||
|
|
||||||
ArgumentException.ThrowIfNullOrEmpty(accessToken);
|
ArgumentException.ThrowIfNullOrEmpty(accessToken);
|
||||||
|
|
||||||
var existing = _deviceManager.GetDevices(
|
var existing = (await _deviceManager.GetDevices(
|
||||||
new DeviceQuery
|
new DeviceQuery
|
||||||
{
|
{
|
||||||
Limit = 1,
|
Limit = 1,
|
||||||
AccessToken = accessToken
|
AccessToken = accessToken
|
||||||
}).Items;
|
}).ConfigureAwait(false)).Items;
|
||||||
|
|
||||||
if (existing.Count > 0)
|
if (existing.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -1786,10 +1845,10 @@ namespace Emby.Server.Implementations.Session
|
|||||||
{
|
{
|
||||||
CheckDisposed();
|
CheckDisposed();
|
||||||
|
|
||||||
var existing = _deviceManager.GetDevices(new DeviceQuery
|
var existing = await _deviceManager.GetDevices(new DeviceQuery
|
||||||
{
|
{
|
||||||
UserId = userId
|
UserId = userId
|
||||||
});
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
foreach (var info in existing.Items)
|
foreach (var info in existing.Items)
|
||||||
{
|
{
|
||||||
@@ -1803,14 +1862,21 @@ namespace Emby.Server.Implementations.Session
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reports the capabilities.
|
/// Reports the capabilities.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
/// <param name="capabilities">The capabilities.</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();
|
CheckDisposed();
|
||||||
|
|
||||||
var session = GetSession(sessionId);
|
var session = GetSession(sessionId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||||
|
{
|
||||||
|
AssertCanControl(session, GetSession(controllingSessionId));
|
||||||
|
}
|
||||||
|
|
||||||
ReportCapabilities(session, capabilities, true);
|
ReportCapabilities(session, capabilities, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1905,13 +1971,18 @@ namespace Emby.Server.Implementations.Session
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void ReportNowViewingItem(string sessionId, string itemId)
|
public void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(itemId);
|
ArgumentException.ThrowIfNullOrEmpty(itemId);
|
||||||
|
|
||||||
var item = _libraryManager.GetItemById(new Guid(itemId));
|
var item = _libraryManager.GetItemById(new Guid(itemId));
|
||||||
var session = GetSession(sessionId);
|
var session = GetSession(sessionId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||||
|
{
|
||||||
|
AssertCanControl(session, GetSession(controllingSessionId));
|
||||||
|
}
|
||||||
|
|
||||||
session.NowViewingItem = GetItemInfo(item, null);
|
session.NowViewingItem = GetItemInfo(item, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1974,11 +2045,11 @@ namespace Emby.Server.Implementations.Session
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<SessionInfo> GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint)
|
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,
|
AccessToken = token,
|
||||||
Limit = 1
|
Limit = 1
|
||||||
}).Items;
|
}).ConfigureAwait(false)).Items;
|
||||||
|
|
||||||
if (items.Count == 0)
|
if (items.Count == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -27,6 +28,12 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the prefetched user data.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The prefetched user data.</value>
|
||||||
|
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the user data manager.
|
/// Gets or sets the user data manager.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -57,7 +64,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private DateTime GetDate(BaseItem x)
|
private DateTime GetDate(BaseItem x)
|
||||||
{
|
{
|
||||||
var userdata = UserDataManager.GetUserData(User, x);
|
var userdata = this.GetUserData(x);
|
||||||
|
|
||||||
if (userdata is not null && userdata.LastPlayedDate.HasValue)
|
if (userdata is not null && userdata.LastPlayedDate.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -35,6 +37,12 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the prefetched user data.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The prefetched user data.</value>
|
||||||
|
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -53,7 +61,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsFavoriteOrLiked(User, userItemData: null) ? 0 : 1;
|
return x.IsFavoriteOrLiked(User, this.GetUserData(x)) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -36,6 +38,12 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the prefetched user data.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The prefetched user data.</value>
|
||||||
|
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -54,7 +62,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsPlayed(User, userItemData: null) ? 0 : 1;
|
return x.IsPlayed(User, this.GetUserData(x)) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -36,6 +38,12 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the prefetched user data.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The prefetched user data.</value>
|
||||||
|
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -54,7 +62,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsUnplayed(User, userItemData: null) ? 0 : 1;
|
return x.IsUnplayed(User, this.GetUserData(x)) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -38,6 +40,12 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the prefetched user data.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The prefetched user data.</value>
|
||||||
|
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -56,7 +64,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
var userdata = UserDataManager.GetUserData(User, x);
|
var userdata = this.GetUserData(x);
|
||||||
|
|
||||||
return userdata is null ? 0 : userdata.PlayCount;
|
return userdata is null ? 0 : userdata.PlayCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,18 @@ namespace Emby.Server.Implementations.SyncPlay
|
|||||||
/// <value>The default ping.</value>
|
/// <value>The default ping.</value>
|
||||||
public long DefaultPing { get; } = 500;
|
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>
|
/// <summary>
|
||||||
/// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
|
/// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -438,7 +450,7 @@ namespace Emby.Server.Implementations.SyncPlay
|
|||||||
{
|
{
|
||||||
if (_participants.TryGetValue(session.Id, out GroupMember value))
|
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);
|
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 />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -181,8 +181,8 @@ namespace Emby.Server.Implementations.SyncPlay
|
|||||||
{
|
{
|
||||||
if (existingGroup.GroupId.Equals(request.GroupId))
|
if (existingGroup.GroupId.Equals(request.GroupId))
|
||||||
{
|
{
|
||||||
// Restore session.
|
// Restore session. The session is already in the group and has already
|
||||||
UpdateSessionsCounter(session.UserId, 1);
|
// been counted, so the counter must not be incremented a second time.
|
||||||
group.SessionJoin(session, request, cancellationToken);
|
group.SessionJoin(session, request, cancellationToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -332,8 +332,11 @@ namespace Emby.Server.Implementations.SyncPlay
|
|||||||
// Group lock required as Group is not thread-safe.
|
// Group lock required as Group is not thread-safe.
|
||||||
lock (group)
|
lock (group)
|
||||||
{
|
{
|
||||||
// Make sure that session still belongs to this group.
|
// Make sure that session still belongs to this group. The lookup can fail
|
||||||
if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId))
|
// 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.
|
// Drop request.
|
||||||
return;
|
return;
|
||||||
@@ -400,7 +403,7 @@ namespace Emby.Server.Implementations.SyncPlay
|
|||||||
// Update sessions counter.
|
// Update sessions counter.
|
||||||
var newSessionsCounter = _activeUsers.AddOrUpdate(
|
var newSessionsCounter = _activeUsers.AddOrUpdate(
|
||||||
userId,
|
userId,
|
||||||
1,
|
toAdd,
|
||||||
(_, sessionsCounter) => sessionsCounter + toAdd);
|
(_, sessionsCounter) => sessionsCounter + toAdd);
|
||||||
|
|
||||||
// Should never happen.
|
// Should never happen.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using MediaBrowser.Controller.Configuration;
|
|||||||
using MediaBrowser.Controller.Dto;
|
using MediaBrowser.Controller.Dto;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Persistence;
|
||||||
using MediaBrowser.Controller.TV;
|
using MediaBrowser.Controller.TV;
|
||||||
using MediaBrowser.Model.Querying;
|
using MediaBrowser.Model.Querying;
|
||||||
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
|
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
|
||||||
@@ -124,53 +125,100 @@ namespace Emby.Server.Implementations.TV
|
|||||||
|
|
||||||
var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching);
|
var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching);
|
||||||
|
|
||||||
var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>();
|
var results = new List<NextUpEpisodeBatchResult>(seriesKeys.Count);
|
||||||
|
|
||||||
foreach (var seriesKey in seriesKeys)
|
foreach (var seriesKey in seriesKeys)
|
||||||
{
|
{
|
||||||
if (!batchResult.TryGetValue(seriesKey, out var result))
|
if (batchResult.TryGetValue(seriesKey, out var result))
|
||||||
{
|
{
|
||||||
continue;
|
results.Add(result);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var nextEpisode = DetermineNextEpisode(result, user, includeSpecials, request.EnableResumable, false);
|
// The selection below tests the played state of every episode it considers, so read the whole
|
||||||
|
// series batch in one query rather than one query per series.
|
||||||
|
var selectionCandidates = new List<BaseItem>();
|
||||||
|
foreach (var result in results)
|
||||||
|
{
|
||||||
|
AddCandidate(selectionCandidates, result.NextUp);
|
||||||
|
AddCandidate(selectionCandidates, result.LastWatched);
|
||||||
|
AddCandidate(selectionCandidates, result.NextPlayedForRewatching);
|
||||||
|
AddCandidate(selectionCandidates, result.LastWatchedForRewatching);
|
||||||
|
|
||||||
|
if (result.Specials is not null)
|
||||||
|
{
|
||||||
|
selectionCandidates.AddRange(result.Specials);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectionUserData = _userDataManager.GetUserDataBatch(selectionCandidates, user);
|
||||||
|
|
||||||
|
var candidates = new List<NextUpCandidate>();
|
||||||
|
foreach (var result in results)
|
||||||
|
{
|
||||||
|
var nextEpisode = SelectNextEpisode(result, user, includeSpecials, includePlayed: false, selectionUserData);
|
||||||
if (nextEpisode is not null)
|
if (nextEpisode is not null)
|
||||||
{
|
{
|
||||||
// The last played date and the version that was actually played live on the version item's user data
|
candidates.Add(new NextUpCandidate(nextEpisode, result.LastWatched, !request.EnableResumable));
|
||||||
// The played state propagated to the sibling versions carries no date
|
|
||||||
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user);
|
|
||||||
nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion);
|
|
||||||
|
|
||||||
DateTime lastWatchedDate = DateTime.MinValue;
|
|
||||||
if (result.LastWatched is not null)
|
|
||||||
{
|
|
||||||
lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextUpList.Add((lastWatchedDate, nextEpisode));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeRewatching)
|
if (includeRewatching)
|
||||||
{
|
{
|
||||||
var nextPlayedEpisode = DetermineNextEpisodeForRewatching(result, user, includeSpecials);
|
var nextPlayedEpisode = SelectNextEpisode(result, user, includeSpecials, includePlayed: true, selectionUserData);
|
||||||
|
|
||||||
if (nextPlayedEpisode is not null)
|
if (nextPlayedEpisode is not null)
|
||||||
{
|
{
|
||||||
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user);
|
// A rewatch suggestion is dropped once it has been resumed, whatever the request asked for.
|
||||||
nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion);
|
candidates.Add(new NextUpCandidate(nextPlayedEpisode, result.LastWatchedForRewatching, true));
|
||||||
|
|
||||||
DateTime rewatchLastWatchedDate = DateTime.MinValue;
|
|
||||||
if (result.LastWatchedForRewatching is not null)
|
|
||||||
{
|
|
||||||
rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The resume progress may live on an alternate version, so read every version in one query.
|
||||||
|
var episodeVersions = new List<BaseItem>();
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (candidate.DropWhenResumed)
|
||||||
|
{
|
||||||
|
candidate.EpisodeVersions = candidate.Episode.GetAllVersions();
|
||||||
|
episodeVersions.AddRange(candidate.EpisodeVersions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodeVersions.Count > 0)
|
||||||
|
{
|
||||||
|
var resumeUserData = _userDataManager.GetUserDataBatch(episodeVersions, user);
|
||||||
|
candidates.RemoveAll(candidate => candidate.EpisodeVersions
|
||||||
|
.Any(version => GetUserData(user, version, resumeUserData)?.PlaybackPositionTicks > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last played date and the version that was actually played live on the version item's user data
|
||||||
|
// The played state propagated to the sibling versions carries no date
|
||||||
|
var lastWatchedVersions = new List<BaseItem>();
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (candidate.LastWatched is Video lastWatchedVideo)
|
||||||
|
{
|
||||||
|
candidate.LastWatchedVersions = lastWatchedVideo.GetAllVersions();
|
||||||
|
lastWatchedVersions.AddRange(candidate.LastWatchedVersions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastWatchedUserData = _userDataManager.GetUserDataBatch(lastWatchedVersions, user);
|
||||||
|
|
||||||
|
var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>(candidates.Count);
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(candidate.LastWatchedVersions, user, lastWatchedUserData);
|
||||||
|
var nextEpisode = GetPreferredVersion(candidate.Episode, candidate.LastWatched, playedVersion);
|
||||||
|
|
||||||
|
DateTime lastWatchedDate = DateTime.MinValue;
|
||||||
|
if (candidate.LastWatched is not null)
|
||||||
|
{
|
||||||
|
lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUpList.Add((lastWatchedDate, nextEpisode));
|
||||||
|
}
|
||||||
|
|
||||||
var sortedEpisodes = nextUpList
|
var sortedEpisodes = nextUpList
|
||||||
.OrderByDescending(x => x.LastWatchedDate)
|
.OrderByDescending(x => x.LastWatchedDate)
|
||||||
.Select(x => (BaseItem)x.Episode);
|
.Select(x => (BaseItem)x.Episode);
|
||||||
@@ -178,12 +226,25 @@ namespace Emby.Server.Implementations.TV
|
|||||||
return GetResult(sortedEpisodes, request);
|
return GetResult(sortedEpisodes, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Episode? DetermineNextEpisode(
|
private static void AddCandidate(List<BaseItem> candidates, BaseItem? item)
|
||||||
MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result,
|
{
|
||||||
|
if (item is not null)
|
||||||
|
{
|
||||||
|
candidates.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserItemData? GetUserData(User user, BaseItem item, IReadOnlyDictionary<Guid, UserItemData> prefetchedUserData)
|
||||||
|
=> prefetchedUserData.TryGetValue(item.Id, out var userData)
|
||||||
|
? userData
|
||||||
|
: _userDataManager.GetUserData(user, item);
|
||||||
|
|
||||||
|
private Episode? SelectNextEpisode(
|
||||||
|
NextUpEpisodeBatchResult result,
|
||||||
User user,
|
User user,
|
||||||
bool includeSpecials,
|
bool includeSpecials,
|
||||||
bool includeResumable,
|
bool includePlayed,
|
||||||
bool includePlayed)
|
IReadOnlyDictionary<Guid, UserItemData> prefetchedUserData)
|
||||||
{
|
{
|
||||||
var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode;
|
var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode;
|
||||||
var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode;
|
var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode;
|
||||||
@@ -217,60 +278,41 @@ namespace Emby.Server.Implementations.TV
|
|||||||
|
|
||||||
if (!includePlayed)
|
if (!includePlayed)
|
||||||
{
|
{
|
||||||
sortedEpisodes = sortedEpisodes.Where(episode => _userDataManager.GetUserData(user, episode) is not { Played: true });
|
sortedEpisodes = sortedEpisodes.Where(episode => GetUserData(user, episode, prefetchedUserData) is not { Played: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
nextEpisode = sortedEpisodes.FirstOrDefault();
|
nextEpisode = sortedEpisodes.FirstOrDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextEpisode is not null && !includeResumable)
|
|
||||||
{
|
|
||||||
// The resume progress may live on an alternate version
|
|
||||||
foreach (var version in nextEpisode.GetAllVersions())
|
|
||||||
{
|
|
||||||
if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextEpisode;
|
return nextEpisode;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Episode? DetermineNextEpisodeForRewatching(
|
|
||||||
MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result,
|
|
||||||
User user,
|
|
||||||
bool includeSpecials)
|
|
||||||
{
|
|
||||||
return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the version of the last watched episode that was actually played, together with its last played date.
|
/// Gets the version of the last watched episode that was actually played, together with its last played date.
|
||||||
/// The version that was played carries the most recent LastPlayedDate.
|
/// The version that was played carries the most recent LastPlayedDate.
|
||||||
/// dates.
|
/// dates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="lastWatched">The last watched episode (any version).</param>
|
/// <param name="versions">The versions of the last watched episode.</param>
|
||||||
/// <param name="user">The user.</param>
|
/// <param name="user">The user.</param>
|
||||||
|
/// <param name="prefetchedUserData">User data read for every version up front.</param>
|
||||||
/// <returns>The played version and its last played date.</returns>
|
/// <returns>The played version and its last played date.</returns>
|
||||||
private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user)
|
private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(
|
||||||
|
IReadOnlyList<Video> versions,
|
||||||
|
User user,
|
||||||
|
IReadOnlyDictionary<Guid, UserItemData> prefetchedUserData)
|
||||||
{
|
{
|
||||||
if (lastWatched is not Video lastWatchedVideo)
|
if (versions.Count == 0)
|
||||||
{
|
{
|
||||||
return (null, null);
|
return (null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
var versions = lastWatchedVideo.GetAllVersions();
|
|
||||||
var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
|
|
||||||
|
|
||||||
var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||||
versions,
|
versions,
|
||||||
version => userDataByVersion.GetValueOrDefault(version.Id),
|
version => GetUserData(user, version, prefetchedUserData),
|
||||||
data => data.LastPlayedDate.HasValue);
|
data => data.LastPlayedDate.HasValue);
|
||||||
|
|
||||||
return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate);
|
return (playedVersion, playedVersion is null ? null : GetUserData(user, playedVersion, prefetchedUserData)?.LastPlayedDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -346,5 +388,28 @@ namespace Emby.Server.Implementations.TV
|
|||||||
totalCount,
|
totalCount,
|
||||||
items.ToArray());
|
items.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An episode picked for Next Up, together with the versions its user data is read from.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class NextUpCandidate
|
||||||
|
{
|
||||||
|
public NextUpCandidate(Episode episode, BaseItem? lastWatched, bool dropWhenResumed)
|
||||||
|
{
|
||||||
|
Episode = episode;
|
||||||
|
LastWatched = lastWatched;
|
||||||
|
DropWhenResumed = dropWhenResumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Episode Episode { get; }
|
||||||
|
|
||||||
|
public BaseItem? LastWatched { get; }
|
||||||
|
|
||||||
|
public bool DropWhenResumed { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Video> EpisodeVersions { get; set; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<Video> LastWatchedVersions { get; set; } = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user