Compare commits
1039 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 | |||
| 6d501ba418 | |||
| 2a8d168796 | |||
| 34cc90f5e2 | |||
| a06b11980e | |||
| 4e9713a032 | |||
| f82101332d | |||
| 7b6ae06f3b | |||
| 26fbd1eac0 | |||
| 35e86416af | |||
| c210ea87f2 | |||
| 4a1fcca182 | |||
| f5211a6722 | |||
| 0b77a520fa | |||
| d0e0e291f2 | |||
| 82ecfde2f7 | |||
| 7be16e9f74 | |||
| f441c5f125 | |||
| 0008bde28e | |||
| e6e3099da9 | |||
| d7dd98858e | |||
| 3d3d3c26b8 | |||
| 5f3e938d09 | |||
| 3031aa91ba | |||
| fb763c47bf | |||
| a65b614449 | |||
| 505a6ebf8f | |||
| 8c4dfc0b71 | |||
| 10d108a1f4 | |||
| 428c0f19bc | |||
| afd91a3d9d | |||
| a0b291a463 | |||
| a9e4c826aa | |||
| b189813eb1 | |||
| 7c20ea2708 | |||
| e63c05137a | |||
| c091ffdc6b | |||
| 70ffd25b50 | |||
| 247ee406a9 | |||
| 2218f2931c | |||
| 6bc1c18004 | |||
| eee818094e | |||
| 0ec7cc5f5b | |||
| 871970120d | |||
| 59bef7940b | |||
| 830d93e446 | |||
| d696cea9bc | |||
| e83125c17c | |||
| c5da7db120 | |||
| f9b7f2edf7 | |||
| 4192a4dd32 | |||
| 84ffe668d0 | |||
| d6da6906a4 | |||
| b7e2d21038 | |||
| e120b7f2dd | |||
| 6c37a6ef8b | |||
| 9162c17834 | |||
| 929834c982 | |||
| 5b3da3bcd7 | |||
| 4860f945a7 | |||
| 9f74fd7354 | |||
| 1c5c95ad1d | |||
| dd81e52f81 | |||
| 868ad089f8 | |||
| 2a08a30982 | |||
| 812c819162 | |||
| 234a49903b | |||
| cd5962df50 | |||
| 9d99164c19 | |||
| 2db10787af | |||
| 614e5c7912 | |||
| 17b0453cfb | |||
| d5a5b56484 | |||
| ef1ce3d6eb | |||
| 07fb350333 | |||
| f49a501f71 | |||
| f6ca06e9bf | |||
| ef84408199 | |||
| 0db3759325 | |||
| df1611cf95 | |||
| a6d09815f2 | |||
| 38aa870514 | |||
| d13c65229d | |||
| 61e75599b3 | |||
| b2485af6e9 | |||
| 4adaf7f146 | |||
| 563a00faaa | |||
| 5e33a2d376 | |||
| d9d9efc96e | |||
| dd1380e562 | |||
| cfb00b58a2 | |||
| 990ab18353 | |||
| 3578e9a332 | |||
| f60cfc788b | |||
| e2586eed9b | |||
| 7fbc1ff8c0 | |||
| 5767a3a800 | |||
| 4b51ef2c2f | |||
| f50fc0fa86 | |||
| a817391f67 | |||
| cc877229b1 | |||
| a3e7024355 | |||
| d3c7aa6bd2 | |||
| 40601523e7 | |||
| e3664a9561 | |||
| a1ed4b9001 | |||
| e09492de62 | |||
| d10381a0e2 | |||
| 9596115c45 | |||
| ed0be243e9 | |||
| f502fd312e | |||
| 9d0dffe818 | |||
| 4e2089b6a1 | |||
| 55518c06ee | |||
| a272efb9a2 | |||
| 33a8cdfc0b | |||
| 38c6c5b478 | |||
| d759df03b5 | |||
| e2c1121079 | |||
| fe3585577b | |||
| b3a0611302 | |||
| c20cc766e1 | |||
| 8044534774 | |||
| cac463d4c3 | |||
| 7faa35dab4 | |||
| 24022092ad | |||
| e2e3094d2d | |||
| d669994bf0 | |||
| 45b8f6c4c8 | |||
| 044f651299 | |||
| f865910a90 | |||
| f2360c07a3 | |||
| 4c812c9ba4 | |||
| 26261dbfe7 | |||
| ae90e0e52e | |||
| 72d0895401 | |||
| 705368ee49 | |||
| cbc2c7c323 | |||
| e16c8a07bd | |||
| a7a138db31 | |||
| 76956c0abc | |||
| d4376e0539 | |||
| b94ba9d409 | |||
| 744ca84a8b | |||
| c55fde25a5 | |||
| 030031dcff | |||
| 341c19bace | |||
| e3a8d209b4 | |||
| e816870f67 | |||
| f28acc7fa1 | |||
| b2ae39e0d9 | |||
| 893662ba50 | |||
| 314004bc19 | |||
| 0915a61c19 | |||
| e123a13e38 | |||
| b15d3c9ad3 | |||
| 3da05330b2 | |||
| 8293eb26b9 | |||
| d8fc0a9914 | |||
| 1f4f4acb46 | |||
| d64e18b69a | |||
| 7a4271c85f | |||
| afb716566d | |||
| e5c68e1257 | |||
| a94588497c | |||
| 4cedbe28e3 | |||
| 9a258c089d | |||
| 9ff92f3f0d | |||
| 5f5c71ab75 | |||
| d92e59aa72 | |||
| d593436281 | |||
| 41b8bd4459 | |||
| f6f0fed2d1 | |||
| e2adf2e2bf | |||
| 78b535b229 | |||
| 7215950bd4 | |||
| c435f63d09 | |||
| eed664b7d3 | |||
| 5b550517b2 | |||
| e565073fd5 | |||
| 2b93191714 | |||
| 046225654a | |||
| 0db27d7b1b | |||
| f571cd5a6a | |||
| 79a55327dc | |||
| dbc796b0b0 | |||
| 02d8e7d828 | |||
| 1e4d126cb9 | |||
| f3a1d56c56 | |||
| 67b8684f3c | |||
| b04614e18d | |||
| 689a90d275 | |||
| 766be1e8bb | |||
| f2f66606d7 | |||
| 949fa4b513 | |||
| 40fa2fd390 | |||
| 7f26bd1091 | |||
| 86ac1aaa6b | |||
| 3c9727d36c | |||
| 5a2809e337 | |||
| d1d89dfb12 | |||
| 45ec0ed8b5 | |||
| ebb66f6ca3 | |||
| b85c9186ef | |||
| c606102f6d | |||
| 8b70582561 | |||
| b62fef3c1f | |||
| 71ab342838 | |||
| 83c4681e99 | |||
| 42e52f60ad | |||
| 6ac64c5319 | |||
| 83e0cfd7ee | |||
| 700e53a807 | |||
| 4d1f90b2f7 | |||
| d74babd8f3 | |||
| b7b2700425 | |||
| 04e4505402 | |||
| 95330223f4 | |||
| f28fa563c9 | |||
| d6ce6ae8b9 | |||
| 8f57f35372 | |||
| 9aa79682ba | |||
| d4cddb8a5d | |||
| dc300fae53 | |||
| 70980f09de | |||
| ca0cf763ff | |||
| 3d4c52092e | |||
| fc0af10509 | |||
| 88216e0ec4 | |||
| 474ae50c36 | |||
| 929e1936eb | |||
| 6382563440 | |||
| 733b4ba73a | |||
| 5d580abb08 | |||
| fc43f151a2 | |||
| 526f4051e9 | |||
| 635fd0433d | |||
| 370170bab0 | |||
| b317af0d30 | |||
| ca1f7af445 | |||
| 65836cc844 | |||
| b4090bdcb2 | |||
| ed61acc19a | |||
| 9b9b609c83 | |||
| 4cc69f4be0 | |||
| 527ba2e11c | |||
| 25d4e207f7 | |||
| 7a269987d2 | |||
| 081944d358 | |||
| 299810a4a9 | |||
| 53e58d8b1b | |||
| b99703301f | |||
| bdf263d867 | |||
| 9d5aedba5f | |||
| 0f88fe3c7f | |||
| 557b14e33e | |||
| c222d370ce | |||
| 0d629591ed | |||
| 23fa02eb59 | |||
| bea016c962 | |||
| fae1e4c556 | |||
| f2b9c68969 | |||
| e04ac6fd35 | |||
| 25011224cf | |||
| 191be0931e | |||
| 2cd2f36fe4 | |||
| 4f19ed5730 | |||
| 5949be852a | |||
| ffe075650c | |||
| abb35571ef | |||
| 474399683d | |||
| a238d59a07 | |||
| c779488ade | |||
| cc44f333b6 | |||
| 2b6302c06e | |||
| c3901fde56 | |||
| aead9c0e22 | |||
| 2fd747432a | |||
| 236db6d2f6 | |||
| 181c057cb8 | |||
| 7daef156e5 | |||
| f068ca3bd6 | |||
| 6c0eff5b39 | |||
| 5e946a45ee | |||
| 7d83779b6f | |||
| 663a873e07 | |||
| 3321a0cf09 | |||
| 5a9fb80239 | |||
| 8ac2d1f7bc | |||
| 9fa9d26341 | |||
| dd0b273b26 | |||
| cab108a839 | |||
| 3d83e67a52 | |||
| b809d964b3 | |||
| 96727072c8 | |||
| 9ec0d7c63b | |||
| bfe0a47b32 | |||
| 9b5f830462 | |||
| 42f9ed76c0 | |||
| 69faa6c583 | |||
| 0c7428f136 | |||
| 97e666c566 | |||
| 5cd3d7ebb7 | |||
| 62a5ded920 | |||
| 21801e8ba1 | |||
| 1a45fc82b5 | |||
| 4fb779920a | |||
| d7727224c2 | |||
| a96dc8bd9b | |||
| d146c347b4 | |||
| f2dea2a3fd | |||
| 4d1dd42420 | |||
| 8eb0ba60c1 | |||
| 2bf15d95df | |||
| be2db99586 | |||
| 37fe9df27c | |||
| 8759ad4d49 | |||
| 051ff4cf6d | |||
| fa4626c080 | |||
| d36c8ebce8 | |||
| 4cc577a72d | |||
| cdf7ce0fc4 | |||
| d894a98b79 | |||
| 2b57a5725c | |||
| 3f96790904 | |||
| 2bac9a8f0c | |||
| 997093ae3a | |||
| 5cbafa566d | |||
| 0d3cf0169e | |||
| 6e3c187493 | |||
| f8771b52ec | |||
| e3d610c3c5 | |||
| 486ffaa6dd | |||
| d96d1f1119 | |||
| 30f28456de | |||
| f8aad322cc | |||
| c6545a8b68 | |||
| 0fe9c1ce91 | |||
| b6882c86dc | |||
| 4503ad295c | |||
| 1a9bd2417a | |||
| 0d2f1b26b3 | |||
| bc59e56ef5 | |||
| b8bac71270 | |||
| 6238448716 | |||
| 56c970c2db | |||
| fcce108948 | |||
| cfeaef6180 | |||
| ebab369eac | |||
| 2a44c35224 | |||
| 0584a102ee | |||
| 2aae53bc15 | |||
| 911044b415 | |||
| b77def6883 | |||
| 5c48935fa8 | |||
| 9e996d612c | |||
| 5be844e1b7 | |||
| e2d9d592bc | |||
| 6f189bf2b8 | |||
| 47f567b6f1 | |||
| 305160d2e4 | |||
| f3ff7a446b | |||
| a3ec1a3712 | |||
| 6bbd6dcd44 | |||
| ce43df6f43 | |||
| 2250015e78 | |||
| 933f7eea1b | |||
| 631a314d24 | |||
| ed06fd5139 | |||
| 2326ecdedc | |||
| 38813f7d42 | |||
| 853922443f | |||
| 8b826d981b | |||
| 8bf1710e07 | |||
| 5e42941d3b | |||
| a1255bda6a | |||
| 9a2fdb3573 | |||
| 08f6627a24 | |||
| 53aafcd38e | |||
| d73e65172a | |||
| fff710585f | |||
| 1294990f4f | |||
| 974038e9b0 | |||
| ab0d0d1890 | |||
| f3fbe5575a | |||
| 860cb75d58 | |||
| e31f168e9b | |||
| 7f5537ca47 | |||
| a03072b617 | |||
| 31d05dbdd6 | |||
| d4faf7bd72 | |||
| 6e728b009f | |||
| efd3814a7e | |||
| b391cd5e9e | |||
| 8433773fad | |||
| 381bf18161 | |||
| f8ffccae7f | |||
| 2528bc1032 | |||
| 4b5f5d6ca3 | |||
| e2eaacd239 | |||
| 77d4ab9ea9 | |||
| 6883cd0969 | |||
| 43a152359e | |||
| 482cf4b8c3 | |||
| 2b4945217a | |||
| ccc1712d10 | |||
| 8622c3bfb7 | |||
| 820e7b91da | |||
| 08ba3717ea | |||
| 8f3eb3205d | |||
| 379c58a48d | |||
| 28b43838dd | |||
| 38f1d9749e | |||
| 9cc25d133d | |||
| 8e1b69b37f | |||
| a43fd188dc | |||
| f011529388 | |||
| 8ffb54603a | |||
| d3ee1e84b1 | |||
| 1035f6a101 | |||
| 22792b62cb | |||
| ef6f342a54 | |||
| a888257c82 | |||
| d55f808423 | |||
| dc92e3b0e4 | |||
| 95cebffa87 | |||
| 617ebf367f | |||
| 70b4589382 | |||
| 5221584b06 | |||
| c4ace4ac95 | |||
| 2fcf4084f8 | |||
| c632417dda | |||
| d090c59939 | |||
| 94d5326411 | |||
| c49f279a27 | |||
| 0fb042b740 | |||
| a5706c2fa6 | |||
| 4278b34fe6 | |||
| 0aeee8233b | |||
| 95de28cdda | |||
| 0874a26131 | |||
| 1bfbad2420 | |||
| fe1d8d8840 | |||
| a53a533cc4 | |||
| 4725722b1c | |||
| 9ea3f45886 | |||
| e64cc73f88 | |||
| 63990d6a2d | |||
| 09723bd123 | |||
| c242533f4e | |||
| 507998a4e3 | |||
| 7939f3b009 | |||
| e1d63c0ea0 | |||
| e1a16b4ec6 | |||
| 9569b4550d | |||
| 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.9",
|
"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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
|
||||||
with:
|
|
||||||
dotnet-version: '10.0.x'
|
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
queries: +security-extended
|
|
||||||
|
|
||||||
- name: Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
|
||||||
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
|
||||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
|
||||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.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@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # v5.5.10
|
|
||||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
repository: jellyfin/jellyfin-triage-script
|
|
||||||
|
|
||||||
- name: install python
|
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
repository: jellyfin/jellyfin-triage-script
|
|
||||||
|
|
||||||
- name: install python
|
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref }}
|
|
||||||
repository: ${{ inputs.repository }}
|
|
||||||
|
|
||||||
- name: Configure .NET
|
|
||||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
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@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
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
|
||||||
+9
-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)
|
||||||
@@ -172,6 +173,7 @@
|
|||||||
- [whooo](https://github.com/whooo)
|
- [whooo](https://github.com/whooo)
|
||||||
- [WiiPlayer2](https://github.com/WiiPlayer2)
|
- [WiiPlayer2](https://github.com/WiiPlayer2)
|
||||||
- [WillWill56](https://github.com/WillWill56)
|
- [WillWill56](https://github.com/WillWill56)
|
||||||
|
- [WizardOfYendor1](https://github.com/WizardOfYendor1)
|
||||||
- [wtayl0r](https://github.com/wtayl0r)
|
- [wtayl0r](https://github.com/wtayl0r)
|
||||||
- [Wuerfelbecher](https://github.com/Wuerfelbecher)
|
- [Wuerfelbecher](https://github.com/Wuerfelbecher)
|
||||||
- [Wunax](https://github.com/Wunax)
|
- [Wunax](https://github.com/Wunax)
|
||||||
@@ -233,6 +235,12 @@
|
|||||||
- [MSalman5230](https://github.com/MSalman5230)
|
- [MSalman5230](https://github.com/MSalman5230)
|
||||||
- [dwandw](https://github.com/dwandw)
|
- [dwandw](https://github.com/dwandw)
|
||||||
- [Lampan-git](https://github.com/Lampan-git)
|
- [Lampan-git](https://github.com/Lampan-git)
|
||||||
|
- [elio42](https://github.com/elio42)
|
||||||
|
- [rwebster85](https://github.com/rwebster85)
|
||||||
|
- [Florin-Popescu](https://github.com/Florin-Popescu)
|
||||||
|
- [m0g3r](https://github.com/m0g3r)
|
||||||
|
- [martin-77](https://github.com/martin-77)
|
||||||
|
- [Oggeb1](https://github.com/Oggeb1)
|
||||||
|
|
||||||
# Emby Contributors
|
# Emby Contributors
|
||||||
|
|
||||||
@@ -243,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)
|
||||||
|
|||||||
+36
-28
@@ -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" />
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
<PackageVersion Include="Diacritics" Version="4.1.8" />
|
<PackageVersion Include="Diacritics" Version="4.1.8" />
|
||||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||||
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.3" />
|
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.4" />
|
||||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" />
|
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" />
|
||||||
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
||||||
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
||||||
@@ -26,33 +27,37 @@
|
|||||||
<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.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.9.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" />
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
<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="PlaylistsNET" Version="1.4.1" />
|
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||||
@@ -67,21 +72,24 @@
|
|||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||||
<PackageVersion Include="SharpCompress" Version="0.49.1" />
|
<PackageVersion Include="SharpCompress" Version="0.50.4" />
|
||||||
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
|
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
|
||||||
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
|
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
|
||||||
<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.9" />
|
<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"]
|
||||||
@@ -57,7 +57,6 @@ namespace Emby.Naming.Common
|
|||||||
".nrg",
|
".nrg",
|
||||||
".nsv",
|
".nsv",
|
||||||
".nuv",
|
".nuv",
|
||||||
".ogg",
|
|
||||||
".ogm",
|
".ogm",
|
||||||
".ogv",
|
".ogv",
|
||||||
".pva",
|
".pva",
|
||||||
@@ -152,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>.+)",
|
||||||
@@ -362,7 +361,10 @@ namespace Emby.Naming.Common
|
|||||||
// Not a Kodi rule as well, but the expression below also causes false positives,
|
// Not a Kodi rule as well, but the expression below also causes false positives,
|
||||||
// so we make sure this one gets tested first.
|
// so we make sure this one gets tested first.
|
||||||
// "Foo Bar 889"
|
// "Foo Bar 889"
|
||||||
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
|
// Names carrying an SxxEyy marker are excluded because the Kodi expression above already covers them.
|
||||||
|
// Without that guard this expression reads digits out of the title instead, turning
|
||||||
|
// "S01E01 1-23-45 [Bluray-1080p]" into episodes 1 through 45.
|
||||||
|
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?![^\\\/]*[Ss][0-9]+[][ ._-]*[Ee][0-9]+)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
|
||||||
{
|
{
|
||||||
IsNamed = true
|
IsNamed = true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ namespace Emby.Naming.TV
|
|||||||
result.Success = true;
|
result.Success = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date))
|
else if (DateTime.TryParse(match.Groups[0].ValueSpan, CultureInfo.InvariantCulture, out date))
|
||||||
{
|
{
|
||||||
result.Year = date.Year;
|
result.Year = date.Year;
|
||||||
result.Month = date.Month;
|
result.Month = date.Month;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,13 +52,18 @@ namespace Emby.Naming.Video
|
|||||||
while (path.Length > 0)
|
while (path.Length > 0)
|
||||||
{
|
{
|
||||||
var index = path.IndexOfAny(delimiters);
|
var index = path.IndexOfAny(delimiters);
|
||||||
|
ReadOnlySpan<char> currentSlice;
|
||||||
if (index == -1)
|
if (index == -1)
|
||||||
{
|
{
|
||||||
index = path.Length - 1;
|
// No delimiter left, the last token is the remainder of the path
|
||||||
|
currentSlice = path;
|
||||||
|
path = default;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
currentSlice = path[..index];
|
||||||
|
path = path[(index + 1)..];
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentSlice = path[..index];
|
|
||||||
path = path[(index + 1)..];
|
|
||||||
|
|
||||||
if (!foundPrefix)
|
if (!foundPrefix)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ using Emby.Server.Implementations.SyncPlay;
|
|||||||
using Emby.Server.Implementations.TV;
|
using Emby.Server.Implementations.TV;
|
||||||
using Emby.Server.Implementations.Updates;
|
using Emby.Server.Implementations.Updates;
|
||||||
using Jellyfin.Api.Helpers;
|
using Jellyfin.Api.Helpers;
|
||||||
|
using Jellyfin.Data;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
using Jellyfin.Drawing;
|
using Jellyfin.Drawing;
|
||||||
using Jellyfin.MediaEncoding.Hls.Playlist;
|
using Jellyfin.MediaEncoding.Hls.Playlist;
|
||||||
using Jellyfin.Networking.Manager;
|
using Jellyfin.Networking.Manager;
|
||||||
@@ -417,6 +419,8 @@ namespace Emby.Server.Implementations
|
|||||||
{
|
{
|
||||||
Logger.LogInformation("Running startup tasks");
|
Logger.LogInformation("Running startup tasks");
|
||||||
|
|
||||||
|
EnsureStartupWizardIntegrity();
|
||||||
|
|
||||||
Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false));
|
Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false));
|
||||||
|
|
||||||
ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated;
|
ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated;
|
||||||
@@ -436,6 +440,24 @@ namespace Emby.Server.Implementations
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnsureStartupWizardIntegrity()
|
||||||
|
{
|
||||||
|
if (ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasConfiguredAdministrator = Resolve<IUserManager>().GetUsers()
|
||||||
|
.Any(user => user.HasPermission(PermissionKind.IsAdministrator) && !string.IsNullOrEmpty(user.Password));
|
||||||
|
|
||||||
|
if (hasConfiguredAdministrator)
|
||||||
|
{
|
||||||
|
Logger.LogWarning("The startup wizard is marked incomplete but a configured administrator already exists. Marking setup as completed to prevent the unauthenticated setup endpoints from being reachable.");
|
||||||
|
ConfigurationManager.Configuration.IsStartupWizardCompleted = true;
|
||||||
|
ConfigurationManager.SaveConfiguration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public void Init(IServiceCollection serviceCollection)
|
public void Init(IServiceCollection serviceCollection)
|
||||||
{
|
{
|
||||||
@@ -965,8 +987,9 @@ namespace Emby.Server.Implementations
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null)
|
public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null)
|
||||||
{
|
{
|
||||||
// If the smartAPI doesn't start with http then treat it as a host or ip.
|
// If the smartAPI isn't already a complete URL then treat it as a host or ip.
|
||||||
if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
if (hostname.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| hostname.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return hostname.TrimEnd('/');
|
return hostname.TrimEnd('/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -10,6 +8,9 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Emby.Server.Implementations.Devices
|
namespace Emby.Server.Implementations.Devices
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provides the persistent unique identifier of this server installation.
|
||||||
|
/// </summary>
|
||||||
public class DeviceId
|
public class DeviceId
|
||||||
{
|
{
|
||||||
private readonly IApplicationPaths _appPaths;
|
private readonly IApplicationPaths _appPaths;
|
||||||
@@ -18,12 +19,20 @@ namespace Emby.Server.Implementations.Devices
|
|||||||
|
|
||||||
private string? _id;
|
private string? _id;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DeviceId"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||||
|
/// <param name="logger">Instance of the <see cref="ILogger{DeviceId}"/> interface.</param>
|
||||||
public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger)
|
public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger)
|
||||||
{
|
{
|
||||||
_appPaths = appPaths;
|
_appPaths = appPaths;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the device id, loading it from disk or generating and persisting a new one if none exists.
|
||||||
|
/// </summary>
|
||||||
public string Value => _id ??= GetDeviceId();
|
public string Value => _id ??= GetDeviceId();
|
||||||
|
|
||||||
private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt");
|
private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt");
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
{
|
{
|
||||||
BaseItemKind.Person, [
|
BaseItemKind.Person, [
|
||||||
BaseItemKind.Audio,
|
BaseItemKind.Audio,
|
||||||
|
BaseItemKind.AudioBook,
|
||||||
|
BaseItemKind.Book,
|
||||||
BaseItemKind.Episode,
|
BaseItemKind.Episode,
|
||||||
BaseItemKind.Movie,
|
BaseItemKind.Movie,
|
||||||
BaseItemKind.LiveTvProgram,
|
BaseItemKind.LiveTvProgram,
|
||||||
@@ -167,9 +169,13 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
// Batch-fetch user data for all items
|
// Batch-fetch user data for all items
|
||||||
Dictionary<Guid, UserItemData>? userDataBatch = null;
|
Dictionary<Guid, UserItemData>? userDataBatch = null;
|
||||||
|
IReadOnlyDictionary<Guid, VersionResumeData>? resumeDataBatch = null;
|
||||||
if (user is not null && options.EnableUserData)
|
if (user is not null && options.EnableUserData)
|
||||||
{
|
{
|
||||||
userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user);
|
userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user);
|
||||||
|
|
||||||
|
// For items with alternate versions, the most recently played version drives resume.
|
||||||
|
resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-compute collection folders once to avoid N+1 queries in CanDelete
|
// Pre-compute collection folders once to avoid N+1 queries in CanDelete
|
||||||
@@ -179,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))
|
||||||
@@ -186,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +249,29 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
artistsBatch = _libraryManager.GetArtists(artistNames.ToArray());
|
artistsBatch = _libraryManager.GetArtists(artistNames.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch-fetch people across all items to avoid one GetPeople query per item.
|
||||||
|
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null;
|
||||||
|
if (options.ContainsField(ItemFields.People))
|
||||||
|
{
|
||||||
|
var peopleItemIds = accessibleItems.Where(i => i.SupportsPeople).Select(i => i.Id).ToList();
|
||||||
|
if (peopleItemIds.Count > 0)
|
||||||
|
{
|
||||||
|
peopleBatch = _libraryManager.GetPeopleByItems(peopleItemIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-detect which videos own alternate versions to avoid the per-item alternate-version
|
||||||
|
// queries in MediaSourceCount. Videos absent from this set have a single media source.
|
||||||
|
IReadOnlySet<Guid>? alternateVersionItemIds = null;
|
||||||
|
if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||||
|
{
|
||||||
|
var versionItemIds = accessibleItems.OfType<Video>().Select(i => i.Id).ToList();
|
||||||
|
if (versionItemIds.Count > 0)
|
||||||
|
{
|
||||||
|
alternateVersionItemIds = _libraryManager.GetItemIdsWithAlternateVersions(versionItemIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (int index = 0; index < accessibleItems.Count; index++)
|
for (int index = 0; index < accessibleItems.Count; index++)
|
||||||
{
|
{
|
||||||
var item = accessibleItems[index];
|
var item = accessibleItems[index];
|
||||||
@@ -248,7 +284,10 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
allCollectionFolders,
|
allCollectionFolders,
|
||||||
childCountBatch,
|
childCountBatch,
|
||||||
playedCountBatch,
|
playedCountBatch,
|
||||||
artistsBatch);
|
artistsBatch,
|
||||||
|
resumeDataBatch?.GetValueOrDefault(item.Id),
|
||||||
|
peopleBatch,
|
||||||
|
alternateVersionItemIds);
|
||||||
|
|
||||||
if (item is LiveTvChannel tvChannel)
|
if (item is LiveTvChannel tvChannel)
|
||||||
{
|
{
|
||||||
@@ -261,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;
|
||||||
@@ -309,7 +348,10 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
List<Folder>? allCollectionFolders = null,
|
List<Folder>? allCollectionFolders = null,
|
||||||
Dictionary<Guid, int>? childCountBatch = null,
|
Dictionary<Guid, int>? childCountBatch = null,
|
||||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||||
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
|
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null,
|
||||||
|
VersionResumeData? resumeData = null,
|
||||||
|
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null,
|
||||||
|
IReadOnlySet<Guid>? alternateVersionItemIds = null)
|
||||||
{
|
{
|
||||||
var dto = new BaseItemDto
|
var dto = new BaseItemDto
|
||||||
{
|
{
|
||||||
@@ -323,7 +365,15 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
if (options.ContainsField(ItemFields.People))
|
if (options.ContainsField(ItemFields.People))
|
||||||
{
|
{
|
||||||
AttachPeople(dto, item, user);
|
IReadOnlyList<PersonInfo>? prefetchedPeople = null;
|
||||||
|
if (peopleBatch is not null)
|
||||||
|
{
|
||||||
|
// The batch omits items with no people, so a miss means "no people",
|
||||||
|
// not "not fetched". Use an empty list to skip the per-item query.
|
||||||
|
prefetchedPeople = peopleBatch.GetValueOrDefault(item.Id) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
AttachPeople(dto, item, user, prefetchedPeople);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.ContainsField(ItemFields.PrimaryImageAspectRatio))
|
if (options.ContainsField(ItemFields.PrimaryImageAspectRatio))
|
||||||
@@ -353,7 +403,8 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
options,
|
options,
|
||||||
userData,
|
userData,
|
||||||
childCountBatch,
|
childCountBatch,
|
||||||
playedCountBatch);
|
playedCountBatch,
|
||||||
|
resumeData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item is IHasMediaSources
|
if (item is IHasMediaSources
|
||||||
@@ -369,7 +420,7 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
AttachStudios(dto, item);
|
AttachStudios(dto, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
AttachBasicFields(dto, item, owner, options, artistsBatch);
|
AttachBasicFields(dto, item, owner, options, artistsBatch, user, alternateVersionItemIds);
|
||||||
|
|
||||||
if (options.ContainsField(ItemFields.CanDelete))
|
if (options.ContainsField(ItemFields.CanDelete))
|
||||||
{
|
{
|
||||||
@@ -474,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;
|
||||||
@@ -538,7 +611,8 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
DtoOptions options,
|
DtoOptions options,
|
||||||
UserItemData? userData = null,
|
UserItemData? userData = null,
|
||||||
Dictionary<Guid, int>? childCountBatch = null,
|
Dictionary<Guid, int>? childCountBatch = null,
|
||||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
|
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||||
|
VersionResumeData? resumeData = null)
|
||||||
{
|
{
|
||||||
if (item.IsFolder)
|
if (item.IsFolder)
|
||||||
{
|
{
|
||||||
@@ -566,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)
|
||||||
@@ -600,6 +678,9 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
// Use pre-fetched user data
|
// Use pre-fetched user data
|
||||||
dto.UserData = GetUserItemDataDto(userData, item.Id);
|
dto.UserData = GetUserItemDataDto(userData, item.Id);
|
||||||
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
|
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
|
||||||
|
|
||||||
|
// For items with alternate versions, the most recently played version drives resume.
|
||||||
|
resumeData?.ApplyTo(dto.UserData);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -648,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,12 +811,18 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
/// <param name="dto">The dto.</param>
|
/// <param name="dto">The dto.</param>
|
||||||
/// <param name="item">The item.</param>
|
/// <param name="item">The item.</param>
|
||||||
/// <param name="user">The requesting user.</param>
|
/// <param name="user">The requesting user.</param>
|
||||||
private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null)
|
/// <param name="prefetchedPeople">People fetched in batch by the caller; when null the people are queried per item.</param>
|
||||||
|
private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null, IReadOnlyList<PersonInfo>? prefetchedPeople = null)
|
||||||
{
|
{
|
||||||
|
// When rendering a page of items the caller batch-fetches people for every item up
|
||||||
|
// front and passes them in, avoiding one GetPeople query per item. Fall back to the
|
||||||
|
// per-item query for the single item path where no batch is available.
|
||||||
|
var source = prefetchedPeople ?? _libraryManager.GetPeople(item);
|
||||||
|
|
||||||
// Ordering by person type to ensure actors and artists are at the front.
|
// Ordering by person type to ensure actors and artists are at the front.
|
||||||
// This is taking advantage of the fact that they both begin with A
|
// This is taking advantage of the fact that they both begin with A
|
||||||
// This should be improved in the future
|
// This should be improved in the future
|
||||||
var people = _libraryManager.GetPeople(item).OrderBy(i => i.SortOrder ?? int.MaxValue)
|
var people = source.OrderBy(i => i.SortOrder ?? int.MaxValue)
|
||||||
.ThenBy(i =>
|
.ThenBy(i =>
|
||||||
{
|
{
|
||||||
if (i.IsType(PersonKind.Actor))
|
if (i.IsType(PersonKind.Actor))
|
||||||
@@ -943,7 +1031,9 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
/// <param name="owner">The owner.</param>
|
/// <param name="owner">The owner.</param>
|
||||||
/// <param name="options">The options.</param>
|
/// <param name="options">The options.</param>
|
||||||
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
|
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
|
||||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
|
/// <param name="user">The user, for per-user values such as the accessible media source count.</param>
|
||||||
|
/// <param name="alternateVersionItemIds">Optional pre-fetched set of item IDs that own alternate versions, shared across a batch of items.</param>
|
||||||
|
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null, IReadOnlySet<Guid>? alternateVersionItemIds = null)
|
||||||
{
|
{
|
||||||
if (options.ContainsField(ItemFields.DateCreated))
|
if (options.ContainsField(ItemFields.DateCreated))
|
||||||
{
|
{
|
||||||
@@ -1074,7 +1164,7 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
dto.ParentId = item.DisplayParentId;
|
dto.ParentId = item.DisplayParentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
AddInheritedImages(dto, item, options, owner);
|
AddInheritedImages(dto, item, options, owner, artistsBatch);
|
||||||
|
|
||||||
if (options.ContainsField(ItemFields.Path))
|
if (options.ContainsField(ItemFields.Path))
|
||||||
{
|
{
|
||||||
@@ -1257,10 +1347,27 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
if (options.ContainsField(ItemFields.MediaSourceCount))
|
if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||||
{
|
{
|
||||||
var mediaSourceCount = video.MediaSourceCount;
|
// A video with no primary version and no alternate versions always has a single
|
||||||
if (mediaSourceCount != 1)
|
// media source. Only compute the count for videos that might have more: a primary
|
||||||
|
// version, or membership in the batch's set of items that own alternate versions.
|
||||||
|
// Without the batch we can't rule it out, so fall back to computing (the single-item
|
||||||
|
// path). Everything else is the common case and keeps the default count of one.
|
||||||
|
var mayHaveAlternateVersions = alternateVersionItemIds is null
|
||||||
|
|| video.PrimaryVersionId.HasValue
|
||||||
|
|| alternateVersionItemIds.Contains(video.Id);
|
||||||
|
|
||||||
|
if (mayHaveAlternateVersions)
|
||||||
{
|
{
|
||||||
dto.MediaSourceCount = mediaSourceCount;
|
// Match the per-user filtering of the media sources: versions the user cannot
|
||||||
|
// access are not selectable, so they must not count towards the badge either.
|
||||||
|
var mediaSourceCount = user is null
|
||||||
|
|| (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
|
||||||
|
? video.MediaSourceCount
|
||||||
|
: video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
|
||||||
|
if (mediaSourceCount != 1)
|
||||||
|
{
|
||||||
|
dto.MediaSourceCount = mediaSourceCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1366,38 +1473,22 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.PreferEpisodeParentPoster)
|
if (options.GetImageLimit(ImageType.Primary) > 0)
|
||||||
{
|
{
|
||||||
var episodeSeason = episode.Season;
|
var episodeSeason = episode.Season;
|
||||||
var seasonPrimaryTag = episodeSeason is not null
|
var seasonPrimaryTag = episodeSeason is not null
|
||||||
? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
|
? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
BaseItem? posterParent = null;
|
|
||||||
if (seasonPrimaryTag is not null)
|
if (seasonPrimaryTag is not null)
|
||||||
{
|
{
|
||||||
dto.ParentPrimaryImageItemId = episodeSeason!.Id;
|
dto.ParentPrimaryImageItemId = episodeSeason!.Id;
|
||||||
dto.ParentPrimaryImageTag = seasonPrimaryTag;
|
dto.ParentPrimaryImageTag = seasonPrimaryTag;
|
||||||
posterParent = episodeSeason;
|
|
||||||
}
|
}
|
||||||
else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
|
else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
|
||||||
{
|
{
|
||||||
dto.ParentPrimaryImageItemId = episodeSeries.Id;
|
dto.ParentPrimaryImageItemId = episodeSeries.Id;
|
||||||
dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag;
|
dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag;
|
||||||
posterParent = episodeSeries;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (posterParent is not null)
|
|
||||||
{
|
|
||||||
if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag))
|
|
||||||
{
|
|
||||||
// Only drop the episode's own primary blurhash; keep the poster parent's.
|
|
||||||
dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag);
|
|
||||||
}
|
|
||||||
|
|
||||||
dto.SeriesPrimaryImageTag = null;
|
|
||||||
dto.PrimaryImageAspectRatio = null;
|
|
||||||
AttachPrimaryImageAspectRatio(dto, posterParent);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1516,11 +1607,11 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem)
|
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||||
{
|
{
|
||||||
if (currentItem is MusicAlbum musicAlbum)
|
if (currentItem is MusicAlbum musicAlbum)
|
||||||
{
|
{
|
||||||
var artist = musicAlbum.GetMusicArtist(new DtoOptions(false));
|
var artist = GetBatchedAlbumArtist(musicAlbum, artistsBatch) ?? musicAlbum.GetMusicArtist(new DtoOptions(false));
|
||||||
if (artist is not null)
|
if (artist is not null)
|
||||||
{
|
{
|
||||||
return artist;
|
return artist;
|
||||||
@@ -1537,7 +1628,20 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
return parent;
|
return parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
|
private static MusicArtist? GetBatchedAlbumArtist(MusicAlbum album, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||||
|
{
|
||||||
|
if (artistsBatch is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = album.AlbumArtists.Count > 0 ? album.AlbumArtists[0] : null;
|
||||||
|
return !string.IsNullOrEmpty(name) && artistsBatch.TryGetValue(name, out var artists) && artists.Length > 0
|
||||||
|
? artists[0]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||||
{
|
{
|
||||||
if (item is UserView { ViewType: CollectionType.playlists } playlistsView
|
if (item is UserView { ViewType: CollectionType.playlists } playlistsView
|
||||||
&& options.GetImageLimit(ImageType.Primary) > 0
|
&& options.GetImageLimit(ImageType.Primary) > 0
|
||||||
@@ -1582,7 +1686,7 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|| (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0)
|
|| (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0)
|
||||||
|| parent is Series)
|
|| parent is Series)
|
||||||
{
|
{
|
||||||
parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent;
|
parent ??= isFirst ? GetImageDisplayParent(item, item, artistsBatch) ?? owner : parent;
|
||||||
if (parent is null)
|
if (parent is null)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
@@ -1641,7 +1745,7 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
parent = GetImageDisplayParent(parent, item);
|
parent = GetImageDisplayParent(parent, item, artistsBatch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -127,8 +127,12 @@ namespace Emby.Server.Implementations.HttpServer
|
|||||||
{
|
{
|
||||||
receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
|
receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (WebSocketException ex)
|
catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
// ObjectDisposedException/OperationCanceledException: the socket was torn
|
||||||
|
// down underneath us (e.g. by the keep-alive watchdog after the connection
|
||||||
|
// was declared lost). Fall through so Closed is still raised and the
|
||||||
|
// session can release this connection.
|
||||||
_logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message);
|
_logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ namespace Emby.Server.Implementations.IO
|
|||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException)
|
catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to enumerate path {Path}", path);
|
_logger.LogWarning("Failed to enumerate path \"{Path}\": {Message}", path, ex.Message);
|
||||||
return Enumerable.Empty<string>();
|
return Enumerable.Empty<string>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using Jellyfin.Api.Extensions;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Enums;
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
@@ -14,7 +15,6 @@ using MediaBrowser.Controller.Entities;
|
|||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using MediaBrowser.Model.Querying;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Images
|
namespace Emby.Server.Implementations.Images
|
||||||
{
|
{
|
||||||
@@ -28,51 +28,23 @@ namespace Emby.Server.Implementations.Images
|
|||||||
{
|
{
|
||||||
var view = (CollectionFolder)item;
|
var view = (CollectionFolder)item;
|
||||||
var viewType = view.CollectionType;
|
var viewType = view.CollectionType;
|
||||||
|
var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
|
||||||
BaseItemKind[] includeItemTypes;
|
|
||||||
|
|
||||||
switch (viewType)
|
|
||||||
{
|
|
||||||
case CollectionType.movies:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.Movie };
|
|
||||||
break;
|
|
||||||
case CollectionType.tvshows:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.Series };
|
|
||||||
break;
|
|
||||||
case CollectionType.music:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead
|
|
||||||
break;
|
|
||||||
case CollectionType.musicvideos:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.MusicVideo };
|
|
||||||
break;
|
|
||||||
case CollectionType.books:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
|
|
||||||
break;
|
|
||||||
case CollectionType.boxsets:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.BoxSet };
|
|
||||||
break;
|
|
||||||
case CollectionType.homevideos:
|
|
||||||
case CollectionType.photos:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo };
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var recursive = viewType != CollectionType.playlists;
|
var recursive = viewType != CollectionType.playlists;
|
||||||
|
|
||||||
|
if (viewType == CollectionType.music)
|
||||||
|
{
|
||||||
|
// Music albums usually don't have dedicated backdrops, so use artist instead
|
||||||
|
includeItemTypes = [BaseItemKind.MusicArtist];
|
||||||
|
}
|
||||||
|
|
||||||
return view.GetItemList(new InternalItemsQuery
|
return view.GetItemList(new InternalItemsQuery
|
||||||
{
|
{
|
||||||
CollapseBoxSetItems = false,
|
CollapseBoxSetItems = false,
|
||||||
Recursive = recursive,
|
Recursive = recursive,
|
||||||
DtoOptions = new DtoOptions(false),
|
DtoOptions = new DtoOptions(false),
|
||||||
ImageTypes = new[] { ImageType.Primary },
|
ImageTypes = [ImageType.Primary],
|
||||||
Limit = 8,
|
Limit = 8,
|
||||||
OrderBy = new[]
|
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)],
|
||||||
{
|
|
||||||
(ItemSortBy.Random, SortOrder.Ascending)
|
|
||||||
},
|
|
||||||
IncludeItemTypes = includeItemTypes
|
IncludeItemTypes = includeItemTypes
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -45,6 +44,7 @@ using MediaBrowser.Model.Configuration;
|
|||||||
using MediaBrowser.Model.Drawing;
|
using MediaBrowser.Model.Drawing;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using MediaBrowser.Model.Library;
|
using MediaBrowser.Model.Library;
|
||||||
using MediaBrowser.Model.Querying;
|
using MediaBrowser.Model.Querying;
|
||||||
@@ -74,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;
|
||||||
@@ -86,6 +85,8 @@ namespace Emby.Server.Implementations.Library
|
|||||||
private readonly IPeopleRepository _peopleRepository;
|
private readonly IPeopleRepository _peopleRepository;
|
||||||
private readonly ExtraResolver _extraResolver;
|
private readonly ExtraResolver _extraResolver;
|
||||||
private readonly IPathManager _pathManager;
|
private readonly IPathManager _pathManager;
|
||||||
|
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;
|
||||||
@@ -120,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>
|
||||||
@@ -132,6 +132,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <param name="peopleRepository">The people repository.</param>
|
/// <param name="peopleRepository">The people repository.</param>
|
||||||
/// <param name="pathManager">The path manager.</param>
|
/// <param name="pathManager">The path manager.</param>
|
||||||
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||||
|
/// <param name="localization">The localization manager.</param>
|
||||||
/// <param name="mediaStreamRepository">The media stream repository.</param>
|
/// <param name="mediaStreamRepository">The media stream repository.</param>
|
||||||
/// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param>
|
/// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param>
|
||||||
public LibraryManager(
|
public LibraryManager(
|
||||||
@@ -145,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,
|
||||||
@@ -157,6 +157,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
IPeopleRepository peopleRepository,
|
IPeopleRepository peopleRepository,
|
||||||
IPathManager pathManager,
|
IPathManager pathManager,
|
||||||
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
|
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
|
||||||
|
ILocalizationManager localization,
|
||||||
IMediaStreamRepository mediaStreamRepository,
|
IMediaStreamRepository mediaStreamRepository,
|
||||||
Lazy<IExternalDataManager> externalDataManagerFactory)
|
Lazy<IExternalDataManager> externalDataManagerFactory)
|
||||||
{
|
{
|
||||||
@@ -170,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;
|
||||||
@@ -184,6 +184,8 @@ namespace Emby.Server.Implementations.Library
|
|||||||
_peopleRepository = peopleRepository;
|
_peopleRepository = peopleRepository;
|
||||||
_pathManager = pathManager;
|
_pathManager = pathManager;
|
||||||
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||||
|
_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;
|
||||||
@@ -407,6 +409,13 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
|
|
||||||
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
|
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
|
||||||
|
|
||||||
|
// Evict the deleted items from the cache and announce each removal.
|
||||||
|
foreach (var (item, _, _) in pathMaps)
|
||||||
|
{
|
||||||
|
_cache.TryRemove(item.Id, out _);
|
||||||
|
ReportItemRemoved(item, item.GetOwner() ?? item.GetParent());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem)
|
public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem)
|
||||||
@@ -606,6 +615,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
folder.UserData = null;
|
folder.UserData = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announce the descendants before the item itself.
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
ReportItemRemoved(child, item);
|
||||||
|
}
|
||||||
|
|
||||||
ReportItemRemoved(item, parent);
|
ReportItemRemoved(item, parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,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)
|
||||||
{
|
{
|
||||||
@@ -1204,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>
|
||||||
@@ -1336,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>
|
||||||
@@ -1471,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
|
||||||
@@ -1488,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++;
|
||||||
@@ -1510,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);
|
||||||
@@ -1727,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/>
|
||||||
@@ -1896,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)
|
||||||
@@ -1949,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
|
||||||
@@ -1963,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)
|
||||||
@@ -2229,6 +2315,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds)
|
||||||
|
{
|
||||||
|
return _linkedChildrenService.GetItemIdsWithAlternateVersions(itemIds);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType)
|
public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType)
|
||||||
{
|
{
|
||||||
@@ -2240,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)
|
||||||
{
|
{
|
||||||
@@ -2272,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();
|
||||||
@@ -2305,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>
|
||||||
@@ -2315,9 +2435,13 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
var comparer = Comparers.FirstOrDefault(c => name == c.Type);
|
var comparer = Comparers.FirstOrDefault(c => name == c.Type);
|
||||||
|
|
||||||
// If it requires a user, create a new one, and assign the user
|
|
||||||
if (comparer is IUserBaseItemComparer)
|
if (comparer is IUserBaseItemComparer)
|
||||||
{
|
{
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Sort key '{name}' requires a user, but none was provided.");
|
||||||
|
}
|
||||||
|
|
||||||
var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable<T> instances
|
var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable<T> instances
|
||||||
|
|
||||||
userComparer.User = user;
|
userComparer.User = user;
|
||||||
@@ -2367,6 +2491,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
altVideo.OwnerId = video.Id;
|
altVideo.OwnerId = video.Id;
|
||||||
altVideo.SetPrimaryVersionId(video.Id);
|
altVideo.SetPrimaryVersionId(video.Id);
|
||||||
|
altVideo.IsInMixedFolder = video.IsInMixedFolder;
|
||||||
// ResolveAlternateVersion only sees the alternate's primary file.
|
// ResolveAlternateVersion only sees the alternate's primary file.
|
||||||
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
|
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
|
||||||
// detect its parts from sibling files so its AdditionalParts persist.
|
// detect its parts from sibling files so its AdditionalParts persist.
|
||||||
@@ -2490,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2552,6 +2683,8 @@ namespace Emby.Server.Implementations.Library
|
|||||||
item.DateLastSaved = DateTime.UtcNow;
|
item.DateLastSaved = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ForgetDroppedLocalAlternateVersions(items);
|
||||||
|
|
||||||
// Resolve and add any local alternate version items that don't exist yet
|
// Resolve and add any local alternate version items that don't exist yet
|
||||||
// This ensures they exist in the database when LinkedChildren are processed
|
// This ensures they exist in the database when LinkedChildren are processed
|
||||||
var allItems = new List<BaseItem>(items);
|
var allItems = new List<BaseItem>(items);
|
||||||
@@ -2580,6 +2713,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
altVideo.OwnerId = video.Id;
|
altVideo.OwnerId = video.Id;
|
||||||
altVideo.SetPrimaryVersionId(video.Id);
|
altVideo.SetPrimaryVersionId(video.Id);
|
||||||
|
altVideo.IsInMixedFolder = video.IsInMixedFolder;
|
||||||
// ResolveAlternateVersion only sees the alternate's primary file.
|
// ResolveAlternateVersion only sees the alternate's primary file.
|
||||||
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
|
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
|
||||||
// detect its parts from sibling files so its AdditionalParts persist.
|
// detect its parts from sibling files so its AdditionalParts persist.
|
||||||
@@ -2640,6 +2774,30 @@ namespace Emby.Server.Implementations.Library
|
|||||||
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||||
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forgets the cached local alternate versions of the supplied items that they no longer list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="items">The items about to be saved.</param>
|
||||||
|
private void ForgetDroppedLocalAlternateVersions(IReadOnlyList<BaseItem> items)
|
||||||
|
{
|
||||||
|
foreach (var video in items.OfType<Video>())
|
||||||
|
{
|
||||||
|
var videoType = video.GetType();
|
||||||
|
var keptIds = video.LocalAlternateVersions
|
||||||
|
.Where(path => !string.IsNullOrEmpty(path))
|
||||||
|
.Select(path => GetNewItemId(path, videoType))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
foreach (var versionId in GetLocalAlternateVersionIds(video))
|
||||||
|
{
|
||||||
|
if (!keptIds.Contains(versionId))
|
||||||
|
{
|
||||||
|
_cache.TryRemove(versionId, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
|
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -2863,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;
|
||||||
|
|
||||||
@@ -2887,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)
|
||||||
{
|
{
|
||||||
@@ -2907,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));
|
||||||
|
|
||||||
@@ -2937,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;
|
||||||
@@ -3038,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;
|
||||||
@@ -3072,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3195,11 +3369,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!episode.ProductionYear.HasValue)
|
if (episode.ProductionYear is null)
|
||||||
{
|
{
|
||||||
episode.ProductionYear = episodeInfo.Year;
|
episode.ProductionYear = episodeInfo.Year;
|
||||||
|
|
||||||
if (episode.ProductionYear.HasValue)
|
if (episode.ProductionYear is not null)
|
||||||
{
|
{
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -3276,9 +3450,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath);
|
var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath);
|
||||||
if (ownerVideoInfo is null)
|
if (ownerVideoInfo is null)
|
||||||
{
|
{
|
||||||
yield break;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var candidates = new List<ExtraCandidate>();
|
||||||
|
|
||||||
var count = filtered.Count;
|
var count = filtered.Count;
|
||||||
for (var i = 0; i < count; i++)
|
for (var i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
@@ -3292,35 +3468,50 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
foreach (var file in filesInSubFolderList)
|
foreach (var file in filesInSubFolderList)
|
||||||
{
|
{
|
||||||
if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType))
|
if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType, out var extraRule))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var extra = GetExtra(file, extraType.Value, subFolderIsMixedFolder);
|
AddCandidate(file, extraType.Value, extraRule, subFolderIsMixedFolder);
|
||||||
if (extra is not null)
|
|
||||||
{
|
|
||||||
yield return extra;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType))
|
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType, out var extraRule))
|
||||||
{
|
{
|
||||||
var extra = GetExtra(current, extraType.Value, false);
|
AddCandidate(current, extraType.Value, extraRule, false);
|
||||||
if (extra is not null)
|
|
||||||
{
|
|
||||||
yield return extra;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder)
|
var extras = new List<BaseItem>();
|
||||||
|
var typeCounters = new Dictionary<ExtraType, int>();
|
||||||
|
|
||||||
|
// Order by path so that the numbering handed out below does not depend on the
|
||||||
|
// order the file system happened to list the folder in
|
||||||
|
foreach (var candidate in candidates.OrderBy(c => c.Extra.Path, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var extra = PrepareExtra(candidate);
|
||||||
|
if (extra is not null)
|
||||||
|
{
|
||||||
|
extras.Add(extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extras;
|
||||||
|
|
||||||
|
void AddCandidate(FileSystemMetadata file, ExtraType extraType, ExtraRule extraRule, bool isInMixedFolder)
|
||||||
{
|
{
|
||||||
var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType));
|
var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType));
|
||||||
if (extra is not Video && extra is not Audio)
|
if (extra is Video or Audio)
|
||||||
{
|
{
|
||||||
return null;
|
candidates.Add(new ExtraCandidate(extra, extraType, extraRule, isInMixedFolder));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItem? PrepareExtra(ExtraCandidate candidate)
|
||||||
|
{
|
||||||
|
var resolved = candidate.Extra;
|
||||||
|
var extra = resolved;
|
||||||
|
var name = GetExtraName(candidate, ownerVideoInfo, typeCounters);
|
||||||
|
|
||||||
// Try to retrieve it from the db. If we don't find it, use the resolved version
|
// Try to retrieve it from the db. If we don't find it, use the resolved version
|
||||||
var itemById = GetItemById(extra.Id);
|
var itemById = GetItemById(extra.Id);
|
||||||
@@ -3329,10 +3520,18 @@ namespace Emby.Server.Implementations.Library
|
|||||||
extra = itemById;
|
extra = itemById;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update extra type if it is more specific then the currently known extra type
|
// An extra is named after its file, so the file is the source of truth. Items created
|
||||||
if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown)
|
// by older versions, or renamed by a metadata provider, are corrected here;
|
||||||
|
// RefreshExtras persists the change.
|
||||||
|
if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true)
|
||||||
{
|
{
|
||||||
extra.ExtraType = extraType;
|
extra.Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update extra type if it is more specific then the currently known extra type
|
||||||
|
if (extra.ExtraType is null or ExtraType.Unknown || candidate.ExtraType != ExtraType.Unknown)
|
||||||
|
{
|
||||||
|
extra.ExtraType = candidate.ExtraType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only return items that are actual extras (have ExtraType set)
|
// Only return items that are actual extras (have ExtraType set)
|
||||||
@@ -3340,7 +3539,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
// so that RefreshExtras can detect when they need updating and set ForceSave.
|
// so that RefreshExtras can detect when they need updating and set ForceSave.
|
||||||
if (extra.ExtraType is not null)
|
if (extra.ExtraType is not null)
|
||||||
{
|
{
|
||||||
extra.IsInMixedFolder = isInMixedFolder;
|
extra.IsInMixedFolder = candidate.IsInMixedFolder;
|
||||||
return extra;
|
return extra;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3348,6 +3547,57 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name to give an extra.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="candidate">The resolved extra.</param>
|
||||||
|
/// <param name="ownerVideoInfo">The naming info of the owner.</param>
|
||||||
|
/// <param name="typeCounters">Number of extras named after their type so far, per type.</param>
|
||||||
|
/// <returns>The name.</returns>
|
||||||
|
private string GetExtraName(ExtraCandidate candidate, VideoFileInfo ownerVideoInfo, Dictionary<ExtraType, int> typeCounters)
|
||||||
|
{
|
||||||
|
var isNamedAfterOwner = candidate.ExtraRule.RuleType switch
|
||||||
|
{
|
||||||
|
ExtraRuleType.Filename => true,
|
||||||
|
ExtraRuleType.Suffix => string.Equals(candidate.Extra.Name, ownerVideoInfo.Name, StringComparison.OrdinalIgnoreCase),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isNamedAfterOwner)
|
||||||
|
{
|
||||||
|
return candidate.Extra.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
typeCounters.TryGetValue(candidate.ExtraType, out var seen);
|
||||||
|
typeCounters[candidate.ExtraType] = seen + 1;
|
||||||
|
|
||||||
|
var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType));
|
||||||
|
|
||||||
|
return seen == 0
|
||||||
|
? typeName
|
||||||
|
: string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
_localization.GetServerLocalizedString("NameExtraNumbered"),
|
||||||
|
typeName,
|
||||||
|
seen + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetExtraTypeNameKey(ExtraType extraType) => extraType switch
|
||||||
|
{
|
||||||
|
ExtraType.Clip => "NameExtraClip",
|
||||||
|
ExtraType.Trailer => "NameExtraTrailer",
|
||||||
|
ExtraType.BehindTheScenes => "NameExtraBehindTheScenes",
|
||||||
|
ExtraType.DeletedScene => "NameExtraDeletedScene",
|
||||||
|
ExtraType.Interview => "NameExtraInterview",
|
||||||
|
ExtraType.Scene => "NameExtraScene",
|
||||||
|
ExtraType.Sample => "NameExtraSample",
|
||||||
|
ExtraType.ThemeSong => "NameExtraThemeSong",
|
||||||
|
ExtraType.ThemeVideo => "NameExtraThemeVideo",
|
||||||
|
ExtraType.Featurette => "NameExtraFeaturette",
|
||||||
|
ExtraType.Short => "NameExtraShort",
|
||||||
|
_ => "NameExtraUnknown"
|
||||||
|
};
|
||||||
|
|
||||||
public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem)
|
public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem)
|
||||||
{
|
{
|
||||||
foreach (var map in _configurationManager.Configuration.PathSubstitutions)
|
foreach (var map in _configurationManager.Configuration.PathSubstitutions)
|
||||||
@@ -3418,12 +3668,24 @@ 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)
|
||||||
{
|
{
|
||||||
return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes);
|
return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds)
|
||||||
|
{
|
||||||
|
return _peopleRepository.GetPeopleByItems(itemIds);
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdatePeople(BaseItem item, List<PersonInfo> people)
|
public void UpdatePeople(BaseItem item, List<PersonInfo> people)
|
||||||
{
|
{
|
||||||
UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult();
|
UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
@@ -3457,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)
|
||||||
{
|
{
|
||||||
@@ -3479,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)
|
||||||
@@ -3535,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
|
||||||
{
|
{
|
||||||
@@ -3561,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)
|
||||||
{
|
{
|
||||||
@@ -3615,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;
|
||||||
|
|
||||||
@@ -3659,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);
|
||||||
|
|
||||||
@@ -3680,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);
|
||||||
|
|
||||||
@@ -3719,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");
|
||||||
}
|
}
|
||||||
@@ -3731,6 +4002,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.Delete(path, true);
|
Directory.Delete(path, true);
|
||||||
|
_directoryService.Invalidate(path);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -3785,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));
|
||||||
@@ -3800,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);
|
||||||
@@ -3843,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3886,5 +4160,19 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType);
|
return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query)
|
||||||
|
{
|
||||||
|
if (query.User is not null)
|
||||||
|
{
|
||||||
|
AddUserToQuery(query, query.User);
|
||||||
|
}
|
||||||
|
|
||||||
|
SetTopParentOrAncestorIds(query);
|
||||||
|
return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ExtraCandidate(BaseItem Extra, ExtraType ExtraType, ExtraRule ExtraRule, bool IsInMixedFolder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,7 +229,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
list.Add(source);
|
list.Add(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SortMediaSources(list, item.Id).ToArray();
|
var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId)
|
||||||
|
? topSourceId
|
||||||
|
: item.Id;
|
||||||
|
|
||||||
|
return SortMediaSources(list, preferredId).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />>
|
/// <inheritdoc />>
|
||||||
@@ -406,6 +410,59 @@ namespace Emby.Server.Implementations.Library
|
|||||||
source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing);
|
source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sources = SetAlternateVersionResumeStates(item, sources, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the queried item is a primary, moves the most recently played version to the front so
|
||||||
|
/// that resuming without an explicit source selection plays the version that was last watched.
|
||||||
|
/// A directly queried alternate version keeps its own source first. Per-user playback position
|
||||||
|
/// is not surfaced on the source itself; it is carried by each version's own UserData.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The queried item.</param>
|
||||||
|
/// <param name="sources">The item's media sources.</param>
|
||||||
|
/// <param name="user">The user.</param>
|
||||||
|
/// <returns>The media sources, reordered when a version drives resume.</returns>
|
||||||
|
private IReadOnlyList<MediaSourceInfo> SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList<MediaSourceInfo> sources, User user)
|
||||||
|
{
|
||||||
|
// For a video, multiple sources means alternate versions.
|
||||||
|
if (item is not Video video || sources.Count < 2)
|
||||||
|
{
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
var versions = video.GetAllVersions();
|
||||||
|
if (versions.Count < 2)
|
||||||
|
{
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
|
||||||
|
var dataBySourceId = new Dictionary<string, UserItemData>(versions.Count, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var version in versions)
|
||||||
|
{
|
||||||
|
if (userDataByVersion.TryGetValue(version.Id, out var data))
|
||||||
|
{
|
||||||
|
dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder only for a resumable (in-progress) version;
|
||||||
|
// a completed version has no position to resume, so it must not be pulled to the front here.
|
||||||
|
var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||||
|
sources,
|
||||||
|
source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null,
|
||||||
|
data => data.PlaybackPositionTicks > 0);
|
||||||
|
|
||||||
|
if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource))
|
||||||
|
{
|
||||||
|
var reordered = new List<MediaSourceInfo>(sources.Count) { resumeSource };
|
||||||
|
reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource)));
|
||||||
|
return reordered;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sources;
|
return sources;
|
||||||
|
|||||||
@@ -29,17 +29,41 @@ namespace Emby.Server.Implementations.Library
|
|||||||
throw new ArgumentException("String can't be empty.", nameof(attribute));
|
throw new ArgumentException("String can't be empty.", nameof(attribute));
|
||||||
}
|
}
|
||||||
|
|
||||||
var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
|
// Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc.
|
||||||
|
// The code below only supports aliases for attributes in the form of "<alias>id".
|
||||||
// Must be at least 3 characters after the attribute =, ], any character,
|
ReadOnlySpan<char> shortAttr = attribute switch
|
||||||
// then we offset it by 1, because we want the index and not length.
|
|
||||||
var maxIndex = str.Length - attribute.Length - 2;
|
|
||||||
while (attributeIndex > -1 && attributeIndex < maxIndex)
|
|
||||||
{
|
{
|
||||||
var attributeEnd = attributeIndex + attribute.Length;
|
_ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb",
|
||||||
|
_ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
|
||||||
|
_ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
|
||||||
|
_ => ReadOnlySpan<char>.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;)
|
||||||
|
{
|
||||||
|
// We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'.
|
||||||
|
var subStr = str[strIndex..];
|
||||||
|
int attributeEnd = 0;
|
||||||
|
|
||||||
|
if (shortAttr.Length > 0)
|
||||||
|
{
|
||||||
|
// If we are using an alias it should be shorter (and a prefix), so let's search for that.
|
||||||
|
attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase);
|
||||||
|
attributeEnd = attributeIndex + shortAttr.Length;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
|
||||||
|
attributeEnd = attributeIndex + attribute.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next iteration should start at the end of the attribute we just found.
|
||||||
|
// If attributeIndex < 0, the loop will end and strIndex won't be used again.
|
||||||
|
strIndex += attributeEnd;
|
||||||
|
|
||||||
if (attributeIndex > 0)
|
if (attributeIndex > 0)
|
||||||
{
|
{
|
||||||
var attributeOpener = str[attributeIndex - 1];
|
var attributeOpener = subStr[attributeIndex - 1];
|
||||||
var attributeCloser = attributeOpener switch
|
var attributeCloser = attributeOpener switch
|
||||||
{
|
{
|
||||||
'[' => ']',
|
'[' => ']',
|
||||||
@@ -47,20 +71,37 @@ namespace Emby.Server.Implementations.Library
|
|||||||
'{' => '}',
|
'{' => '}',
|
||||||
_ => '\0'
|
_ => '\0'
|
||||||
};
|
};
|
||||||
if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
|
|
||||||
{
|
|
||||||
var closingIndex = str[attributeEnd..].IndexOf(attributeCloser);
|
|
||||||
|
|
||||||
// Must be at least 1 character before the closing bracket.
|
if (attributeCloser != '\0')
|
||||||
if (closingIndex > 1)
|
{
|
||||||
|
if (shortAttr.Length > 0
|
||||||
|
&& attributeEnd + 1 < subStr.Length
|
||||||
|
&& (subStr[attributeEnd] is 'i' or 'I')
|
||||||
|
&& (subStr[attributeEnd + 1] is 'd' or 'D'))
|
||||||
{
|
{
|
||||||
return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
|
// We were searching for a shortened attribute, but it's followed by "id" - let's skip it.
|
||||||
|
attributeEnd += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// attributeEnd points at '='.
|
||||||
|
// We need at least 1 more character and the closing bracket after that.
|
||||||
|
if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-'))
|
||||||
|
{
|
||||||
|
var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser);
|
||||||
|
|
||||||
|
// Must be at least 1 character before the closing bracket.
|
||||||
|
if (closingIndex > 1)
|
||||||
|
{
|
||||||
|
var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim();
|
||||||
|
|
||||||
|
if (trimmed.Length > 0)
|
||||||
|
{
|
||||||
|
return trimmed.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
str = str[attributeEnd..];
|
|
||||||
attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// for imdbid we also accept pattern matching
|
// for imdbid we also accept pattern matching
|
||||||
@@ -70,16 +111,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return match ? imdbId.ToString() : null;
|
return match ? imdbId.ToString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow tmdb as an alias for tmdbid
|
|
||||||
if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var tmdbValue = str.GetAttributeValue("tmdb");
|
|
||||||
if (tmdbValue is not null)
|
|
||||||
{
|
|
||||||
return tmdbValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -43,7 +44,19 @@ public class PathManager : IPathManager
|
|||||||
public string? GetAttachmentPath(string mediaSourceId, string fileName)
|
public string? GetAttachmentPath(string mediaSourceId, string fileName)
|
||||||
{
|
{
|
||||||
var folder = GetAttachmentFolderPath(mediaSourceId);
|
var folder = GetAttachmentFolderPath(mediaSourceId);
|
||||||
return folder is null ? null : Path.Combine(folder, fileName);
|
if (folder is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeName = PathHelper.GetSafeLeafFileName(fileName);
|
||||||
|
if (safeName is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(folder, safeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
|||||||
: base(logger, namingOptions, directoryService)
|
: base(logger, namingOptions, directoryService)
|
||||||
{
|
{
|
||||||
_namingOptions = namingOptions;
|
_namingOptions = namingOptions;
|
||||||
_trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService) };
|
_trailerResolvers = [new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService, parseName: true)];
|
||||||
_videoResolvers = new IItemResolver[] { this };
|
_videoResolvers = [this];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Video Resolve(ItemResolveArgs args)
|
protected override Video Resolve(ItemResolveArgs args)
|
||||||
@@ -54,12 +54,13 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
|||||||
_ => _videoResolvers
|
_ => _videoResolvers
|
||||||
};
|
};
|
||||||
|
|
||||||
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "")
|
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, [NotNullWhen(true)] out ExtraRule? extraRule, string? libraryRoot = "")
|
||||||
{
|
{
|
||||||
var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot);
|
var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot);
|
||||||
if (extraResult.ExtraType is null)
|
if (extraResult.ExtraType is null || extraResult.Rule is null)
|
||||||
{
|
{
|
||||||
extraType = null;
|
extraType = null;
|
||||||
|
extraRule = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
|||||||
}
|
}
|
||||||
|
|
||||||
extraType = extraResult.ExtraType;
|
extraType = extraResult.ExtraType;
|
||||||
|
extraRule = extraResult.Rule;
|
||||||
return isValid;
|
return isValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
using Emby.Naming.Common;
|
using Emby.Naming.Common;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -14,15 +15,25 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
|||||||
public class GenericVideoResolver<T> : BaseVideoResolver<T>
|
public class GenericVideoResolver<T> : BaseVideoResolver<T>
|
||||||
where T : Video, new()
|
where T : Video, new()
|
||||||
{
|
{
|
||||||
|
private readonly bool _parseName;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class.
|
/// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="logger">The logger.</param>
|
/// <param name="logger">The logger.</param>
|
||||||
/// <param name="namingOptions">The naming options.</param>
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
/// <param name="directoryService">The directory service.</param>
|
/// <param name="directoryService">The directory service.</param>
|
||||||
public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService)
|
/// <param name="parseName">Whether to parse the file name for metadata such as the year.</param>
|
||||||
|
public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService, bool parseName = false)
|
||||||
: base(logger, namingOptions, directoryService)
|
: base(logger, namingOptions, directoryService)
|
||||||
{
|
{
|
||||||
|
_parseName = parseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override T Resolve(ItemResolveArgs args)
|
||||||
|
{
|
||||||
|
return ResolveVideo<T>(args, _parseName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,15 +376,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
|||||||
// We need to only look at the name of this actual item (not parents)
|
// We need to only look at the name of this actual item (not parents)
|
||||||
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
|
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
|
||||||
|
|
||||||
var tmdbid = justName.GetAttributeValue("tmdbid");
|
// The fallback filename is only used when the item isn't in a mixed folder
|
||||||
|
var fileName = item.IsInMixedFolder ? ReadOnlySpan<char>.Empty : Path.GetFileName(item.Path.AsSpan());
|
||||||
|
|
||||||
// If not in a mixed folder and ID not found in folder path, check filename
|
item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid"));
|
||||||
if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
|
item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid"));
|
||||||
|
|
||||||
|
string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute)
|
||||||
{
|
{
|
||||||
tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
|
var id = name.GetAttributeValue(attribute);
|
||||||
}
|
|
||||||
|
|
||||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
|
// If not in a mixed folder and ID not found in folder path, check filename
|
||||||
|
if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder)
|
||||||
|
{
|
||||||
|
id = fallbackName.GetAttributeValue(attribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(item.Path))
|
if (!string.IsNullOrEmpty(item.Path))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Emby.Server.Implementations.Playlists;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
@@ -45,8 +47,30 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anything directly inside the internal playlists folder is a playlist, even when its
|
||||||
|
// playlist.xml is missing: failing to resolve here makes the library scan treat the
|
||||||
|
// playlist as deleted from disk and remove it, taking its items with it.
|
||||||
|
if (args.Parent is PlaylistsFolder)
|
||||||
|
{
|
||||||
|
return new Playlist
|
||||||
|
{
|
||||||
|
Path = args.Path,
|
||||||
|
Name = filename,
|
||||||
|
OpenAccess = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// It's a directory-based playlist if the directory contains a playlist file
|
// It's a directory-based playlist if the directory contains a playlist file
|
||||||
var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
|
IEnumerable<string> filePaths;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase)))
|
if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
return new Playlist
|
return new Playlist
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,49 +92,45 @@ public class SearchManager : ISearchManager
|
|||||||
await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
|
await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
|
||||||
|
|
||||||
var externalResults = await externalTask.ConfigureAwait(false);
|
var externalResults = await externalTask.ConfigureAwait(false);
|
||||||
var fromExternal = externalResults.Count > 0;
|
|
||||||
IReadOnlyList<SearchResult> results;
|
|
||||||
if (fromExternal)
|
|
||||||
{
|
|
||||||
results = externalResults;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
results = await internalTask.ConfigureAwait(false);
|
|
||||||
if (_internalProviders.Length > 0)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("No results from external providers, using internal provider results");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Internal providers apply user-access filtering inline in their queries. External
|
// Internal providers apply user-access filtering inline in their queries. External
|
||||||
// providers don't know about user permissions, so they may return IDs from hidden
|
// providers don't know about user permissions, so they may return IDs from hidden
|
||||||
// libraries or items the user is otherwise blocked from. Run the post-filter only
|
// libraries or items the user is otherwise blocked from. Filter them here to close
|
||||||
// when results came from externals to close that gap. The Items controller's second
|
// that gap. The Items controller's second roundtrip via folder.GetItems applies most
|
||||||
// roundtrip via folder.GetItems applies most of these again, but it does not restrict
|
// of these again, but it does not restrict by TopParentIds when ItemIds is set.
|
||||||
// by TopParentIds when ItemIds is set.
|
if (externalResults.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
|
||||||
if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
|
|
||||||
{
|
{
|
||||||
var user = _userManager.GetUserById(query.UserId.Value);
|
var user = _userManager.GetUserById(query.UserId.Value);
|
||||||
if (user is not null)
|
if (user is not null)
|
||||||
{
|
{
|
||||||
results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
|
externalResults = await FilterByUserAccessAsync(externalResults, user, query, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
if (externalResults.Count > 0)
|
||||||
|
{
|
||||||
|
return externalResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_internalProviders.Length > 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No results from external providers, using internal provider results");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await internalTask.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
||||||
IReadOnlyList<SearchResult> candidates,
|
IReadOnlyList<SearchResult> candidates,
|
||||||
User user,
|
User user,
|
||||||
|
SearchProviderQuery query,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
|
// SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds
|
||||||
// TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
|
// for the user's accessible libraries. The candidate ids are applied to the query below
|
||||||
// because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
|
// rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when
|
||||||
var accessFilter = new InternalItemsQuery(user);
|
// ItemIds is non-empty.
|
||||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager);
|
||||||
|
|
||||||
Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
|
Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
|
||||||
|
|
||||||
@@ -147,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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library.Search;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the access filter that decides which items a search may return for a user.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SearchQueryAccessFilter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an access filter carrying the search's library access and type filters.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user the search runs for.</param>
|
||||||
|
/// <param name="query">The search query.</param>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
/// <returns>The access filter.</returns>
|
||||||
|
public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager)
|
||||||
|
{
|
||||||
|
// The type filters have to travel with the access filter: a by-name item belongs to no
|
||||||
|
// library, so it carries no TopParentId to match, and the library filter only knows to
|
||||||
|
// exempt it when the query says those types are wanted. A search scoped to a parent gets
|
||||||
|
// no exemption because a by-name item has no parent to descend from either.
|
||||||
|
var accessFilter = new InternalItemsQuery(user)
|
||||||
|
{
|
||||||
|
IncludeItemTypes = query.IncludeItemTypes,
|
||||||
|
ExcludeItemTypes = query.ExcludeItemTypes,
|
||||||
|
IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty()
|
||||||
|
};
|
||||||
|
|
||||||
|
// ConfigureUserAccess populates TopParentIds for the libraries the user may open.
|
||||||
|
libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||||
|
|
||||||
|
return accessFilter;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,7 +114,7 @@ public class SqlSearchProvider : IInternalSearchProvider
|
|||||||
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
|
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
|
||||||
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
|
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
|
||||||
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
|
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
|
||||||
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);
|
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query);
|
||||||
|
|
||||||
// Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
|
// Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
|
||||||
// the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
|
// the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
|
||||||
@@ -196,8 +196,9 @@ public class SqlSearchProvider : IInternalSearchProvider
|
|||||||
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
|
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
|
||||||
JellyfinDbContext dbContext,
|
JellyfinDbContext dbContext,
|
||||||
IQueryable<BaseItemEntity> query,
|
IQueryable<BaseItemEntity> query,
|
||||||
Guid? userId)
|
SearchProviderQuery searchQuery)
|
||||||
{
|
{
|
||||||
|
var userId = searchQuery.UserId;
|
||||||
if (!userId.HasValue || userId.Value.IsEmpty())
|
if (!userId.HasValue || userId.Value.IsEmpty())
|
||||||
{
|
{
|
||||||
return query;
|
return query;
|
||||||
@@ -209,8 +210,7 @@ public class SqlSearchProvider : IInternalSearchProvider
|
|||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
var accessFilter = new InternalItemsQuery(user);
|
var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager);
|
||||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
|
||||||
return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
|
return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
|||||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||||
private readonly IItemQueryHelpers _queryHelpers;
|
private readonly IItemQueryHelpers _queryHelpers;
|
||||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
|
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
|
||||||
@@ -60,14 +61,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
|||||||
/// <param name="dbProvider">The database context factory.</param>
|
/// <param name="dbProvider">The database context factory.</param>
|
||||||
/// <param name="queryHelpers">The shared query helpers.</param>
|
/// <param name="queryHelpers">The shared query helpers.</param>
|
||||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
public MovieSimilarItemsProvider(
|
public MovieSimilarItemsProvider(
|
||||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
IItemQueryHelpers queryHelpers,
|
IItemQueryHelpers queryHelpers,
|
||||||
IServerConfigurationManager serverConfigurationManager)
|
IServerConfigurationManager serverConfigurationManager,
|
||||||
|
ILibraryManager libraryManager)
|
||||||
{
|
{
|
||||||
_dbProvider = dbProvider;
|
_dbProvider = dbProvider;
|
||||||
_queryHelpers = queryHelpers;
|
_queryHelpers = queryHelpers;
|
||||||
_serverConfigurationManager = serverConfigurationManager;
|
_serverConfigurationManager = serverConfigurationManager;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -156,6 +160,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
|||||||
IsPlayed = false
|
IsPlayed = false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (query.User is not null)
|
||||||
|
{
|
||||||
|
_libraryManager.ConfigureUserAccess(filter, query.User);
|
||||||
|
}
|
||||||
|
|
||||||
_queryHelpers.PrepareFilterQuery(filter);
|
_queryHelpers.PrepareFilterQuery(filter);
|
||||||
var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
|
var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
|
||||||
baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
|
baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
|
||||||
@@ -251,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);
|
||||||
|
|
||||||
@@ -267,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/>
|
||||||
@@ -183,6 +195,7 @@ public class SimilarItemsManager : ISimilarItemsManager
|
|||||||
// Collect references in batches and resolve against local library.
|
// Collect references in batches and resolve against local library.
|
||||||
// Stop fetching once we have enough resolved local items.
|
// Stop fetching once we have enough resolved local items.
|
||||||
const int BatchSize = 20;
|
const int BatchSize = 20;
|
||||||
|
const int MaxRemoteReferenceFetchLimit = 500;
|
||||||
var remaining = requestedLimit - allResults.Count;
|
var remaining = requestedLimit - allResults.Count;
|
||||||
var collectedReferences = new List<SimilarItemReference>();
|
var collectedReferences = new List<SimilarItemReference>();
|
||||||
var pendingBatch = new List<SimilarItemReference>();
|
var pendingBatch = new List<SimilarItemReference>();
|
||||||
@@ -199,7 +212,7 @@ public class SimilarItemsManager : ISimilarItemsManager
|
|||||||
remaining -= resolvedItems.Count;
|
remaining -= resolvedItems.Count;
|
||||||
pendingBatch.Clear();
|
pendingBatch.Clear();
|
||||||
|
|
||||||
if (remaining <= 0)
|
if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -229,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/>
|
||||||
@@ -375,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,91 +171,189 @@ 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 userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault();
|
|
||||||
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 under keys from older metadata resolve the same
|
||||||
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
|
// way as the single item path.
|
||||||
var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList();
|
var itemIds = items.Select(e => e.Id).Distinct().ToList();
|
||||||
if (allKeys.Count > 0)
|
using var context = _repository.CreateDbContext();
|
||||||
|
var userDataByItem = context.UserData
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.UserId.Equals(user.Id))
|
||||||
|
.WhereOneOrMany(itemIds, e => e.ItemId)
|
||||||
|
.ToArray()
|
||||||
|
.GroupBy(e => e.ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToArray());
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
using var context = _repository.CreateDbContext();
|
if (result.ContainsKey(item.Id))
|
||||||
var userDataArray = context.UserData
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(e => e.UserId.Equals(user.Id))
|
|
||||||
.WhereOneOrMany(allItemIds, e => e.ItemId)
|
|
||||||
.WhereOneOrMany(allKeys, e => e.CustomDataKey)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
|
|
||||||
foreach (var (item, keys) in itemsNeedingQuery)
|
|
||||||
{
|
{
|
||||||
UserItemData userData;
|
continue;
|
||||||
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
|
}
|
||||||
{
|
|
||||||
var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N"));
|
|
||||||
userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public VersionResumeData? GetResumeUserData(User user, BaseItem item)
|
||||||
|
{
|
||||||
|
return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
|
||||||
|
var result = new Dictionary<Guid, VersionResumeData>();
|
||||||
|
|
||||||
|
// Candidate primaries: a directly queried version (PrimaryVersionId set) keeps its own data.
|
||||||
|
// Linked alternates are already known in memory; only the local-alternate existence check
|
||||||
|
// would otherwise hit the database (one query per item via Video.HasLocalAlternateVersions),
|
||||||
|
// so collect those ids and resolve them all in a single query below.
|
||||||
|
List<Video>? candidates = null;
|
||||||
|
List<Guid>? localProbeIds = null;
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (item is not Video video || video.PrimaryVersionId.HasValue)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
(candidates ??= []).Add(video);
|
||||||
|
|
||||||
|
if (video.LinkedAlternateVersions.Length == 0)
|
||||||
|
{
|
||||||
|
(localProbeIds ??= []).Add(video.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates is null)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<Guid>? withLocalAlternates = null;
|
||||||
|
if (localProbeIds is not null)
|
||||||
|
{
|
||||||
|
using var dbContext = _repository.CreateDbContext();
|
||||||
|
withLocalAlternates = dbContext.LinkedChildren
|
||||||
|
.Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion)
|
||||||
|
.WhereOneOrMany(localProbeIds, lc => lc.ParentId)
|
||||||
|
.Select(lc => lc.ParentId)
|
||||||
|
.Distinct()
|
||||||
|
.ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(Guid PrimaryId, IReadOnlyList<Video> Versions)>? versionGroups = null;
|
||||||
|
List<BaseItem>? allVersions = null;
|
||||||
|
|
||||||
|
foreach (var video in candidates)
|
||||||
|
{
|
||||||
|
// Only items that actually have alternate versions aggregate over them.
|
||||||
|
if (video.LinkedAlternateVersions.Length == 0
|
||||||
|
&& (withLocalAlternates is null || !withLocalAlternates.Contains(video.Id)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var versions = video.GetAllVersions();
|
||||||
|
if (versions.Count < 2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
(versionGroups ??= []).Add((video.Id, versions));
|
||||||
|
(allVersions ??= []).AddRange(versions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionGroups is null)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDataByVersion = GetUserDataBatch(allVersions!.DistinctBy(i => i.Id).ToList(), user);
|
||||||
|
|
||||||
|
foreach (var (primaryId, versions) in versionGroups)
|
||||||
|
{
|
||||||
|
// Consider both in-progress and completed versions so a finished alternate still marks the primary as played.
|
||||||
|
var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||||
|
versions,
|
||||||
|
version => userDataByVersion.GetValueOrDefault(version.Id),
|
||||||
|
data => data.PlaybackPositionTicks > 0 || data.Played);
|
||||||
|
|
||||||
|
if (resumeVersion is not null)
|
||||||
|
{
|
||||||
|
result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
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()
|
||||||
{
|
{
|
||||||
Key = item.GetUserDataKeys()[0],
|
Key = item.GetUserDataKeys()[0],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks the row matching the item's current user data keys, in key order, so rows left behind
|
||||||
|
/// under keys from older metadata don't take priority over the rows the write path updates.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item whose keys to match.</param>
|
||||||
|
/// <param name="rows">The candidate user data rows for a single user.</param>
|
||||||
|
/// <returns>The best matching row, or <c>null</c> when there are none.</returns>
|
||||||
|
private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows)
|
||||||
|
{
|
||||||
|
var candidates = rows?.ToList();
|
||||||
|
if (candidates is null || candidates.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var key in item.GetUserDataKeys())
|
||||||
|
{
|
||||||
|
var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal));
|
||||||
|
if (match is not null)
|
||||||
|
{
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
|
public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
|
||||||
=> GetUserDataDto(item, null, user, new DtoOptions());
|
=> GetUserDataDto(item, null, user, new DtoOptions());
|
||||||
@@ -281,6 +370,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var dto = GetUserItemDataDto(userData, item.Id);
|
var dto = GetUserItemDataDto(userData, item.Id);
|
||||||
|
|
||||||
item.FillUserDataDtoValues(dto, userData, itemDto, user, options);
|
item.FillUserDataDtoValues(dto, userData, itemDto, user, options);
|
||||||
|
|
||||||
|
// For an item with alternate versions, surface the most recently played version's resume point.
|
||||||
|
GetResumeUserData(user, item)?.ApplyTo(dto);
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,5 +478,31 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
return playedToCompletion;
|
return playedToCompletion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ResetPlaybackStreamSelections(User user, BaseItem item)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
|
||||||
|
using var dbContext = _repository.CreateDbContext();
|
||||||
|
var rows = dbContext.UserData
|
||||||
|
.Where(e => e.ItemId == item.Id && e.UserId == user.Id
|
||||||
|
&& (e.AudioStreamIndex != null || e.SubtitleStreamIndex != null))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (rows.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
row.AudioStreamIndex = null;
|
||||||
|
row.SubtitleStreamIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.SaveChanges();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures top-level library folders have a primary poster after scans.
|
||||||
|
/// Poster extraction is attempted before library scanning. When a library is
|
||||||
|
/// empty at that point, no poster can be extracted. This post-scan task reruns
|
||||||
|
/// metadata extraction for top-level folders that are still missing images.
|
||||||
|
/// </summary>
|
||||||
|
public class CollectionPosterVerifyPostScanTask : ILibraryPostScanTask
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly ILogger<CollectionPosterVerifyPostScanTask> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CollectionPosterVerifyPostScanTask" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public CollectionPosterVerifyPostScanTask(
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
ILogger<CollectionPosterVerifyPostScanTask> logger)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the specified progress.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="progress">The progress.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>Task.</returns>
|
||||||
|
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var libraries = _libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().ToList();
|
||||||
|
var totalLibraries = libraries.Count;
|
||||||
|
var processedLibraries = 0;
|
||||||
|
|
||||||
|
foreach (var library in libraries)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!library.HasImage(ImageType.Primary))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Library {LibraryName} is missing a primary image. Refreshing metadata.", library.Name);
|
||||||
|
await library.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
processedLibraries++;
|
||||||
|
progress.Report((double)processedLibraries / totalLibraries * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.Report(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
"TaskAudioNormalization": "تطبيع الصوت",
|
"TaskAudioNormalization": "تطبيع الصوت",
|
||||||
"TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.",
|
"TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.",
|
||||||
"TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة",
|
"TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة",
|
||||||
"TaskDownloadMissingLyricsDescription": "ينزّل الكلمات للأغاني.",
|
"TaskDownloadMissingLyricsDescription": "تحميل كلمات الأغاني",
|
||||||
"TaskExtractMediaSegments": "فحص مقاطع المحتوى",
|
"TaskExtractMediaSegments": "فحص مقاطع المحتوى",
|
||||||
"TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).",
|
"TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).",
|
||||||
"TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل",
|
"TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل",
|
||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
|
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
|
||||||
"CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.",
|
"CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.",
|
||||||
"Original": "فريد",
|
"Original": "فريد",
|
||||||
"LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}"
|
"LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}",
|
||||||
|
"NameExtraBehindTheScenes": "خلف المشاهد",
|
||||||
|
"NameExtraClip": "مقطع",
|
||||||
|
"NameExtraDeletedScene": "المشهد المحذوف",
|
||||||
|
"NameExtraInterview": "مقابلة",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "عيّنة",
|
||||||
|
"NameExtraScene": "مشهد",
|
||||||
|
"NameExtraShort": "قصير",
|
||||||
|
"NameExtraThemeSong": "الاغنية السمة",
|
||||||
|
"NameExtraThemeVideo": "الفيديو السمة",
|
||||||
|
"NameExtraFeaturette": "فيلم قصير إضافي",
|
||||||
|
"NameExtraTrailer": "إعلان ترويجي",
|
||||||
|
"NameExtraUnknown": "إضافي"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.",
|
"CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.",
|
||||||
"CleanupUserDataTask": "Tasca de neteja de dades d'usuari",
|
"CleanupUserDataTask": "Tasca de neteja de dades d'usuari",
|
||||||
"Original": "Original",
|
"Original": "Original",
|
||||||
"LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}"
|
"LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}",
|
||||||
|
"NameExtraBehindTheScenes": "Rere les càmeres",
|
||||||
|
"NameExtraClip": "Tall",
|
||||||
|
"NameExtraDeletedScene": "Escena eliminada",
|
||||||
|
"NameExtraFeaturette": "Migmetratge",
|
||||||
|
"NameExtraInterview": "Entrevista",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Mostra",
|
||||||
|
"NameExtraScene": "Escena",
|
||||||
|
"NameExtraShort": "Curt",
|
||||||
|
"NameExtraThemeSong": "Tema musical",
|
||||||
|
"NameExtraThemeVideo": "Vídeo temàtic",
|
||||||
|
"NameExtraTrailer": "Tràiler",
|
||||||
|
"NameExtraUnknown": "Extra"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.",
|
"CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.",
|
||||||
"CleanupUserDataTask": "Pročistit uživatelská data",
|
"CleanupUserDataTask": "Pročistit uživatelská data",
|
||||||
"Original": "Originál",
|
"Original": "Originál",
|
||||||
"LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}"
|
"LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}",
|
||||||
|
"NameExtraBehindTheScenes": "Zákulisí",
|
||||||
|
"NameExtraClip": "Klip",
|
||||||
|
"NameExtraDeletedScene": "Vymazaná scéna",
|
||||||
|
"NameExtraFeaturette": "Featurette",
|
||||||
|
"NameExtraInterview": "Rozhovor",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Ukázka",
|
||||||
|
"NameExtraScene": "Scéna",
|
||||||
|
"NameExtraShort": "Krátké",
|
||||||
|
"NameExtraThemeSong": "Úvodní píseň",
|
||||||
|
"NameExtraThemeVideo": "Úvodní video",
|
||||||
|
"NameExtraTrailer": "Upoutávka",
|
||||||
|
"NameExtraUnknown": "Extra"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"Favorites": "Favoritter",
|
"Favorites": "Favoritter",
|
||||||
"Folders": "Mapper",
|
"Folders": "Mapper",
|
||||||
"Genres": "Genrer",
|
"Genres": "Genrer",
|
||||||
"HeaderContinueWatching": "Fortsæt afspilning",
|
"HeaderContinueWatching": "Fortsæt med at se",
|
||||||
"HeaderFavoriteEpisodes": "Yndlingsafsnit",
|
"HeaderFavoriteEpisodes": "Yndlingsafsnit",
|
||||||
"HeaderFavoriteShows": "Yndlingsserier",
|
"HeaderFavoriteShows": "Yndlingsserier",
|
||||||
"HeaderLiveTV": "Live-TV",
|
"HeaderLiveTV": "Live-TV",
|
||||||
@@ -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}",
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.",
|
"TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.",
|
||||||
"TaskDownloadMissingSubtitles": "Hent manglende undertekster",
|
"TaskDownloadMissingSubtitles": "Hent manglende undertekster",
|
||||||
"TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.",
|
"TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.",
|
||||||
"TaskUpdatePlugins": "Opdater plugins",
|
"TaskUpdatePlugins": "Opdatér plugins",
|
||||||
"TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.",
|
"TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.",
|
||||||
"TaskCleanLogs": "Ryd log-mappe",
|
"TaskCleanLogs": "Ryd log-mappe",
|
||||||
"TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.",
|
"TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.",
|
||||||
@@ -79,10 +79,10 @@
|
|||||||
"TaskRefreshChapterImages": "Udtræk kapitelbilleder",
|
"TaskRefreshChapterImages": "Udtræk kapitelbilleder",
|
||||||
"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": "Opdater 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": "Opdater 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.",
|
||||||
"TaskCleanActivityLog": "Ryd aktivitetslog",
|
"TaskCleanActivityLog": "Ryd aktivitetslog",
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
"Forced": "Tvunget",
|
"Forced": "Tvunget",
|
||||||
"Default": "Standard",
|
"Default": "Standard",
|
||||||
"TaskOptimizeDatabaseDescription": "Komprimerer databasen for at frigøre plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen.",
|
"TaskOptimizeDatabaseDescription": "Komprimerer databasen for at frigøre plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen.",
|
||||||
"TaskOptimizeDatabase": "Optimer database",
|
"TaskOptimizeDatabase": "Optimér database",
|
||||||
"TaskKeyframeExtractorDescription": "Udtrækker rammer fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.",
|
"TaskKeyframeExtractorDescription": "Udtrækker rammer fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.",
|
||||||
"TaskKeyframeExtractor": "Udtræk nøglerammer",
|
"TaskKeyframeExtractor": "Udtræk nøglerammer",
|
||||||
"External": "Ekstern",
|
"External": "Ekstern",
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
"TaskRefreshTrickplayImagesDescription": "Laver trickplay-billeder for videoer i aktiverede biblioteker.",
|
"TaskRefreshTrickplayImagesDescription": "Laver trickplay-billeder for videoer i aktiverede biblioteker.",
|
||||||
"TaskAudioNormalizationDescription": "Skanner filer for data vedrørende lydnormalisering.",
|
"TaskAudioNormalizationDescription": "Skanner filer for data vedrørende lydnormalisering.",
|
||||||
"TaskAudioNormalization": "Lydnormalisering",
|
"TaskAudioNormalization": "Lydnormalisering",
|
||||||
"TaskDownloadMissingLyricsDescription": "Søger på internettet efter manglende sangtekster baseret på metadata-konfigurationen",
|
"TaskDownloadMissingLyricsDescription": "Download sangtekster",
|
||||||
"TaskDownloadMissingLyrics": "Hent manglende sangtekster",
|
"TaskDownloadMissingLyrics": "Hent manglende sangtekster",
|
||||||
"TaskExtractMediaSegments": "Scan for mediesegmenter",
|
"TaskExtractMediaSegments": "Scan for mediesegmenter",
|
||||||
"TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder",
|
"TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder",
|
||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTask": "Brugerdata oprydningsopgave",
|
"CleanupUserDataTask": "Brugerdata oprydningsopgave",
|
||||||
"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",
|
||||||
|
"NameExtraClip": "Klip",
|
||||||
|
"NameExtraDeletedScene": "Slettet scene",
|
||||||
|
"NameExtraFeaturette": "Featurette",
|
||||||
|
"NameExtraInterview": "Interview",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Smagsprøve",
|
||||||
|
"NameExtraScene": "Scene",
|
||||||
|
"NameExtraShort": "Kort",
|
||||||
|
"NameExtraThemeSong": "Temasang",
|
||||||
|
"NameExtraThemeVideo": "Temavideo",
|
||||||
|
"NameExtraTrailer": "Trailer",
|
||||||
|
"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 @@
|
|||||||
"CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten",
|
"CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten",
|
||||||
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.",
|
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.",
|
||||||
"Original": "Original",
|
"Original": "Original",
|
||||||
"LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}"
|
"LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}",
|
||||||
|
"NameExtraBehindTheScenes": "Behind The Scenes",
|
||||||
|
"NameExtraDeletedScene": "Entfernte Szene",
|
||||||
|
"NameExtraInterview": "Interview",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Ausschnitt",
|
||||||
|
"NameExtraScene": "Szene",
|
||||||
|
"NameExtraShort": "Kurzfilm",
|
||||||
|
"NameExtraThemeSong": "Titellied",
|
||||||
|
"NameExtraThemeVideo": "Titelvideo",
|
||||||
|
"NameExtraTrailer": "Trailer",
|
||||||
|
"NameExtraUnknown": "Extra",
|
||||||
|
"NameExtraClip": "Clip",
|
||||||
|
"NameExtraFeaturette": "Hinter den Kulissen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
"Movies": "Movies",
|
"Movies": "Movies",
|
||||||
"Music": "Music",
|
"Music": "Music",
|
||||||
"MusicVideos": "Music Videos",
|
"MusicVideos": "Music Videos",
|
||||||
|
"NameExtraBehindTheScenes": "Behind The Scenes",
|
||||||
|
"NameExtraClip": "Clip",
|
||||||
|
"NameExtraDeletedScene": "Deleted Scene",
|
||||||
|
"NameExtraFeaturette": "Featurette",
|
||||||
|
"NameExtraInterview": "Interview",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Sample",
|
||||||
|
"NameExtraScene": "Scene",
|
||||||
|
"NameExtraShort": "Short",
|
||||||
|
"NameExtraThemeSong": "Theme Song",
|
||||||
|
"NameExtraThemeVideo": "Theme Video",
|
||||||
|
"NameExtraTrailer": "Trailer",
|
||||||
|
"NameExtraUnknown": "Extra",
|
||||||
"NameInstallFailed": "{0} installation failed",
|
"NameInstallFailed": "{0} installation failed",
|
||||||
"NameSeasonNumber": "Season {0}",
|
"NameSeasonNumber": "Season {0}",
|
||||||
"NameSeasonUnknown": "Season Unknown",
|
"NameSeasonUnknown": "Season Unknown",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
|
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
|
||||||
"CleanupUserDataTask": "Tarea de limpieza de datos de usuarios",
|
"CleanupUserDataTask": "Tarea de limpieza de datos de usuarios",
|
||||||
"LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
|
"LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
|
||||||
"Original": "Original"
|
"Original": "Original",
|
||||||
|
"NameExtraBehindTheScenes": "Detrás de cámaras",
|
||||||
|
"NameExtraClip": "Clip",
|
||||||
|
"NameExtraDeletedScene": "Escena eliminada",
|
||||||
|
"NameExtraFeaturette": "Minidocumental",
|
||||||
|
"NameExtraInterview": "Entrevista",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Muestra",
|
||||||
|
"NameExtraScene": "Escena",
|
||||||
|
"NameExtraShort": "Cortometraje",
|
||||||
|
"NameExtraThemeSong": "Música de presentación",
|
||||||
|
"NameExtraThemeVideo": "Video de presentación",
|
||||||
|
"NameExtraTrailer": "Tráiler",
|
||||||
|
"NameExtraUnknown": "Extra"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,5 +106,20 @@
|
|||||||
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
||||||
"TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.",
|
"TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.",
|
||||||
"CleanupUserDataTask": "Tarea de limpieza de los datos del usuario",
|
"CleanupUserDataTask": "Tarea de limpieza de los datos del usuario",
|
||||||
"CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días."
|
"CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días.",
|
||||||
|
"LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
|
||||||
|
"NameExtraBehindTheScenes": "Detrás de cámaras",
|
||||||
|
"NameExtraClip": "Clip",
|
||||||
|
"NameExtraDeletedScene": "Escena eliminada",
|
||||||
|
"NameExtraFeaturette": "Minidocumental",
|
||||||
|
"NameExtraInterview": "Entrevista",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Muestra",
|
||||||
|
"NameExtraScene": "Escena",
|
||||||
|
"NameExtraShort": "Cortometraje",
|
||||||
|
"NameExtraThemeSong": "Música de presentación",
|
||||||
|
"NameExtraThemeVideo": "Video de presentación",
|
||||||
|
"NameExtraTrailer": "Tráiler",
|
||||||
|
"NameExtraUnknown": "Extra",
|
||||||
|
"Original": "Original"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTask": "Tarea de limpieza de datos del usuario",
|
"CleanupUserDataTask": "Tarea de limpieza de datos del usuario",
|
||||||
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.",
|
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.",
|
||||||
"Original": "Original",
|
"Original": "Original",
|
||||||
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}"
|
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}",
|
||||||
|
"NameExtraBehindTheScenes": "Detrás de Cámaras",
|
||||||
|
"NameExtraClip": "Clip",
|
||||||
|
"NameExtraDeletedScene": "Escena eliminada",
|
||||||
|
"NameExtraFeaturette": "Reportaje especial",
|
||||||
|
"NameExtraInterview": "Entrevista",
|
||||||
|
"NameExtraSample": "Muestra",
|
||||||
|
"NameExtraScene": "Escena",
|
||||||
|
"NameExtraShort": "Cortometraje",
|
||||||
|
"NameExtraThemeSong": "Tema principal",
|
||||||
|
"NameExtraThemeVideo": "Vídeo del tema principal",
|
||||||
|
"NameExtraTrailer": "Tráiler",
|
||||||
|
"NameExtraUnknown": "Extra",
|
||||||
|
"NameExtraNumbered": "{0} {1}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,5 +106,20 @@
|
|||||||
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
|
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
|
||||||
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
||||||
"CleanupUserDataTask": "Tarea de limpieza de datos de usuario",
|
"CleanupUserDataTask": "Tarea de limpieza de datos de usuario",
|
||||||
"CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días."
|
"CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
|
||||||
|
"LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}",
|
||||||
|
"Original": "Original",
|
||||||
|
"NameExtraUnknown": "Extra",
|
||||||
|
"NameExtraBehindTheScenes": "Detrás de cámaras",
|
||||||
|
"NameExtraClip": "Clip",
|
||||||
|
"NameExtraDeletedScene": "Escena eliminada",
|
||||||
|
"NameExtraFeaturette": "Minidocumental",
|
||||||
|
"NameExtraInterview": "Entrevista",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Muestra",
|
||||||
|
"NameExtraScene": "Escena",
|
||||||
|
"NameExtraShort": "Cortometraje",
|
||||||
|
"NameExtraThemeSong": "Música de presentación",
|
||||||
|
"NameExtraThemeVideo": "Video de presentación",
|
||||||
|
"NameExtraTrailer": "Tráiler"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTask": "Puhasta kasutajaandmed",
|
"CleanupUserDataTask": "Puhasta kasutajaandmed",
|
||||||
"CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud.",
|
"CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud.",
|
||||||
"LyricDownloadFailureFromForItem": "Laulusõnade hankimine teenusest {0} loole {1} nurjus",
|
"LyricDownloadFailureFromForItem": "Laulusõnade hankimine teenusest {0} loole {1} nurjus",
|
||||||
"Original": "Algne"
|
"Original": "Algne",
|
||||||
|
"NameExtraBehindTheScenes": "Kulisside taga",
|
||||||
|
"NameExtraDeletedScene": "Väljajäetud stseen",
|
||||||
|
"NameExtraFeaturette": "Lisalõik",
|
||||||
|
"NameExtraInterview": "Intervjuu",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Näidis",
|
||||||
|
"NameExtraScene": "Stseen",
|
||||||
|
"NameExtraShort": "Lühifilm",
|
||||||
|
"NameExtraThemeSong": "Tunnusmeloodia",
|
||||||
|
"NameExtraThemeVideo": "Tunnusvideo",
|
||||||
|
"NameExtraTrailer": "Treiler",
|
||||||
|
"NameExtraUnknown": "Lisamaterjal",
|
||||||
|
"NameExtraClip": "Videoklipp"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.",
|
"CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.",
|
||||||
"CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina",
|
"CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina",
|
||||||
"LyricDownloadFailureFromForItem": "Ezin izan dira {1}-ren letrak deskargatu {0}-tik",
|
"LyricDownloadFailureFromForItem": "Ezin izan dira {1}-ren letrak deskargatu {0}-tik",
|
||||||
"Original": "Jatorrizkoa"
|
"Original": "Jatorrizkoa",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Lagina",
|
||||||
|
"NameExtraScene": "Eszena",
|
||||||
|
"NameExtraShort": "Laburra",
|
||||||
|
"NameExtraThemeSong": "Gai-abestia",
|
||||||
|
"NameExtraThemeVideo": "Gai-bideoa",
|
||||||
|
"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,16 +1,125 @@
|
|||||||
{
|
{
|
||||||
"Artists": "Listafólk",
|
"Artists": "Tónlistafólk",
|
||||||
"Collections": "Søvn",
|
"Collections": "Samlingar",
|
||||||
"Default": "Sjálvgildi",
|
"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",
|
||||||
|
"HeaderFavoriteShows": "Yndisrøðir",
|
||||||
|
"HeaderLiveTV": "Beinleiðis sjónvarp",
|
||||||
|
"HearingImpaired": "Hoyrnarveik",
|
||||||
|
"Inherit": "Arvar",
|
||||||
|
"LabelRunningTimeValue": "Spælitíð: {0}",
|
||||||
|
"Latest": "Seinastu",
|
||||||
|
"LyricDownloadFailureFromForItem": "Miseydnaðist at niðurtakað sangtekst fyri {1} frá {0}",
|
||||||
|
"NameInstallFailed": "{0} innlegging miseydnaðist",
|
||||||
|
"NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.",
|
||||||
|
"NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt",
|
||||||
|
"NotificationOptionPluginInstalled": "Ískoytisforrit innlagt",
|
||||||
|
"NotificationOptionPluginUninstalled": "Ískoytisforrit strikað",
|
||||||
|
"NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført",
|
||||||
|
"NotificationOptionUserLockedOut": "Brúkari útihýstur",
|
||||||
|
"Photos": "Ljósmyndir",
|
||||||
|
"PluginInstalledWithName": "{0} innlagt",
|
||||||
|
"PluginUninstalledWithName": "{0} strikað",
|
||||||
|
"PluginUpdatedWithName": "{0} dagført",
|
||||||
|
"Shows": "Røðir",
|
||||||
|
"SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}",
|
||||||
|
"TvShows": "Sjónvarpsrøðir",
|
||||||
|
"UserCreatedWithName": "Brúkari {0} er stovnaður",
|
||||||
|
"UserDeletedWithName": "Brúkari {0} er strikaður",
|
||||||
|
"UserDownloadingItemWithValues": "{0} niðurtekur {1}",
|
||||||
|
"UserLockedOutWithName": "Brúkari {0} er útihýstur",
|
||||||
|
"VersionNumber": "Útgáva {0}",
|
||||||
|
"TasksLibraryCategory": "Savn",
|
||||||
|
"TaskRefreshLibrary": "Skanna miðlasavn",
|
||||||
|
"TaskCleanLogsDescription": "Strikar gerðalistafílur eldri enn {0} dagar.",
|
||||||
|
"TaskUpdatePlugins": "Dagfør ískoytisforrit",
|
||||||
|
"TaskRefreshChannels": "Endurinnles rásir",
|
||||||
|
"TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir",
|
||||||
|
"Movies": "Filmar",
|
||||||
|
"MixedContent": "Blandað innihald",
|
||||||
|
"Music": "Tónleikur",
|
||||||
|
"UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}",
|
||||||
|
"HeaderContinueWatching": "Hald áfram at hyggja",
|
||||||
|
"MusicVideos": "Sjónbandaløg",
|
||||||
|
"TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.",
|
||||||
|
"TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.",
|
||||||
|
"TaskOptimizeDatabase": "Albøt dátugrunn",
|
||||||
|
"NameSeasonNumber": "Umfar {0}",
|
||||||
|
"NameSeasonUnknown": "Ókent umfar",
|
||||||
|
"ScheduledTaskFailedWithName": "{0} miseydnaðist",
|
||||||
|
"Undefined": "Óskilmarkað",
|
||||||
|
"TasksMaintenanceCategory": "Viðlíkahald",
|
||||||
|
"TaskCleanLogs": "Reinsa gerðalistaskjáttu",
|
||||||
|
"UserOnlineFromDevice": "{0} er íbundin frá {1}",
|
||||||
|
"HeaderNextUp": "Næst á skránni",
|
||||||
|
"NotificationOptionPluginError": "Brek í ískoytisforriti",
|
||||||
|
"NotificationOptionInstallationFailed": "Innleggingarbrek",
|
||||||
|
"NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan",
|
||||||
|
"TasksApplicationCategory": "Nýtsluskipan",
|
||||||
|
"NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk",
|
||||||
|
"NotificationOptionApplicationUpdateInstalled": "Skipanardagføring var innløgd",
|
||||||
|
"UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}",
|
||||||
|
"HomeVideos": "Heimaupptøkur",
|
||||||
|
"StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.",
|
||||||
|
"UserOfflineFromDevice": "{0} breyt av á {1}",
|
||||||
|
"UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt",
|
||||||
|
"TasksChannelsCategory": "Alnetsrásir",
|
||||||
|
"TaskCleanActivityLog": "Reinsa virksemisskrá",
|
||||||
|
"TaskCleanActivityLogDescription": "Strikar skrásetingar eldri enn ásetta aldur.",
|
||||||
|
"TaskCleanCache": "Reinsa kovaskjáttu",
|
||||||
|
"TaskCleanCacheDescription": "Strikar kovafílar ið kervið ikki hevur tørv á longur.",
|
||||||
|
"TaskCleanTranscode": "Reinsa umkotuskjáttu",
|
||||||
|
"TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir",
|
||||||
|
"TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir",
|
||||||
|
"CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.",
|
||||||
|
"CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur",
|
||||||
|
"TaskRefreshPeople": "Dagfør persónsupplýsingar",
|
||||||
|
"TaskRefreshPeopleDescription": "Dagførur metadátur um leikarar og leikstjórar í tínum margmiðlasavni.",
|
||||||
|
"TaskRefreshChannelsDescription": "Dagførur upplýsingar um alnetsrásir.",
|
||||||
|
"TaskDownloadMissingSubtitlesDescription": "Leitar á alnótini eftir vantandi undirtekstum grundað á metadátauppsetan.",
|
||||||
|
"NotificationOptionTaskFailed": "Brek undir fyriskipaðari koyrslu",
|
||||||
|
"TaskRefreshLibraryDescription": "Skannar títt miðlasavn fyri nýggjum fílum og dagførur metadátur.",
|
||||||
|
"TaskKeyframeExtractor": "Lyklamyndaúttøka",
|
||||||
|
"TaskKeyframeExtractorDescription": "Úttekur lyklamyndir frá kykmynda-fílum til tess at byggja nágreiniligari HLS-spælilistar. Koyrslan kann taka langa tíð.",
|
||||||
|
"TaskOptimizeDatabaseDescription": "Trýstur dátugrunninin saman og loysur tóma goymslu. Koyrslan kann bøta um avrikið, eftir skanning ella aðrar broytingar í savninum ið elva til dátugrunnsbroytingar.",
|
||||||
|
"TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.",
|
||||||
|
"TaskRefreshChapterImages": "Kapitlamyndaúttøkur",
|
||||||
|
"NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað",
|
||||||
|
"NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað",
|
||||||
|
"NotificationOptionAudioPlayback": "Ljóðspæl byrjað",
|
||||||
|
"NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað",
|
||||||
|
"TaskExtractMediaSegments": "Leita eftir margmiðlabrotum",
|
||||||
|
"TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.",
|
||||||
|
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
|
||||||
|
"NameExtraShort": "Stuttfilmur",
|
||||||
|
"NameExtraThemeSong": "Eyðkennislag",
|
||||||
|
"NameExtraTrailer": "Brellbiti",
|
||||||
|
"NameExtraInterview": "Samrøða",
|
||||||
|
"NameExtraBehindTheScenes": "Aftanfyri leiktjøldini",
|
||||||
|
"NameExtraClip": "Klipp",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraFeaturette": "Stuttur heimildarfilmur",
|
||||||
|
"TaskAudioNormalization": "Ljóðjavnan",
|
||||||
|
"TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.",
|
||||||
|
"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,18 @@
|
|||||||
"CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
|
"CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
|
||||||
"CleanupUserDataTask": "Tâche de nettoyage des données utilisateur",
|
"CleanupUserDataTask": "Tâche de nettoyage des données utilisateur",
|
||||||
"LyricDownloadFailureFromForItem": "Le téléchargement des paroles a échoué de {0} pour {1}",
|
"LyricDownloadFailureFromForItem": "Le téléchargement des paroles a échoué de {0} pour {1}",
|
||||||
"Original": "Original"
|
"Original": "Original",
|
||||||
|
"NameExtraBehindTheScenes": "Dans Les Coulisses",
|
||||||
|
"NameExtraClip": "Clip",
|
||||||
|
"NameExtraDeletedScene": "Scène supprimée",
|
||||||
|
"NameExtraFeaturette": "Court-métrage",
|
||||||
|
"NameExtraInterview": "Entrevue",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Échantillon",
|
||||||
|
"NameExtraScene": "Scène",
|
||||||
|
"NameExtraShort": "Court-métrage",
|
||||||
|
"NameExtraThemeSong": "Chanson thème",
|
||||||
|
"NameExtraThemeVideo": "Générique",
|
||||||
|
"NameExtraTrailer": "Bande-annonce",
|
||||||
|
"NameExtraUnknown": "Extra"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
|
"CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
|
||||||
"CleanupUserDataTask": "Tâche de nettoyage des données utilisateur",
|
"CleanupUserDataTask": "Tâche de nettoyage des données utilisateur",
|
||||||
"LyricDownloadFailureFromForItem": "Le téléchargement des paroles à échoué de {0} pour {1}",
|
"LyricDownloadFailureFromForItem": "Le téléchargement des paroles à échoué de {0} pour {1}",
|
||||||
"Original": "Original"
|
"Original": "Original",
|
||||||
|
"NameExtraBehindTheScenes": "Dans Les Coulisses",
|
||||||
|
"NameExtraClip": "Clip",
|
||||||
|
"NameExtraDeletedScene": "Scène supprimée",
|
||||||
|
"NameExtraFeaturette": "Court-métrage",
|
||||||
|
"NameExtraInterview": "Entrevue",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Échantillon",
|
||||||
|
"NameExtraScene": "Scène",
|
||||||
|
"NameExtraShort": "Court-métrage",
|
||||||
|
"NameExtraThemeSong": "Thème musical",
|
||||||
|
"NameExtraThemeVideo": "Générique",
|
||||||
|
"NameExtraTrailer": "Bande-annonce",
|
||||||
|
"NameExtraUnknown": "Extra"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,5 +106,14 @@
|
|||||||
"TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.",
|
"TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.",
|
||||||
"TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.",
|
"TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.",
|
||||||
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
|
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
|
||||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש"
|
"CleanupUserDataTask": "משימת ניקוי מידע משתמש",
|
||||||
|
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
|
||||||
|
"Original": "מקור",
|
||||||
|
"NameExtraBehindTheScenes": "מאחורי הקלעים",
|
||||||
|
"NameExtraClip": "קליפ",
|
||||||
|
"NameExtraDeletedScene": "סצנה שנמחקה",
|
||||||
|
"NameExtraFeaturette": "סרט קצר",
|
||||||
|
"NameExtraInterview": "ריאיון",
|
||||||
|
"NameExtraSample": "דגימה",
|
||||||
|
"NameExtraScene": "סצנה"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,5 +105,21 @@
|
|||||||
"TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।",
|
"TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।",
|
||||||
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
|
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
|
||||||
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
|
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
|
||||||
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य"
|
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य",
|
||||||
|
"Original": "असली",
|
||||||
|
"LyricDownloadFailureFromForItem": "{0} के लिए {1} से बोल (Lyrics) डाउनलोड करने में विफल रहा",
|
||||||
|
"NameExtraBehindTheScenes": "परदे के पीछे",
|
||||||
|
"NameExtraClip": "क्लिप",
|
||||||
|
"NameExtraDeletedScene": "हटाया गया दृश्य",
|
||||||
|
"NameExtraFeaturette": "फीचरेट",
|
||||||
|
"NameExtraInterview": "साक्षात्कार",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "नमूना",
|
||||||
|
"NameExtraScene": "दृश्य",
|
||||||
|
"NameExtraShort": "शॉर्ट",
|
||||||
|
"NameExtraThemeSong": "थीम सॉन्ग",
|
||||||
|
"NameExtraThemeVideo": "थीम वीडियो",
|
||||||
|
"NameExtraTrailer": "ट्रेलर",
|
||||||
|
"NameExtraUnknown": "अतिरिक्त",
|
||||||
|
"CleanupUserDataTaskDescription": "कम से कम 90 दिनों से अनुपस्थित मीडिया से सभी उपयोगकर्ता डेटा (देखने की स्थिति, पसंदीदा स्थिति आदि) को साफ़ करता है।"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,18 @@
|
|||||||
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.",
|
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.",
|
||||||
"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",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.",
|
"CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.",
|
||||||
"CleanupUserDataTask": "Felhasználói adatok tisztítása feladat",
|
"CleanupUserDataTask": "Felhasználói adatok tisztítása feladat",
|
||||||
"Original": "Eredeti",
|
"Original": "Eredeti",
|
||||||
"LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen"
|
"LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen",
|
||||||
|
"NameExtraBehindTheScenes": "Színfalak mögött",
|
||||||
|
"NameExtraClip": "Klip",
|
||||||
|
"NameExtraDeletedScene": "Törölt jelenet",
|
||||||
|
"NameExtraFeaturette": "Kísérő film",
|
||||||
|
"NameExtraInterview": "Interjú",
|
||||||
|
"NameExtraSample": "Minta",
|
||||||
|
"NameExtraScene": "Jelenet",
|
||||||
|
"NameExtraShort": "Rövidfilm",
|
||||||
|
"NameExtraThemeSong": "Főcímdal",
|
||||||
|
"NameExtraThemeVideo": "Főcímvideó",
|
||||||
|
"NameExtraTrailer": "Előzetes",
|
||||||
|
"NameExtraUnknown": "Extra",
|
||||||
|
"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": "Ոճ"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
"UserDownloadingItemWithValues": "{0} hleður niður {1}",
|
"UserDownloadingItemWithValues": "{0} hleður niður {1}",
|
||||||
"SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}",
|
"SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}",
|
||||||
"Shows": "Þættir",
|
"Shows": "Þættir",
|
||||||
"TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.",
|
"TaskRefreshChannelsDescription": "Endurhleður upplýsingum netrása.",
|
||||||
"TaskRefreshChannels": "Endurhlaða Rásir",
|
"TaskRefreshChannels": "Endurhlaða Rásir",
|
||||||
"TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.",
|
"TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.",
|
||||||
"TaskCleanTranscode": "Hreinsa Umkóðunarmöppu",
|
"TaskCleanTranscode": "Hreinsa Umkóðunarmöppu",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"TasksMaintenanceCategory": "Viðhald",
|
"TasksMaintenanceCategory": "Viðhald",
|
||||||
"Default": "Sjálfgefið",
|
"Default": "Sjálfgefið",
|
||||||
"TaskCleanActivityLog": "Hreinsa athafnaskrá",
|
"TaskCleanActivityLog": "Hreinsa athafnaskrá",
|
||||||
"TaskRefreshPeople": "Endurnýja fólk",
|
"TaskRefreshPeople": "Endurnýja upplýsingar um fólk",
|
||||||
"TaskDownloadMissingSubtitles": "Sækja texta sem vantar",
|
"TaskDownloadMissingSubtitles": "Sækja texta sem vantar",
|
||||||
"TaskOptimizeDatabase": "Fínstilla gagnagrunn",
|
"TaskOptimizeDatabase": "Fínstilla gagnagrunn",
|
||||||
"Undefined": "Óskilgreint",
|
"Undefined": "Óskilgreint",
|
||||||
@@ -95,13 +95,31 @@
|
|||||||
"TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.",
|
"TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.",
|
||||||
"Forced": "Þvingað",
|
"Forced": "Þvingað",
|
||||||
"External": "Útvær",
|
"External": "Útvær",
|
||||||
"TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir fyrir myndbönd í virkum söfnum.",
|
"TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir (Trickplay) fyrir myndbönd í virkum söfnum.",
|
||||||
"TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir",
|
"TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir (Trickplay)",
|
||||||
"TaskAudioNormalization": "Hljóðstöðlun",
|
"TaskAudioNormalization": "Hljóðstöðlun",
|
||||||
"TaskAudioNormalizationDescription": "Leitar að hljóðstöðlunargögnum í skrám.",
|
"TaskAudioNormalizationDescription": "Leitar að hljóðstöðlunargögnum í skrám.",
|
||||||
"TaskDownloadMissingLyricsDescription": "Sækja söngtexta fyrir lög",
|
"TaskDownloadMissingLyricsDescription": "Sækja söngtexta fyrir lög",
|
||||||
"TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar",
|
"TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar",
|
||||||
"TaskExtractMediaSegments": "Skönnun efnishluta",
|
"TaskExtractMediaSegments": "Skönnun efnishluta",
|
||||||
"CleanupUserDataTask": "Hreinsun notendagagna",
|
"CleanupUserDataTask": "Hreinsun notendagagna",
|
||||||
"CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga."
|
"CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga.",
|
||||||
|
"LyricDownloadFailureFromForItem": "Ekki tókst að niðurhala texta frá {0} fyrir {1}",
|
||||||
|
"Original": "Upprunaleg",
|
||||||
|
"TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt.",
|
||||||
|
"TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir",
|
||||||
|
"TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins.",
|
||||||
|
"NameExtraBehindTheScenes": "Bak við tjöldin",
|
||||||
|
"NameExtraClip": "Brot",
|
||||||
|
"NameExtraDeletedScene": "Eydd atriði",
|
||||||
|
"NameExtraFeaturette": "Stutt heimildarmynd",
|
||||||
|
"NameExtraInterview": "Viðtal",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Sýnishorn",
|
||||||
|
"NameExtraScene": "Sena",
|
||||||
|
"NameExtraShort": "Stuttmynd",
|
||||||
|
"NameExtraThemeSong": "Þema lag",
|
||||||
|
"NameExtraThemeVideo": "Þema myndband",
|
||||||
|
"NameExtraTrailer": "Stikla",
|
||||||
|
"NameExtraUnknown": "Aukaefni"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTask": "Task di pulizia dei dati utente",
|
"CleanupUserDataTask": "Task di pulizia dei dati utente",
|
||||||
"CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.",
|
"CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.",
|
||||||
"Original": "Originale",
|
"Original": "Originale",
|
||||||
"LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}"
|
"LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}",
|
||||||
|
"NameExtraBehindTheScenes": "Dietro le scene",
|
||||||
|
"NameExtraClip": "Filmato",
|
||||||
|
"NameExtraDeletedScene": "Scena eliminata",
|
||||||
|
"NameExtraInterview": "Intervista",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraScene": "Scena",
|
||||||
|
"NameExtraSample": "Campione",
|
||||||
|
"NameExtraShort": "Corto",
|
||||||
|
"NameExtraThemeSong": "Sigla musicale",
|
||||||
|
"NameExtraTrailer": "Trailer",
|
||||||
|
"NameExtraFeaturette": "Caratteristica",
|
||||||
|
"NameExtraThemeVideo": "Video tematico",
|
||||||
|
"NameExtraUnknown": "Extra"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,5 +106,7 @@
|
|||||||
"TaskDownloadMissingLyrics": "失われた歌詞をダウンロード",
|
"TaskDownloadMissingLyrics": "失われた歌詞をダウンロード",
|
||||||
"TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。",
|
"TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。",
|
||||||
"CleanupUserDataTask": "ユーザーデータのクリーンアップタスク",
|
"CleanupUserDataTask": "ユーザーデータのクリーンアップタスク",
|
||||||
"CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。"
|
"CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。",
|
||||||
|
"LyricDownloadFailureFromForItem": "歌詞",
|
||||||
|
"Original": "オリジナル"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,8 @@
|
|||||||
"CleanupUserDataTask": "사용자 데이터 정리 작업",
|
"CleanupUserDataTask": "사용자 데이터 정리 작업",
|
||||||
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.",
|
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.",
|
||||||
"LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
|
"LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
|
||||||
"Original": "원본"
|
"Original": "원본",
|
||||||
|
"NameExtraClip": "클립",
|
||||||
|
"NameExtraDeletedScene": "삭제된 장면",
|
||||||
|
"NameExtraInterview": "인터뷰"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,5 +104,21 @@
|
|||||||
"TaskKeyframeExtractorDescription": "Extrahéiert Schlësselbiller aus Videodateien, fir méi präzis HLS-Playlisten ze erstellen. Dës Aufgab kann eng längere Zäit daueren.",
|
"TaskKeyframeExtractorDescription": "Extrahéiert Schlësselbiller aus Videodateien, fir méi präzis HLS-Playlisten ze erstellen. Dës Aufgab kann eng längere Zäit daueren.",
|
||||||
"TaskRefreshChannelsDescription": "Aktualiséiert Informatiounen iwwer Internetkanäl.",
|
"TaskRefreshChannelsDescription": "Aktualiséiert Informatiounen iwwer Internetkanäl.",
|
||||||
"TaskExtractMediaSegmentsDescription": "Extrahéiert oder kritt Mediesegmenter aus Plugins, déi MediaSegment ënnerstëtzen.",
|
"TaskExtractMediaSegmentsDescription": "Extrahéiert oder kritt Mediesegmenter aus Plugins, déi MediaSegment ënnerstëtzen.",
|
||||||
"TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren."
|
"TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren.",
|
||||||
|
"LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}",
|
||||||
|
"Original": "Original",
|
||||||
|
"CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten",
|
||||||
|
"CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn.",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
"Artists": "Atlikėjai",
|
"Artists": "Atlikėjai",
|
||||||
"AuthenticationSucceededWithUserName": "{0} sėkmingai autentifikuota",
|
"AuthenticationSucceededWithUserName": "{0} sėkmingai autentifikuota",
|
||||||
"Books": "Knygos",
|
"Books": "Knygos",
|
||||||
"ChapterNameValue": "Scena{0}",
|
"ChapterNameValue": "Skyrius{0}",
|
||||||
"Collections": "Rinkiniai",
|
"Collections": "Kolekcijos",
|
||||||
"FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti",
|
"FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti",
|
||||||
"Favorites": "Mėgstami",
|
"Favorites": "Mėgstami",
|
||||||
"Folders": "Katalogai",
|
"Folders": "Aplankai",
|
||||||
"Genres": "Žanrai",
|
"Genres": "Žanrai",
|
||||||
"HeaderContinueWatching": "Žiūrėti toliau",
|
"HeaderContinueWatching": "Žiūrėti toliau",
|
||||||
"HeaderFavoriteEpisodes": "Mėgstamiausios serijos",
|
"HeaderFavoriteEpisodes": "Mėgstami Epizodai",
|
||||||
"HeaderFavoriteShows": "Mėgstamiausios TV Laidos",
|
"HeaderFavoriteShows": "Mėgstamos TV Laidos",
|
||||||
"HeaderLiveTV": "Tiesioginė TV",
|
"HeaderLiveTV": "Tiesioginė TV",
|
||||||
"HeaderNextUp": "Toliau",
|
"HeaderNextUp": "Toliau",
|
||||||
"HomeVideos": "Namų vaizdo įrašai",
|
"HomeVideos": "Namų vaizdo įrašai",
|
||||||
@@ -26,14 +26,14 @@
|
|||||||
"NameInstallFailed": "{0} diegimo klaida",
|
"NameInstallFailed": "{0} diegimo klaida",
|
||||||
"NameSeasonNumber": "Sezonas {0}",
|
"NameSeasonNumber": "Sezonas {0}",
|
||||||
"NameSeasonUnknown": "Sezonas neatpažintas",
|
"NameSeasonUnknown": "Sezonas neatpažintas",
|
||||||
"NewVersionIsAvailable": "Nauja \"Jellyfin Server\" versija yra prieinama atsisiuntimui.",
|
"NewVersionIsAvailable": "Nauja Jellyfin Server versija yra prieinama atsisiuntimui.",
|
||||||
"NotificationOptionApplicationUpdateAvailable": "Galimi programos atnaujinimai",
|
"NotificationOptionApplicationUpdateAvailable": "Galimi programos atnaujinimai",
|
||||||
"NotificationOptionApplicationUpdateInstalled": "Programos atnaujinimai įdiegti",
|
"NotificationOptionApplicationUpdateInstalled": "Programos atnaujinimai įdiegti",
|
||||||
"NotificationOptionAudioPlayback": "Garso atkūrimas pradėtas",
|
"NotificationOptionAudioPlayback": "Garso atkūrimas pradėtas",
|
||||||
"NotificationOptionAudioPlaybackStopped": "Garso atkūrimas sustabdytas",
|
"NotificationOptionAudioPlaybackStopped": "Garso atkūrimas sustabdytas",
|
||||||
"NotificationOptionCameraImageUploaded": "Kameros vaizdai įkelti",
|
"NotificationOptionCameraImageUploaded": "Kameros atvaizdai įkelti",
|
||||||
"NotificationOptionInstallationFailed": "Diegimo klaida",
|
"NotificationOptionInstallationFailed": "Diegimo klaida",
|
||||||
"NotificationOptionNewLibraryContent": "Naujas turinys įkeltas",
|
"NotificationOptionNewLibraryContent": "Pridėtas naujas turinys",
|
||||||
"NotificationOptionPluginError": "Įskiepio klaida",
|
"NotificationOptionPluginError": "Įskiepio klaida",
|
||||||
"NotificationOptionPluginInstalled": "Įskiepis įdiegtas",
|
"NotificationOptionPluginInstalled": "Įskiepis įdiegtas",
|
||||||
"NotificationOptionPluginUninstalled": "Įskiepis išdiegtas",
|
"NotificationOptionPluginUninstalled": "Įskiepis išdiegtas",
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"Shows": "Laidos",
|
"Shows": "Laidos",
|
||||||
"StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.",
|
"StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.",
|
||||||
"SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}",
|
"SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}",
|
||||||
"TvShows": "TV laidos",
|
"TvShows": "TV Laidos",
|
||||||
"UserCreatedWithName": "Buvo sukurtas {0} naudotojas",
|
"UserCreatedWithName": "Buvo sukurtas {0} naudotojas",
|
||||||
"UserDeletedWithName": "Naudotojas {0} ištrintas",
|
"UserDeletedWithName": "Naudotojas {0} ištrintas",
|
||||||
"UserDownloadingItemWithValues": "{0} siunčiasi {1}",
|
"UserDownloadingItemWithValues": "{0} siunčiasi {1}",
|
||||||
@@ -59,14 +59,14 @@
|
|||||||
"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",
|
||||||
"TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.",
|
"TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.",
|
||||||
"TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.",
|
"TaskCleanTranscodeDescription": "Ištrina dienos senumo transkodavimo failus.",
|
||||||
"TaskCleanTranscode": "Išvalyti perkodavimo katalogą",
|
"TaskCleanTranscode": "Išvalyti transkodavimo katalogą",
|
||||||
"TaskRefreshLibraryDescription": "Skenuoja medijos biblioteką, ieškodamas naujų failų, ir atnaujina metaduomenis.",
|
"TaskRefreshLibraryDescription": "Skenuoja medijos biblioteką, ieškodamas naujų failų, ir atnaujina metaduomenis.",
|
||||||
"TaskRefreshLibrary": "Skenuoti medijos biblioteką",
|
"TaskRefreshLibrary": "Skenuoti medijos biblioteką",
|
||||||
"TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus",
|
"TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus",
|
||||||
@@ -76,8 +76,8 @@
|
|||||||
"TaskRefreshPeople": "Atnaujinti žmones",
|
"TaskRefreshPeople": "Atnaujinti žmones",
|
||||||
"TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.",
|
"TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.",
|
||||||
"TaskCleanLogs": "Išvalyti žurnalą",
|
"TaskCleanLogs": "Išvalyti žurnalą",
|
||||||
"TaskRefreshChapterImagesDescription": "Sukuria vaizdo įrašų, kuriuose yra skyrių, miniatiūras.",
|
"TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašams, kuriuose yra skyriai.",
|
||||||
"TaskRefreshChapterImages": "Ištraukti skyrių vaizdus",
|
"TaskRefreshChapterImages": "Ištraukti skyrių atvaizdus",
|
||||||
"TaskCleanCache": "Išvalyti talpyklą",
|
"TaskCleanCache": "Išvalyti talpyklą",
|
||||||
"TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.",
|
"TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.",
|
||||||
"TasksChannelsCategory": "Internetiniai kanalai",
|
"TasksChannelsCategory": "Internetiniai kanalai",
|
||||||
@@ -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.",
|
||||||
@@ -96,15 +96,30 @@
|
|||||||
"External": "Išorinis",
|
"External": "Išorinis",
|
||||||
"HearingImpaired": "Su klausos sutrikimais",
|
"HearingImpaired": "Su klausos sutrikimais",
|
||||||
"TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus",
|
"TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus",
|
||||||
"TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.",
|
"TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įgalintose bibliotekose, Trickplay peržiūras.",
|
||||||
"TaskAudioNormalization": "Garso normalizavimas",
|
"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 vaizdų 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ėgstamiausią būseną ir t. t.)."
|
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).",
|
||||||
|
"LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}",
|
||||||
|
"NameExtraBehindTheScenes": "Užkulisiuose",
|
||||||
|
"NameExtraClip": "Klipas",
|
||||||
|
"NameExtraDeletedScene": "Ištrinta scena",
|
||||||
|
"NameExtraInterview": "Interviu",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Pavyzdys",
|
||||||
|
"NameExtraScene": "Scena",
|
||||||
|
"NameExtraThemeSong": "Teminė daina",
|
||||||
|
"NameExtraThemeVideo": "Teminis vaizdo įrašas",
|
||||||
|
"NameExtraTrailer": "Anonsas",
|
||||||
|
"NameExtraUnknown": "Papildomas",
|
||||||
|
"Original": "Originalus",
|
||||||
|
"NameExtraFeaturette": "Trumpametražis filmas",
|
||||||
|
"NameExtraShort": "Trumpas filmukas"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,18 @@
|
|||||||
"TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām",
|
"TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām",
|
||||||
"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}",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTask": "Opruimtaak gebruikersdata",
|
"CleanupUserDataTask": "Opruimtaak gebruikersdata",
|
||||||
"Genres": "Genres",
|
"Genres": "Genres",
|
||||||
"Original": "Oorspronkelijk",
|
"Original": "Oorspronkelijk",
|
||||||
"LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt"
|
"LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt",
|
||||||
|
"NameExtraBehindTheScenes": "Achter de schermen",
|
||||||
|
"NameExtraClip": "Clip",
|
||||||
|
"NameExtraDeletedScene": "Geschrapte scène",
|
||||||
|
"NameExtraFeaturette": "Featurette",
|
||||||
|
"NameExtraInterview": "Vraaggesprek",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Voorbeeldfragment",
|
||||||
|
"NameExtraScene": "Scène",
|
||||||
|
"NameExtraShort": "Korte film",
|
||||||
|
"NameExtraThemeSong": "Themamuziek",
|
||||||
|
"NameExtraThemeVideo": "Themavideo",
|
||||||
|
"NameExtraTrailer": "Trailer",
|
||||||
|
"NameExtraUnknown": "Extra inhoud"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
{
|
{
|
||||||
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}"
|
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}",
|
||||||
|
"Books": "Libres",
|
||||||
|
"Artists": "Artistas",
|
||||||
|
"Collections": "Collecciones",
|
||||||
|
"ChapterNameValue": "Capitol {0}",
|
||||||
|
"External": "Extèrn",
|
||||||
|
"Folders": "Dorsièrs",
|
||||||
|
"Favorites": "Favorits",
|
||||||
|
"HeaderContinueWatching": "Contunhar de regardar",
|
||||||
|
"HeaderFavoriteEpisodes": "Episòdis Favorits",
|
||||||
|
"AuthenticationSucceededWithUserName": "{0} autentificat amb succès",
|
||||||
|
"HeaderFavoriteShows": "Serias Favoritas",
|
||||||
|
"HeaderLiveTV": "TV en dirècte",
|
||||||
|
"HeaderNextUp": "Seguent",
|
||||||
|
"HearingImpaired": "Amb de deficiéncias auditivas",
|
||||||
|
"Movies": "Filmes",
|
||||||
|
"Music": "Musica",
|
||||||
|
"Latest": "Darrièr",
|
||||||
|
"Forced": "Forçat",
|
||||||
|
"Default": "Defaut",
|
||||||
|
"Genres": "Genres",
|
||||||
|
"HomeVideos": "Vidèos d'Acuèlh",
|
||||||
|
"Inherit": "Eiretar",
|
||||||
|
"LabelIpAddressValue": "Adreça IP: {0}",
|
||||||
|
"LabelRunningTimeValue": "Temps d'execucion : {0}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.",
|
"CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.",
|
||||||
"CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika",
|
"CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika",
|
||||||
"Original": "Oryginalny",
|
"Original": "Oryginalny",
|
||||||
"LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}"
|
"LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}",
|
||||||
|
"NameExtraBehindTheScenes": "Za kulisami",
|
||||||
|
"NameExtraClip": "Urywek",
|
||||||
|
"NameExtraDeletedScene": "Usunięta scena",
|
||||||
|
"NameExtraFeaturette": "Film średniometrażowy",
|
||||||
|
"NameExtraInterview": "Wywiad",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Fragment",
|
||||||
|
"NameExtraScene": "Scena",
|
||||||
|
"NameExtraShort": "Film krótkometrażowy",
|
||||||
|
"NameExtraThemeSong": "Czołówka",
|
||||||
|
"NameExtraThemeVideo": "Wideo wprowadzające",
|
||||||
|
"NameExtraTrailer": "Zwiastun",
|
||||||
|
"NameExtraUnknown": "Dodatek"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"CleanupUserDataTask": "Tarefa de limpeza de dados do usuário",
|
"CleanupUserDataTask": "Tarefa de limpeza de dados do usuário",
|
||||||
"CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias.",
|
"CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias.",
|
||||||
"LyricDownloadFailureFromForItem": "Download das Letras falharam em {0} para o item {1}",
|
"LyricDownloadFailureFromForItem": "Download das Letras falharam em {0} para o item {1}",
|
||||||
"Original": "Original"
|
"Original": "Original",
|
||||||
|
"NameExtraBehindTheScenes": "Nos Bastidores",
|
||||||
|
"NameExtraClip": "Clipe",
|
||||||
|
"NameExtraDeletedScene": "cena Extra",
|
||||||
|
"NameExtraNumbered": "{0} {1}",
|
||||||
|
"NameExtraSample": "Trecho",
|
||||||
|
"NameExtraScene": "Cena",
|
||||||
|
"NameExtraShort": "Curta-metragem",
|
||||||
|
"NameExtraThemeSong": "Música Tema",
|
||||||
|
"NameExtraThemeVideo": "Vídeo de Abertura",
|
||||||
|
"NameExtraTrailer": "Trailer",
|
||||||
|
"NameExtraUnknown": "Extra",
|
||||||
|
"NameExtraFeaturette": "Nos Bastidores",
|
||||||
|
"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,112 +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 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",
|
||||||
"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 da imagem do 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",
|
||||||
|
"NameExtraClip": "Excerto",
|
||||||
|
"NameExtraDeletedScene": "Cena eliminada",
|
||||||
|
"NameExtraFeaturette": "Minidocumentário",
|
||||||
|
"NameExtraInterview": "Entrevista",
|
||||||
|
"NameExtraSample": "Amostra",
|
||||||
|
"NameExtraShort": "Curta-metragem",
|
||||||
|
"NameExtraThemeSong": "Tema musical",
|
||||||
|
"NameExtraThemeVideo": "Vídeo temático",
|
||||||
|
"NameExtraScene": "Cena",
|
||||||
|
"NameExtraUnknown": "Extra",
|
||||||
|
"NameExtraTrailer": "Trailer",
|
||||||
|
"NameExtraNumbered": "{0} {1}"
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user