Compare commits
1697 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3a0611302 | |||
| c20cc766e1 | |||
| cac463d4c3 | |||
| 7faa35dab4 | |||
| 24022092ad | |||
| e2e3094d2d | |||
| d669994bf0 | |||
| 45b8f6c4c8 | |||
| 044f651299 | |||
| f865910a90 | |||
| f2360c07a3 | |||
| 4c812c9ba4 | |||
| 26261dbfe7 | |||
| ae90e0e52e | |||
| 72d0895401 | |||
| e16c8a07bd | |||
| a7a138db31 | |||
| 76956c0abc | |||
| d4376e0539 | |||
| b94ba9d409 | |||
| 744ca84a8b | |||
| c55fde25a5 | |||
| 030031dcff | |||
| 341c19bace | |||
| e3a8d209b4 | |||
| e816870f67 | |||
| f28acc7fa1 | |||
| b2ae39e0d9 | |||
| 893662ba50 | |||
| 314004bc19 | |||
| b15d3c9ad3 | |||
| 8293eb26b9 | |||
| d8fc0a9914 | |||
| 1f4f4acb46 | |||
| d64e18b69a | |||
| 7a4271c85f | |||
| afb716566d | |||
| e5c68e1257 | |||
| 4cedbe28e3 | |||
| 9ff92f3f0d | |||
| d92e59aa72 | |||
| d593436281 | |||
| 41b8bd4459 | |||
| 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 | |||
| feef2403c4 | |||
| 1b6342e217 | |||
| 62e6cf0196 | |||
| 8c6ee890cb | |||
| eee26e6fee | |||
| fb07067f8f | |||
| a83920c5a7 | |||
| 75d71cb73c | |||
| c158418e0b | |||
| cbef19c313 | |||
| fc13a7ca7d | |||
| ff36b1b417 | |||
| 9ec19b8244 | |||
| ed5e868a6b | |||
| 58de9b7a99 | |||
| aa037c748a | |||
| 1efdad3443 | |||
| f2ed842b4b | |||
| 617ebf367f | |||
| 70b4589382 | |||
| c2cb18a9d1 | |||
| f398b6d08b | |||
| fa07a3abe8 | |||
| b9db4566a7 | |||
| d71b17fcc7 | |||
| dff84c8490 | |||
| 5221584b06 | |||
| 1947296edd | |||
| c4ace4ac95 | |||
| 2fcf4084f8 | |||
| 31070e8208 | |||
| 2c98ad99db | |||
| e26f4a1005 | |||
| e41f415594 | |||
| c632417dda | |||
| d090c59939 | |||
| da515e94b1 | |||
| 987744529a | |||
| 917244ab1d | |||
| af82aceadb | |||
| 7f2cd5cf57 | |||
| 2feb588db3 | |||
| 94d5326411 | |||
| 58e9e3423a | |||
| da2b994fff | |||
| 7f7e4dfa40 | |||
| c257fd5004 | |||
| 0046adda29 | |||
| b60c535c84 | |||
| 069eb40ebf | |||
| c49f279a27 | |||
| 4e80648fd3 | |||
| f08a3f9fd9 | |||
| 083f9d291a | |||
| e4383493a9 | |||
| ce58e4400e | |||
| 3741d71965 | |||
| 11f642594d | |||
| 8d15529df7 | |||
| 310a47c1d4 | |||
| 0fb042b740 | |||
| 24886d4849 | |||
| e75161c557 | |||
| c6c72f30ec | |||
| 528593efbf | |||
| 308981cc0d | |||
| bebb7ce803 | |||
| 1a6f019cfd | |||
| 751b763838 | |||
| 64e02c0e28 | |||
| 49f8a96360 | |||
| 364f1e12c0 | |||
| ada11f5692 | |||
| 5036bf7db0 | |||
| 1c4dea4b2c | |||
| 1ea525a408 | |||
| e525fc7c4b | |||
| 3307406ac8 | |||
| e86b502cbc | |||
| 4c228eaf63 | |||
| 1176c2d329 | |||
| b9271eb199 | |||
| e2433e2c79 | |||
| 0022508889 | |||
| a9a02719ab | |||
| d50205cc9f | |||
| e4edce9a70 | |||
| ac92da233b | |||
| f6bb086415 | |||
| 9375f31bd3 | |||
| a0862a4cb5 | |||
| 2d8ab1e2ec | |||
| 068bbb7981 | |||
| f9644f24d2 | |||
| 8d0003533e | |||
| 1dd5a85080 | |||
| f5c3e2c65a | |||
| f4bab458a2 | |||
| 8028e1d59d | |||
| 1a9ed49083 | |||
| ab988d0e73 | |||
| aa3fc60b2e | |||
| d8f8dbabcb | |||
| 5df25cf688 | |||
| 047519c61a | |||
| e198c430ae | |||
| 3c11329256 | |||
| 3d80da6cfa | |||
| db89b49752 | |||
| 21efb55db6 | |||
| a9dc8f6f74 | |||
| 23f8ec93ab | |||
| a5706c2fa6 | |||
| d0a8445f76 | |||
| 7e3f758bee | |||
| 8e2c5607ef | |||
| 4278b34fe6 | |||
| 0aeee8233b | |||
| 58508e60a4 | |||
| 2ede3c1342 | |||
| dd42a121c4 | |||
| 5ac14f0688 | |||
| d8acea21fb | |||
| 0ef94347c0 | |||
| 12f718e7bb | |||
| 5920d8dc3c | |||
| 95de28cdda | |||
| 0874a26131 | |||
| 25016babc1 | |||
| eb38f462ad | |||
| 1bfbad2420 | |||
| c693047467 | |||
| 894ee38f68 | |||
| 97fd210cd3 | |||
| 1a786f26c1 | |||
| 007515eb73 | |||
| 0a0060c9ca | |||
| 8b002e3fca | |||
| f5cf68e979 | |||
| f6ce2cedd9 | |||
| e71914e993 | |||
| fbe522592a | |||
| 59974490cc | |||
| dee63ef3f1 | |||
| 2392e32779 | |||
| 6c931dcdda | |||
| f584759638 | |||
| fe1accc0e7 | |||
| fe1d8d8840 | |||
| a53a533cc4 | |||
| 4725722b1c | |||
| 9ea3f45886 | |||
| e64cc73f88 | |||
| 63990d6a2d | |||
| 09723bd123 | |||
| c242533f4e | |||
| 507998a4e3 | |||
| 4459147788 | |||
| 003f01a99a | |||
| f56670bdce | |||
| d8d386e88a | |||
| b4d970ae38 | |||
| fa0e1b6e9a | |||
| d873964297 | |||
| 6ccdaad0a4 | |||
| ec43ea156e | |||
| cbf284d229 | |||
| 8aaea6ea52 | |||
| 3114c0a9b8 | |||
| c9f9f30baf | |||
| 0ed27bad65 | |||
| c8da0abf0f | |||
| a96824f257 | |||
| 1a2db53710 | |||
| f4db44ffb3 | |||
| b639166c0a | |||
| efb0336369 | |||
| 5f13afa1ce | |||
| 45e40d3b33 | |||
| 53c1c4982a | |||
| 21c0a35edf | |||
| 1b80da0c3d | |||
| 47f2b3b6d0 | |||
| 857b99ce61 | |||
| cf88058099 | |||
| 5ee9e79da2 | |||
| 5ed7798c36 | |||
| b71b4cc26f | |||
| 26a149a970 | |||
| 5104497331 | |||
| 7185257da5 | |||
| d4c962f6e4 | |||
| 52cf8d1ba4 | |||
| 081f0ef4a0 | |||
| 53eda14dcc | |||
| 0d30a59564 | |||
| cc5fb3f1ee | |||
| d69de6ccc4 | |||
| 54463929d0 | |||
| ab15371d86 | |||
| c7111b7570 | |||
| 9ab7cc0fe9 | |||
| 285fc1b9f6 | |||
| 5ce7170813 | |||
| a6ae68433a | |||
| bdb8250300 | |||
| 8b387c82cf | |||
| a2bab98c23 | |||
| 9397148b20 | |||
| 63d1af5fe7 | |||
| 3d6d6c18f9 | |||
| c13af99320 | |||
| 6f0ff89bdc | |||
| 13c6549b42 | |||
| bee53dee54 | |||
| 7f8e39b21e | |||
| 1dd02b0e30 | |||
| 2b6da44815 | |||
| 941298ee81 | |||
| f178e3ebff | |||
| a479e145dc | |||
| 7939f3b009 | |||
| 99e9b2310f | |||
| ad4e884d18 | |||
| a3b2b9c07f | |||
| 5c7ee6a635 | |||
| 2a95223c67 | |||
| c9f71d8531 | |||
| b6e26ba3d9 | |||
| 5feb70f489 | |||
| 0beb07c407 | |||
| 645ae6bb99 | |||
| 34fc8c270c | |||
| 8d544e4842 | |||
| ef9468e857 | |||
| fa3a33c04e | |||
| 1752323296 | |||
| f12b666cbb | |||
| aa2370e021 | |||
| d085eb9d87 | |||
| 2f17516d4b | |||
| cbd0c5b841 | |||
| 321b83c693 | |||
| ecd9c9079b | |||
| b555de4cea | |||
| c449a93372 | |||
| 02ca63cd13 | |||
| 961c6d3d54 | |||
| 9f350171c6 | |||
| f0e01e33c4 | |||
| 4af66c4e1a | |||
| b015d2d219 | |||
| fdfdf34cdb | |||
| 12c1711dc1 | |||
| 11130030d2 | |||
| a05bde53d4 | |||
| dd3aa04422 | |||
| d0f1df13b2 | |||
| e1d63c0ea0 | |||
| e1a16b4ec6 | |||
| 9fafc87bf6 | |||
| 148cdef180 | |||
| f9eb550cbb | |||
| cb9d6e9884 | |||
| 1175846120 | |||
| 498d265208 | |||
| 4322ee4391 | |||
| a784733a53 | |||
| 0b25c1483c | |||
| 93b82ecb41 | |||
| 317f57803d | |||
| e627c723e2 | |||
| 372c1681d8 | |||
| 69b0b63a95 | |||
| 31f4ce93a6 | |||
| b615ecd704 | |||
| 4897cc7e2e | |||
| 9569b4550d | |||
| a3849819c2 | |||
| 863730e647 | |||
| 26dbd86b81 | |||
| 17a32f055c | |||
| df6296bc30 | |||
| 7e72bc6e9c | |||
| aacb147f4d | |||
| 6296a03342 | |||
| 8ddb485192 | |||
| 400d7d83bc | |||
| 4ca3cd79fa | |||
| 623b1c39e5 | |||
| 5ed3ffdb42 | |||
| b141b893eb | |||
| 4361e073ed | |||
| 17e2d3bb11 | |||
| e2fe5c163a | |||
| bc8a66a351 | |||
| 0ef4f86ed7 | |||
| eb2cef1b7e | |||
| 37350282cc | |||
| 3d8bcf1ffd | |||
| e63bc1e27c | |||
| 4209b3ba51 | |||
| 40485aa2f6 | |||
| 49cc2f7899 | |||
| 11ca3ade86 | |||
| fa734df82f | |||
| fbfaa4f661 | |||
| d65eafdfe2 | |||
| 51ff31cd41 | |||
| 2ed07155b2 | |||
| c6fd0be892 | |||
| 325be80a2b | |||
| 3e5cf13968 | |||
| 9f7eee2592 | |||
| 57bc534f40 | |||
| afb4d180d9 | |||
| 5bc7969c2c | |||
| 9dfcc0918f | |||
| 8384ef8a04 | |||
| 2b2db76948 | |||
| e805cd4df7 | |||
| c436c9ff29 | |||
| 7d744009ad | |||
| f6af1a9fb6 | |||
| 405d987557 | |||
| a15b426e73 | |||
| 2a689f268b | |||
| 2c66447f08 | |||
| 43e869f832 | |||
| 8deb5c1d2a | |||
| 077ad5c574 | |||
| 3a4e6236a8 | |||
| c4957dff31 | |||
| ed27de13a0 | |||
| e00d01cc30 | |||
| 2c7addc671 | |||
| dac6b70f52 | |||
| 5e8b86f80c | |||
| 07c63d5ebf | |||
| 80a552a35d | |||
| 8d28c15e3e | |||
| 3430f4fa57 | |||
| 3a1bd5984d | |||
| 7e7bacd013 | |||
| 8b84bf6e21 | |||
| aa960dc696 | |||
| f14ab37bf5 | |||
| 2f8bf92fb8 | |||
| ed3c62b66e | |||
| 8740f3d154 | |||
| d6240bfa88 | |||
| 3655b4b094 | |||
| 5c3c3e35b9 | |||
| 6b1378fa89 | |||
| ea8f6c51fd | |||
| d71194aa8c | |||
| 1fdf58e40f | |||
| c4a21cb322 | |||
| 31005e878b | |||
| 19ce23c3c8 | |||
| 9d20aefd89 | |||
| f73fc1feb2 | |||
| 09e607db99 | |||
| 20dda72cec | |||
| 72492d95b4 | |||
| 9c184ceed7 | |||
| c53133bf9b | |||
| c63c7aa176 | |||
| 9d420271ad | |||
| 97c20e6ac5 | |||
| fae4950ac2 | |||
| d93e2d6667 | |||
| 31889c0215 | |||
| 8ec3b5c7ac | |||
| 8e602f982f | |||
| 097097300a | |||
| ac8e5780d6 | |||
| 27a8d29aba | |||
| 2acefba085 | |||
| 6fdb423bfb | |||
| 1cfef32906 | |||
| 0a5a1f135f | |||
| 031b9a11b8 | |||
| 0b209fe66b | |||
| ae7a8c418b | |||
| e6e19e3ce0 | |||
| 1b14ee6e11 | |||
| a8ab398fab | |||
| 7a5181c3fd | |||
| 1751c5b45d | |||
| 157431475f | |||
| 651fe2b7b3 | |||
| 78abe79764 | |||
| 068b3fd58d | |||
| 4f81f29a90 | |||
| b8c0017b74 | |||
| c91f640d37 | |||
| 3b452c3682 | |||
| e811cd7caf | |||
| 38bda65ca9 | |||
| 5165e4e2d4 | |||
| 843e0f0c84 | |||
| 6014ad6ef2 | |||
| 2f27e9b5b6 | |||
| 7504875ccf | |||
| 1872340a01 | |||
| b1b4519944 | |||
| 8435dfb779 | |||
| 95164883c1 | |||
| 93d7a1cf20 | |||
| 5cfb379aa6 | |||
| 4be3f5f1f9 | |||
| 9e794e80c2 | |||
| a47da0f1a3 | |||
| 8f7c54ee5e | |||
| 7f5ff3dabd | |||
| c6910c3034 | |||
| 25f0e8b423 | |||
| e9942c3857 | |||
| 04ecf77d97 | |||
| b9ee9b0660 | |||
| 27a3ccb7e4 | |||
| a9865367d8 | |||
| 39049a726e | |||
| c169184e01 | |||
| 5bcea608c5 | |||
| 22bf421be6 | |||
| 6d3a7b6f69 | |||
| 406aaabefd | |||
| d5bb7756f1 | |||
| ef64f19eac | |||
| 2800ae3b8a | |||
| 7ead333f6e | |||
| 9be1246749 | |||
| 2229851689 | |||
| 21f12a1ad0 | |||
| be0f0b9761 | |||
| 5701cdce68 | |||
| f24709f11c | |||
| 4f238ca9b3 | |||
| 42870986a8 | |||
| a42956c182 | |||
| 2b7f641163 | |||
| 65710a4e4f | |||
| 82120732ca | |||
| 9a3dfec151 | |||
| 02835c6144 | |||
| 6d6dee9492 | |||
| fc251265d9 | |||
| 5cb6ac521a | |||
| 4dd19b990b | |||
| efa502a65f | |||
| b7b405dc83 | |||
| 149649a6cf | |||
| 169745fddb | |||
| c4c521719e | |||
| e9cad048e9 | |||
| de64a69c7a | |||
| c328dbef10 | |||
| 8437866ffa | |||
| df751af194 | |||
| b84bd34c67 | |||
| 82b51a60a5 | |||
| 5cdfb9bfac | |||
| 6c59f9a03d | |||
| e1e18e8da0 | |||
| 63a078b720 | |||
| d636b82e83 | |||
| 0028104ed9 | |||
| f6ac1d9f38 | |||
| bc074b5283 | |||
| 3876a0ad3d | |||
| a629080c89 | |||
| 2fbb821581 | |||
| 33ed52b8ee | |||
| d1ab428476 | |||
| 142b89eab5 | |||
| 1bbbc1c823 | |||
| a8f361f8c0 | |||
| c0593281ff | |||
| d648aba881 | |||
| 44d5954205 | |||
| 10c42d70ca | |||
| da88a06ede | |||
| 842a5efdcf | |||
| e84fd95bcc | |||
| 28546f535c | |||
| e50aeb914a | |||
| 087e745e7a | |||
| c8c890b9e5 | |||
| 4b8be6bc91 | |||
| ec054f6a34 | |||
| d3e6079d38 | |||
| 9ade1fb8f6 | |||
| 8a43b1c784 | |||
| 4178e0ebaf | |||
| 064fd8c5c0 | |||
| 7be1350205 | |||
| b227f3e85b | |||
| fd6badf096 | |||
| 7e4727fff8 | |||
| 6f2e42c20c | |||
| d4f91ab5ca | |||
| 5e82b61bab | |||
| 0d58c773f9 | |||
| 6be96100c7 | |||
| 57c0fcd674 | |||
| 6ea2f05497 | |||
| ec04313317 | |||
| d87e9f9622 | |||
| 10de1ce8fe | |||
| 5d5ae271a5 | |||
| d707a9dba1 | |||
| d359d2f7a8 | |||
| ba268cc3fb | |||
| dcba6c3659 | |||
| 57821e4cde | |||
| b7d0301099 | |||
| 2365cea626 | |||
| fa65a392b0 | |||
| ec990be12a | |||
| ea7000a4d6 | |||
| 0f6bab03eb | |||
| 88cad2ad1a | |||
| 07a802d8fa | |||
| 4ebce39070 | |||
| d20c775daf | |||
| b8e25b49b3 | |||
| 622947e374 | |||
| 6e22075a63 | |||
| d68d0fa962 | |||
| 00b08c0b32 | |||
| 0183127d2a | |||
| d9ced0d639 | |||
| f5f75ed2e1 | |||
| df6f706c2f | |||
| 3aed429120 | |||
| f9012b6411 | |||
| 6293e7a3c9 | |||
| 9404fa2b27 | |||
| 19b756a507 | |||
| 4e94c3e28b | |||
| 127d924c5b | |||
| 8150a51238 | |||
| fd80a9d916 | |||
| e75f7f1b28 | |||
| f7bfad8673 | |||
| 3690d0bf86 | |||
| 4e257364b6 | |||
| 58e7ff7f9d | |||
| 68ab585894 | |||
| 105492ac28 | |||
| bb12b122c3 | |||
| 6d15f693b5 | |||
| e12fbe08a4 | |||
| e9af1588f2 | |||
| b733857da2 | |||
| 44a5c6b3dd | |||
| 035d8f06cc | |||
| c59d3bb21a | |||
| 9962fbbe2e | |||
| 070e2b2d0c | |||
| 8b94722110 | |||
| 755ef1f942 | |||
| a1f3da1819 | |||
| d19449e6a5 | |||
| fc866a64e0 | |||
| 48299ff694 | |||
| a35787b82b | |||
| a9117bcda4 | |||
| 9f5f18d2db | |||
| 8044156df5 | |||
| f9a7cd7457 | |||
| 1dd8541ed5 | |||
| d86a4d6815 | |||
| 857e730168 | |||
| 461662f1ff | |||
| a183fce142 | |||
| b1e2419c65 | |||
| 3c93465940 | |||
| 64c8c83fae | |||
| 1691940c3f | |||
| c3707e3b87 | |||
| d4dda995e1 | |||
| 5ed9fb11ac | |||
| 54d2a0f3bf | |||
| 4bb1e2543f | |||
| 3bb9235777 | |||
| 50768923fb | |||
| 44957300ab | |||
| cec9b51b4f | |||
| 18e8a5375b | |||
| b31520d603 | |||
| c483619928 | |||
| ab8e3422dc | |||
| 2df0b9d821 | |||
| b717754ed8 | |||
| 0d6484b7ec | |||
| bd70e0ca34 | |||
| f806ae4018 | |||
| 8a1ad14faf | |||
| e71bb7e904 | |||
| 96269ff177 | |||
| 365729d75f | |||
| 4f1ad3fee0 | |||
| a2eff41d66 | |||
| 5aa093d299 | |||
| 1d44899606 | |||
| 48b0029180 | |||
| 7113a8c57c | |||
| 946ff12185 | |||
| 1e64e42842 | |||
| d4a46bc629 | |||
| 5a1eec690f | |||
| f9bad54b40 | |||
| 31e8e197cf | |||
| d8c55a29bb | |||
| ff4d384cd6 | |||
| 8510cfe77d | |||
| eb6c0e0935 | |||
| 6a85961816 | |||
| 8ba9319f27 | |||
| b5885fe234 | |||
| ba3497b9dd | |||
| 0fd80868f8 | |||
| 9239b12118 | |||
| 8a01f04de5 | |||
| 1864c17412 | |||
| ac6edd40c8 | |||
| 03523be555 | |||
| fa8073d9ff | |||
| f9c7b18fdd | |||
| 1ac5f71bf3 | |||
| 5bad7b8ae3 | |||
| fb33b725e0 | |||
| ce3fa80a28 | |||
| ec9c94bd7a | |||
| bb265cd403 | |||
| 5b4882c102 | |||
| 22644075e7 | |||
| 6fc406f2c5 | |||
| 60e01e1f22 | |||
| e0f50f504a | |||
| d8bbb4dfe8 | |||
| 046023b9dd | |||
| 193a15ea45 | |||
| 45700f6f7d | |||
| 99f2129d91 | |||
| 29d11f6ecb | |||
| 3ef7ada736 | |||
| 22f0507258 | |||
| c300651d0d | |||
| eacdc83fda | |||
| e3b5cf4996 | |||
| 553f38a237 | |||
| 68f26e5a34 | |||
| 78e0a2b5c1 | |||
| 24a0df9a39 | |||
| b28a5794ec | |||
| 179db631f7 | |||
| e44821e8f6 | |||
| 740e9f8749 | |||
| 0bf7653e36 | |||
| b846958f2c | |||
| 3c2833e3e8 | |||
| 3c9b71e124 | |||
| 8cecf53057 | |||
| c008f28d31 | |||
| 142ba42883 | |||
| cf9b8161ef | |||
| 83c9ab0079 | |||
| f5e9c1de45 | |||
| 76c17856ba | |||
| 4b59d8f117 | |||
| 31720cef05 | |||
| 34e2f91d50 | |||
| 42a0892022 | |||
| 95562f9fee | |||
| 643116e1d0 | |||
| 9bf04df5d9 | |||
| 8482b3cfb9 | |||
| 7f3e27c007 | |||
| 3225023c1f | |||
| cc7bfff412 | |||
| 70a48e1da4 | |||
| 80df5dc984 | |||
| 982d7432f9 | |||
| 97a1feb16d | |||
| 894768c4f9 | |||
| 134fe92f42 | |||
| 5d2a529fb3 | |||
| 94b3d41d7d | |||
| a2dcaa9521 | |||
| e4d6b8e1bd | |||
| 7256ac81c0 | |||
| 6e81226054 | |||
| 12af9f9a57 | |||
| 83ee080200 | |||
| da4d06c5ab | |||
| 87c8349c6b | |||
| c5726559fd | |||
| 9e489cd41f | |||
| 9b00854e68 | |||
| 397147d035 | |||
| f788e8b741 | |||
| 99ad70fbc8 | |||
| 7c57b62ece | |||
| c7ac591548 | |||
| 736a01f447 | |||
| cef81ae2ed | |||
| 376769c732 | |||
| 6fdfc6a61b | |||
| b6e4b3a4f5 | |||
| d5d4309417 | |||
| 5b3537b3d7 | |||
| 2134ea3f7f | |||
| 42e8a780ca | |||
| 7825fa4e43 | |||
| d5f4c624e3 | |||
| c5ee639016 | |||
| e4f01973b7 | |||
| 5f30a4538b | |||
| a536ef88c4 | |||
| a0834973ed | |||
| acaeba11f3 | |||
| a3960b30c0 | |||
| 8ceb8c23ce | |||
| a6da575785 | |||
| ea206f43a2 | |||
| 5cfa466d8b | |||
| 921a364bb0 | |||
| ad9ebe5baa | |||
| 6ea77f484d | |||
| a12736a0ce | |||
| e5bbb1ea0c | |||
| 3fb1d94038 | |||
| 3a4dff8cc4 | |||
| e807575dc7 | |||
| ef83000b30 | |||
| 6a20f948f4 | |||
| bd6acb1686 | |||
| f33c039d1b | |||
| 40cadfca44 | |||
| 0ba41754d8 | |||
| 6e154de954 | |||
| 4fe3abdc0e | |||
| 1219c5ec3b | |||
| 2147f57df5 | |||
| f793acc1aa | |||
| 9c09e7113e | |||
| aa96ff42e6 | |||
| 5fa865f9e5 | |||
| bcc748e664 | |||
| e065015d6d | |||
| 6b443bb2ec | |||
| fec78c8448 | |||
| c22933260b | |||
| 8fc6f07d5a | |||
| 386c4cb723 | |||
| 965b602c68 | |||
| 8142bbd50e | |||
| 418beafebb | |||
| 434ebc8b11 | |||
| 300036c859 | |||
| 24ec04d89f | |||
| b82a2ced75 | |||
| 568e26c534 | |||
| f52005768a | |||
| a0d1e05696 | |||
| f6211a03dd | |||
| 995d56d5ff | |||
| 63a7c71e77 | |||
| dc4d3639e0 | |||
| d88dd1dd63 | |||
| a2fba38954 | |||
| 54856dc026 | |||
| dbd58dd666 | |||
| dbc42bb8e2 | |||
| f6a5b27efc | |||
| ebc15d3e27 | |||
| c0c4740215 | |||
| 98bbc26c5e | |||
| 4b48cad6f7 | |||
| d65960fe5d | |||
| 27d54c5b1c | |||
| 31adb5dcd1 | |||
| cf03e3118a | |||
| 3997e016fa | |||
| 4eead536a9 | |||
| 6c8a5dc104 | |||
| 8795ed4ebf | |||
| 7b7800435a | |||
| 136ec00f3e | |||
| 8a9d9dd977 | |||
| 37983c943a | |||
| d218303b93 | |||
| 4952e65a03 | |||
| b825829191 | |||
| ff4c9263e4 | |||
| 946c6b9981 | |||
| 6880a2ce3e | |||
| b308baaf41 | |||
| 8e29e5e419 | |||
| a7b8a68b8d | |||
| 99dcda6197 | |||
| bcd5c33ecd | |||
| 32270576c2 | |||
| e4eba084dd | |||
| 352b6c91f8 | |||
| a8a029de73 | |||
| 7ab1c6bb15 | |||
| 119b2e3d2f | |||
| 08f9ec5d37 | |||
| d6e2fcb233 | |||
| ba722b4517 | |||
| 4f08c7a097 | |||
| 3b29375179 | |||
| eb3f8b93d0 | |||
| ebb6949ea7 | |||
| 1d8bdcc411 | |||
| e6e7f2a692 | |||
| 5882006ee7 | |||
| ea1c1d0468 | |||
| 077fa89717 | |||
| 382db1da0d | |||
| 0ebf6a6db6 | |||
| d2334a35c1 | |||
| 99e4e1fbd7 | |||
| bc05ecd543 | |||
| 268f23f39a | |||
| 744c5539d8 | |||
| f5b2e0b8f9 | |||
| f1b4f805e5 | |||
| 59c360aea7 | |||
| c843c71003 | |||
| a7c95ab009 | |||
| 8824f07e1b | |||
| 7cd79c3121 | |||
| b444d2c66a | |||
| b83378d656 | |||
| 5807bf1d8f | |||
| 3da726463d | |||
| 8c0898738d | |||
| e378f63b70 | |||
| 44f7d2b854 | |||
| e4500303bb | |||
| 2f18e6e7f6 | |||
| ca0b2aa7a6 | |||
| f2ed024296 | |||
| d5fb6f99ef | |||
| 3d4e4c4572 | |||
| 11e16df596 | |||
| b6f4ffd251 | |||
| 8715cb5b9e | |||
| febfd7f94a | |||
| d87fe973f3 | |||
| 8271568677 | |||
| b22c8882d6 | |||
| 63c4fc297a | |||
| e70eaf8bc1 | |||
| f680495ca3 | |||
| d2f733f9a4 | |||
| d55f082579 | |||
| bc316b3dc8 | |||
| 116a036d56 | |||
| 8ee4f951fe | |||
| 457c53da6f | |||
| 46ffe0af9c | |||
| e6d73ae367 | |||
| 826e21ecc8 | |||
| 885b45838c | |||
| bd6bf6ee3c | |||
| 2068be1221 | |||
| bdfb6edfa3 | |||
| 25e8c6d591 | |||
| c11c33e1a8 | |||
| e8232d31ab | |||
| 2d0d497961 | |||
| 4bd9dbe910 | |||
| b456afe00e | |||
| b7da5c1860 | |||
| 9aa69eded9 | |||
| b5f5b02787 | |||
| 01b3c6f902 | |||
| 41d2070008 | |||
| 61ff36d761 | |||
| 100d6bb38c | |||
| 66c11231b2 | |||
| 34c1e45bc2 | |||
| ea0641b659 | |||
| d63b2b2657 | |||
| 6ce5f9dfd5 | |||
| ed43ad0968 | |||
| 27396bffc6 | |||
| d156e04c9a | |||
| 5541653f73 | |||
| ae5420d4ae | |||
| 0f1a6fe4c2 | |||
| 97340edf02 | |||
| ccf2d15d5d | |||
| c4c3e9ea4d | |||
| 56a469d8c3 | |||
| 561e78efb4 | |||
| ff0a64ecb9 | |||
| 2dfebb51be | |||
| 716f4c8198 | |||
| 679664ca28 | |||
| b0eec00e1c | |||
| e49d71707c | |||
| 9da046abc1 | |||
| 01eb56f047 | |||
| 94dcaf2ea2 | |||
| 37b50fe13c | |||
| 6829794aa0 | |||
| 3a090a5716 | |||
| f96c399e62 | |||
| 06a6c6e16b | |||
| daf88a5ca2 | |||
| 0f75518287 | |||
| 893188ab28 | |||
| de32e2eb6f | |||
| 84962cbc94 | |||
| ba356638e8 | |||
| 3439d3c017 | |||
| 837c7d4ed3 | |||
| 4ce03ffa21 | |||
| 50cabcd99d | |||
| b346370dfc | |||
| fc6419685c | |||
| 04ffbe5e9a | |||
| d0809ce58b | |||
| 9730aaac57 | |||
| 340bcafd3d | |||
| f5c9a4a476 | |||
| a56aa2dd53 | |||
| 106f33227a | |||
| 1311f66f72 | |||
| 217068eeb7 | |||
| 44d55f7fa3 | |||
| 29582ed461 | |||
| ca6d499680 | |||
| 3b69859867 | |||
| c1c7de6a81 | |||
| 7bcbe20641 | |||
| 2edf23bb40 | |||
| 074aa7e639 | |||
| a37e83d448 | |||
| d8543351e2 | |||
| dce91cf8c8 | |||
| 48e456903e | |||
| 2757c18312 | |||
| a1117a1fbd | |||
| f685a65241 | |||
| e73ebc9741 | |||
| 18a1cd388a | |||
| 1c2f08bc17 | |||
| 6fff4a7bfa | |||
| 5aad260767 | |||
| 8b5914001d | |||
| 5eaaad660d | |||
| de36952f53 | |||
| edec464306 | |||
| 6b8400cc3d | |||
| a0bf5199ba | |||
| 01264c10a6 | |||
| 558b31f386 | |||
| 5dcec831f3 | |||
| 737abe6f3a | |||
| edc6caf255 | |||
| 0a99a78ddc | |||
| 71594b4a9a | |||
| 5656df4339 | |||
| bb6c3b4eec | |||
| fa4d51c5e6 | |||
| 2420ece5fe | |||
| 21bb702fd3 | |||
| 4a494271dd | |||
| 1f6768178a | |||
| 00dd84035e | |||
| f5d966fcc3 | |||
| 98d7c8d59f | |||
| 268d88a5fb | |||
| 8ddc35a1ce | |||
| fead4acae1 | |||
| ccd042750d | |||
| 46ad25f47d | |||
| 0c46004cd9 | |||
| e4619556ba | |||
| a0346fe5b7 | |||
| 9f2dc178f5 | |||
| aedd2b04a2 | |||
| 4c751e0a86 | |||
| 32d8086121 | |||
| 7c200899d7 | |||
| 613d72fa26 | |||
| 909e2142d6 | |||
| 8083ab78b5 | |||
| cbc0138507 | |||
| 1dacb69d80 | |||
| 77ff451e60 | |||
| 4f695bc58a | |||
| d1d4fe2e33 | |||
| 98b561d62c | |||
| d6a8fa1485 | |||
| fc0d268fe5 | |||
| 042385599f | |||
| 7b10888c95 | |||
| 09a729effe | |||
| 2789532aa8 | |||
| 694db80d4c | |||
| 3920ed4b92 | |||
| a650148dfd | |||
| 0a1dd56af6 | |||
| 0413a8b6d2 | |||
| 841e4dabb5 | |||
| 815a153b94 | |||
| 0c274af72c | |||
| 6d34f605a3 | |||
| dad6f650bc | |||
| 5045c2e448 | |||
| ec4744709d | |||
| 91dd146c0f | |||
| 4344d75694 | |||
| b97f5b809d | |||
| b9e5cce383 | |||
| 565de2d377 | |||
| 6ed633ce42 | |||
| cf7150cd9d | |||
| b8d2f1f911 | |||
| 17e8759a52 | |||
| 360b6bcce1 | |||
| 874fd9ac0a | |||
| 92f3c8cf15 | |||
| 441a41b2eb | |||
| bc6c3b1013 | |||
| a676391af2 | |||
| 411a6cced1 | |||
| 62474af0c0 | |||
| 738d4753f2 | |||
| 152d4451ec | |||
| fd71adfba9 | |||
| 327ace1d30 | |||
| 95a301dc43 | |||
| 7151c4ca21 | |||
| 05997987ad | |||
| bab4e620e3 | |||
| 977d1c38b2 | |||
| 342846e4fc | |||
| 99440f8432 | |||
| 2086ac7dd2 | |||
| 4a1012fd22 | |||
| 89427af41c | |||
| 5996c4afce | |||
| dfa78590c2 | |||
| 912a963a2b | |||
| f260585917 | |||
| 22d8a00716 | |||
| c350fd0f40 | |||
| 139d23ddc2 | |||
| cc2ccd1bf3 | |||
| 1491494bcb | |||
| 0b77f97048 | |||
| d3d4d37e82 | |||
| 1c1447362e | |||
| d1c97b8e1d | |||
| fb32709259 | |||
| c9b7c5bb56 | |||
| 42ff253339 | |||
| c4ffc357a3 | |||
| afcaec0a89 | |||
| 09edca8b7a | |||
| f9fd34b11e | |||
| aa666565d1 | |||
| f83e8ee806 | |||
| 84ebed1eb7 | |||
| 1d7c6af520 | |||
| 226da3b371 | |||
| 8d052a6cb1 | |||
| ef0409d06c | |||
| b9abf590c5 | |||
| 6a9bb060eb | |||
| 6a7600a8c6 | |||
| e12131108e | |||
| 89b5b99873 | |||
| 50dcec1ff5 | |||
| d089537bca | |||
| e70355fbe1 | |||
| 140c459ac3 | |||
| 098e8c6fed | |||
| 1ba8e2c93c | |||
| 9e480f6efb | |||
| 0892847c2f | |||
| c464ba83f2 | |||
| 62e51fd00a | |||
| cf9051c277 | |||
| d270957c82 | |||
| 0ee872999d | |||
| c4f4dcc181 | |||
| 22ee5113d0 | |||
| e233eee07b | |||
| 185849b68a | |||
| e62b6f8339 | |||
| 9931537d87 | |||
| 82c4df5cde | |||
| 103f556c8d | |||
| 582a1d9866 | |||
| 244757c92c | |||
| 0ff869dfcd | |||
| a1e0e4fd9d | |||
| 4138214ac3 | |||
| 9039077286 | |||
| 8a1129bbde | |||
| 706a8d2850 | |||
| ba4dbcf5a1 | |||
| bfae788a44 | |||
| e02a2ae48f | |||
| 18dc32d735 | |||
| 85ff708597 | |||
| 23b48a0d0f | |||
| d1055b0b36 | |||
| 3c77758b32 | |||
| b564a43d9c | |||
| f08657ab27 | |||
| e5fb071708 | |||
| 72b4faa00b | |||
| d28ee6d714 | |||
| 2f62a8bb39 | |||
| 75f1276119 | |||
| 6c8395ff87 | |||
| 82b2e7773f | |||
| 336958318d | |||
| 8a8f7956ef | |||
| c728e97bda | |||
| 5c76dd26bc | |||
| 7af5ee1812 | |||
| 7f0e71578d | |||
| 45e881c93e | |||
| b429306f05 | |||
| 88acd51ee2 | |||
| 3c802a7505 | |||
| 928a8458dd | |||
| 43797fee42 | |||
| b9158c467a | |||
| 252ab45473 | |||
| afc083e9fa | |||
| f867ce3842 | |||
| 2a464c316d | |||
| b9cf26db2f | |||
| 580585846b | |||
| 1af1c72e81 | |||
| 5557004375 | |||
| 5d50ff5f81 | |||
| 8e2ed40a8b | |||
| 8461268837 | |||
| 8a0b963d2c | |||
| 24acd94015 | |||
| c30654c33c | |||
| 7bafd13564 | |||
| 0e73a56a45 | |||
| f9fec33048 | |||
| 934a9c9e32 | |||
| 25115e95aa | |||
| 146681f0ba | |||
| 7f1a0ff6fc | |||
| 8d8d38600e | |||
| 172b054f48 | |||
| 2168847a45 | |||
| e5a2acd6dd | |||
| ddc613cd72 | |||
| fa99b1d81c | |||
| 93902fc610 | |||
| 84f66dd54e | |||
| d446fde009 | |||
| ee676fd568 | |||
| 2cca942ce6 | |||
| 59d574edb7 | |||
| c170b18155 | |||
| 45a49a4fb4 | |||
| 12a2f7c1a5 | |||
| d0950c8f09 | |||
| ebf8220f1d | |||
| c4e8180b3c | |||
| 771b0a7eab | |||
| 4db0ab0f40 | |||
| dd480f96cd | |||
| 6b6d54a07c | |||
| 428beda1c7 | |||
| baa8d40940 | |||
| c8bdee26b7 | |||
| ef73ed6ef7 | |||
| acb9da6f93 | |||
| d1f242bc09 | |||
| 113bd9af05 | |||
| d757e12e1a | |||
| 37d6101f02 | |||
| d70e0fe9cf | |||
| 053cc9406d | |||
| 0f85120c5e | |||
| 25aef7fabf | |||
| 492ea66841 | |||
| 8b2a8b94b6 | |||
| f24e80701c | |||
| 0b3d6676d1 | |||
| c3a8734adf | |||
| 8fd59d6f33 | |||
| da3bff3edf | |||
| 45cb5a0008 | |||
| ea097fb1a3 | |||
| a25b48b151 | |||
| 873f1d9e83 | |||
| 294439bf74 | |||
| 6e74be0d46 | |||
| deb81eae10 | |||
| 70dcf3f7b3 | |||
| ebcfed83c4 | |||
| 5d46278584 | |||
| 4f020a947a | |||
| 3460d1de3c | |||
| 7d2e4cd817 | |||
| 8cd6ef37c4 | |||
| e4daaf0d83 | |||
| 69c98af9f9 | |||
| 7425a493ee | |||
| 691c194152 | |||
| 2f8896c375 | |||
| 6c507b77ae | |||
| 6ed0ccd37c | |||
| 80e1e42947 | |||
| 6ace00eb6a | |||
| a35ffbf17e | |||
| 8c02c3be93 | |||
| 45669c9b30 | |||
| 19c232809e | |||
| 301f65af48 | |||
| 082ba58e51 | |||
| 3b5bdc6bc2 | |||
| b05e91dba1 | |||
| c7703242e5 | |||
| 21042ad0c2 | |||
| 8904551a59 | |||
| cf1ef22367 | |||
| d95bab41a1 | |||
| 820e208bdc | |||
| c08e81c52b | |||
| 23e66ae1ea | |||
| 73fd6721f6 | |||
| 41c1c5f7bf | |||
| 37bbdf3fe7 | |||
| f124223015 | |||
| 9587a9b13c | |||
| 6963d95880 | |||
| 67c67df507 | |||
| 569f8cfcfc | |||
| aa4ddd139a | |||
| 8ac97f5471 | |||
| efabfbc931 | |||
| 6b5dc115e8 | |||
| 2dc0af667e | |||
| 196c243a7d | |||
| 55dbff8f30 | |||
| 2af43e0131 | |||
| faf1cea63e | |||
| 7e25089c08 | |||
| 8fa36a38e2 | |||
| 5b3f29946b | |||
| c869b5b884 | |||
| a08b6ac266 | |||
| 4e68a5a078 | |||
| 99c68ddd50 | |||
| d7f628677e | |||
| e51680cf56 | |||
| 2e7d7752e9 | |||
| 26ac2ccd74 | |||
| de9e653b73 | |||
| e34e7a1d0b | |||
| 5a30f108fe | |||
| 74c9629372 | |||
| 6c5f448787 | |||
| f848b8f12c | |||
| bcec5f2e44 | |||
| 7d05c875f3 | |||
| c805c5e2b1 | |||
| c2c4c0adbf | |||
| 5ea3910af9 | |||
| 06fb300cff | |||
| 626ab7e00a | |||
| 1d140645b0 | |||
| 5182aec13f | |||
| 52f0c3dd24 | |||
| b8327dbc9f | |||
| d1722936c0 | |||
| 931240a3f5 | |||
| b216a27bfc | |||
| 8471a67bcd | |||
| b8a409195f | |||
| 1da67e5e10 | |||
| ed1ec7ca6b | |||
| 3d7a68beb1 | |||
| 32fc57cf17 | |||
| 88602ce905 | |||
| 0598c6eaf6 | |||
| 0d7b687da0 | |||
| 4cb0385745 | |||
| 438d992c8b | |||
| e69754fd3a | |||
| ac81ddd39a | |||
| 217ea488df | |||
| f693c9d39f | |||
| 1adf441f1c | |||
| 96d72788a1 | |||
| 0d74a95bb8 | |||
| a7d039b7c6 | |||
| 87b02b1316 | |||
| 871de372ff | |||
| c9d93b0745 | |||
| 1ccd10863e | |||
| 4258df4485 | |||
| 63f06aad94 | |||
| 43a055d7ea | |||
| ffe82be7a7 | |||
| 490bf347cb | |||
| 23929a3e70 | |||
| 83d0dbdbcb | |||
| 573ce9ceaa | |||
| f21fe9f95e | |||
| f92eca3efb | |||
| 7d778d7bef | |||
| 21f65e2e27 | |||
| 28b0657608 | |||
| a489942454 | |||
| 423c2654c0 | |||
| 4dc826644d | |||
| 0f21222a0c | |||
| 570b8b2eb9 | |||
| 08fd175f5a | |||
| 511b5d9c53 | |||
| 6514196e8d | |||
| ed6cb30762 | |||
| 232c0399e2 | |||
| dbb015441f | |||
| 4c1c160990 | |||
| 0931d6e4de | |||
| 3f2ebc4179 | |||
| 14e8194581 | |||
| 3c4dc16003 | |||
| 54d28d9842 | |||
| adfa520057 | |||
| 5deb69b23f | |||
| 348b2992d7 | |||
| 9f8fb6d588 | |||
| cee16d47cb | |||
| 9e53f46ad2 | |||
| 53dfcae1a6 | |||
| 81f1cc78b2 | |||
| efd659412f | |||
| c31ea251c4 | |||
| 285e7c6c4f | |||
| c274336563 | |||
| d5fd5dfe6a | |||
| 42ddcfa565 | |||
| 6fa69f9fe5 | |||
| 0b876365a1 | |||
| cdc8325c7b | |||
| a6a8e29916 | |||
| 6fd3847298 | |||
| 3ff516a430 | |||
| d8591840f3 | |||
| c5affbbf71 | |||
| 788f090f27 | |||
| 0e3b6652b3 | |||
| 79061f4635 | |||
| d167d59c23 | |||
| f58b4860f7 | |||
| cd9154f110 | |||
| 96b7fc0ac0 | |||
| c8ad861590 | |||
| 1a1a24cfff | |||
| d43db230fa | |||
| fd6e48603b | |||
| b09c9655fd | |||
| 790220ef6b | |||
| c08b1a4595 | |||
| 622b60064d | |||
| 91b2b7fc3d | |||
| 0fb6d930e1 | |||
| 0b4854c5ef | |||
| d6a1c8413c | |||
| a014cb538e | |||
| 378ba937b6 | |||
| 2508e8349b | |||
| bd9a44ce7d | |||
| da31d0c6a6 | |||
| 2b6febc8da | |||
| aebabb1580 | |||
| d5402718b7 | |||
| fd108ff528 | |||
| 22ce1f25d0 | |||
| b13039f377 | |||
| 2904083053 | |||
| 47af1c4576 | |||
| 80365f277a | |||
| c46dff16cc | |||
| 9ec657cf6e |
@@ -3,7 +3,7 @@
|
|||||||
"isRoot": true,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"dotnet-ef": {
|
"dotnet-ef": {
|
||||||
"version": "9.0.10",
|
"version": "10.0.10",
|
||||||
"commands": [
|
"commands": [
|
||||||
"dotnet-ef"
|
"dotnet-ef"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "Development Jellyfin Server",
|
"name": "Development Jellyfin Server",
|
||||||
"image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm",
|
"image": "mcr.microsoft.com/devcontainers/dotnet:10.0-noble",
|
||||||
"service": "app",
|
"service": "app",
|
||||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||||
// restores nuget packages, installs the dotnet workloads and installs the dev https certificate
|
// restores nuget packages, installs the dotnet workloads and installs the dev https certificate
|
||||||
"postStartCommand": "sudo dotnet restore; sudo dotnet workload update; sudo dotnet dev-certs https --trust; sudo bash \"./.devcontainer/install-ffmpeg.sh\"",
|
"postStartCommand": "sudo dotnet restore; sudo dotnet workload update; sudo dotnet dev-certs https --trust; sudo bash \"./.devcontainer/install-ffmpeg.sh\"",
|
||||||
// reads the extensions list and installs them
|
// The previous way of installing extensions via the vs command dont work on selfhosted devcontainers
|
||||||
"postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension",
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"ms-dotnettools.csharp",
|
||||||
|
"editorconfig.editorconfig",
|
||||||
|
"github.vscode-github-actions",
|
||||||
|
"ms-dotnettools.vscode-dotnet-runtime",
|
||||||
|
"ms-dotnettools.csdevkit",
|
||||||
|
"alexcvzz.vscode-sqlite",
|
||||||
|
"streetsidesoftware.code-spell-checker",
|
||||||
|
"eamodio.gitlens",
|
||||||
|
"redhat.vscode-xml"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/dotnet:2": {
|
"ghcr.io/devcontainers/features/dotnet:2": {
|
||||||
"version": "none",
|
"version": "none",
|
||||||
"dotnetRuntimeVersions": "9.0",
|
"dotnetRuntimeVersions": "10.0",
|
||||||
"aspNetCoreRuntimeVersions": "9.0"
|
"aspNetCoreRuntimeVersions": "10.0"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
|
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
|
||||||
"preserve_apt_list": false,
|
"preserve_apt_list": false,
|
||||||
@@ -19,7 +33,7 @@
|
|||||||
"libfontconfig1"
|
"libfontconfig1"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
"ghcr.io/devcontainers/features/docker-in-docker:3": {
|
||||||
"dockerDashComposeVersion": "v2"
|
"dockerDashComposeVersion": "v2"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ sudo apt-get install software-properties-common -y
|
|||||||
sudo add-apt-repository universe -y
|
sudo add-apt-repository universe -y
|
||||||
|
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/jellyfin.gpg
|
curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/jellyfin.gpg
|
||||||
export VERSION_OS="$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release )"
|
export VERSION_OS="$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release )"
|
||||||
export VERSION_CODENAME="$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )"
|
export VERSION_CODENAME="$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )"
|
||||||
export DPKG_ARCHITECTURE="$( dpkg --print-architecture )"
|
export DPKG_ARCHITECTURE="$( dpkg --print-architecture )"
|
||||||
|
|||||||
@@ -379,6 +379,9 @@ dotnet_diagnostic.CA1720.severity = suggestion
|
|||||||
# disable warning CA1724: Type names should not match namespaces
|
# disable warning CA1724: Type names should not match namespaces
|
||||||
dotnet_diagnostic.CA1724.severity = suggestion
|
dotnet_diagnostic.CA1724.severity = suggestion
|
||||||
|
|
||||||
|
# disable warning CA1873: Avoid potentially expensive logging
|
||||||
|
dotnet_diagnostic.CA1873.severity = suggestion
|
||||||
|
|
||||||
# disable warning CA1805: Do not initialize unnecessarily
|
# disable warning CA1805: Do not initialize unnecessarily
|
||||||
dotnet_diagnostic.CA1805.severity = suggestion
|
dotnet_diagnostic.CA1805.severity = suggestion
|
||||||
|
|
||||||
@@ -400,6 +403,10 @@ dotnet_diagnostic.CA1861.severity = suggestion
|
|||||||
# disable warning CA2000: Dispose objects before losing scope
|
# disable warning CA2000: Dispose objects before losing scope
|
||||||
dotnet_diagnostic.CA2000.severity = suggestion
|
dotnet_diagnostic.CA2000.severity = suggestion
|
||||||
|
|
||||||
|
# TODO: Reevaluate when false positives are fixed: https://github.com/dotnet/roslyn-analyzers/issues/7699
|
||||||
|
# disable warning CA2025: Do not pass 'IDisposable' instances into unawaited tasks
|
||||||
|
dotnet_diagnostic.CA2025.severity = suggestion
|
||||||
|
|
||||||
# disable warning CA2253: Named placeholders should not be numeric values
|
# disable warning CA2253: Named placeholders should not be numeric values
|
||||||
dotnet_diagnostic.CA2253.severity = suggestion
|
dotnet_diagnostic.CA2253.severity = suggestion
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -1,4 +1,11 @@
|
|||||||
# Joshua must review all changes to deployment and build.sh
|
# Joshua must review all changes to bump_version and any files it touches
|
||||||
.ci/* @joshuaboniface
|
bump_version @joshuaboniface
|
||||||
deployment/* @joshuaboniface
|
.github/ISSUE_TEMPLATE @joshuaboniface
|
||||||
build.sh @joshuaboniface
|
MediaBrowser.Common/MediaBrowser.Common.csproj @joshuaboniface
|
||||||
|
Jellyfin.Data/Jellyfin.Data.csproj @joshuaboniface
|
||||||
|
MediaBrowser.Controller/MediaBrowser.Controller.csproj @joshuaboniface
|
||||||
|
MediaBrowser.Model/MediaBrowser.Model.csproj @joshuaboniface
|
||||||
|
Emby.Naming/Emby.Naming.csproj @joshuaboniface
|
||||||
|
src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @joshuaboniface
|
||||||
|
# Core must approve all changes within the repo config
|
||||||
|
.github/ @jellyfin/core
|
||||||
|
|||||||
@@ -87,7 +87,12 @@ body:
|
|||||||
label: Jellyfin Server version
|
label: Jellyfin Server version
|
||||||
description: What version of Jellyfin are you using?
|
description: What version of Jellyfin are you using?
|
||||||
options:
|
options:
|
||||||
- 10.10.0+
|
- 10.11.11
|
||||||
|
- 10.11.10
|
||||||
|
- 10.11.9
|
||||||
|
- 10.11.8
|
||||||
|
- 10.11.7
|
||||||
|
- 10.11.6
|
||||||
- Master
|
- Master
|
||||||
- Unstable
|
- Unstable
|
||||||
- Older*
|
- Older*
|
||||||
@@ -136,13 +141,14 @@ body:
|
|||||||
- **FFmpeg Version**: [e.g. 5.1.2-Jellyfin]
|
- **FFmpeg Version**: [e.g. 5.1.2-Jellyfin]
|
||||||
- **Playback**: [Direct Play, Remux, Direct Stream, Transcode]
|
- **Playback**: [Direct Play, Remux, Direct Stream, Transcode]
|
||||||
- **Hardware Acceleration**: [e.g. none, VAAPI, NVENC, etc.]
|
- **Hardware Acceleration**: [e.g. none, VAAPI, NVENC, etc.]
|
||||||
|
- **CPU Model**: [e.g. AMD Ryzen 5 9600X, Intel Core i7-8565U, etc.]
|
||||||
- **GPU Model**: [e.g. none, UHD630, GTX1050, etc.]
|
- **GPU Model**: [e.g. none, UHD630, GTX1050, etc.]
|
||||||
- **Installed Plugins**: [e.g. none, Fanart, Anime, etc.]
|
- **Installed Plugins**: [e.g. none, Fanart, Anime, etc.]
|
||||||
- **Reverse Proxy**: [e.g. none, nginx, apache, etc.]
|
- **Reverse Proxy**: [e.g. none, nginx, apache, etc.]
|
||||||
- **Base URL**: [e.g. none, yes: /example]
|
- **Base URL**: [e.g. none, yes: /example]
|
||||||
- **Networking**: [e.g. Host, Bridge/NAT]
|
- **Networking**: [e.g. Host, Bridge/NAT]
|
||||||
- **Jellyfin Data Storage**: [e.g. local SATA SSD, local HDD]
|
- **Jellyfin Data Storage & Filesystem**: [e.g. local SATA SSD - ext4, local HDD - NTFS]
|
||||||
- **Media Storage**: [e.g. Local HDD, SMB Share]
|
- **Media Storage & Filesystem**: [e.g. Local HDD - ext4, SMB Share]
|
||||||
- **External Integrations**: [e.g. Jellystat, Jellyseerr]
|
- **External Integrations**: [e.g. Jellystat, Jellyseerr]
|
||||||
value: |
|
value: |
|
||||||
- OS:
|
- OS:
|
||||||
@@ -153,13 +159,14 @@ body:
|
|||||||
- FFmpeg Version:
|
- FFmpeg Version:
|
||||||
- Playback Method:
|
- Playback Method:
|
||||||
- Hardware Acceleration:
|
- Hardware Acceleration:
|
||||||
|
- CPU Model:
|
||||||
- GPU Model:
|
- GPU Model:
|
||||||
- Plugins:
|
- Plugins:
|
||||||
- Reverse Proxy:
|
- Reverse Proxy:
|
||||||
- Base URL:
|
- Base URL:
|
||||||
- Networking:
|
- Networking:
|
||||||
- Jellyfin Data Storage:
|
- Jellyfin Data Storage & Filesystem:
|
||||||
- Media Storage:
|
- Media Storage & Filesystem:
|
||||||
- External Integrations:
|
- External Integrations:
|
||||||
render: markdown
|
render: markdown
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<!--
|
<!--
|
||||||
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
|
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
|
||||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our documentation.
|
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
**Changes**
|
**Changes**
|
||||||
<!-- Describe your changes here in 1-5 sentences. -->
|
<!-- Describe your changes here in 1-5 sentences. -->
|
||||||
|
|
||||||
|
**Code assistance**
|
||||||
|
<!-- If code assistance was used, describe how it contributed
|
||||||
|
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
|
||||||
|
|
||||||
**Issues**
|
**Issues**
|
||||||
<!-- Tag any issues that this PR solves here.
|
<!-- Tag any issues that this PR solves here.
|
||||||
ex. Fixes # -->
|
ex. Fixes # -->
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: '24 2 * * 4'
|
- cron: '24 2 * * 4'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
@@ -20,18 +24,21 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended
|
queries: +security-extended
|
||||||
|
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: ABI Compatibility
|
name: ABI Compatibility
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
@@ -11,22 +11,22 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
dotnet build Jellyfin.Server -o ./out
|
dotnet build Jellyfin.Server -o ./out
|
||||||
|
|
||||||
- name: Upload Head
|
- name: Upload Head
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: abi-head
|
name: abi-head
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
@@ -40,16 +40,16 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Checkout common ancestor
|
- name: Checkout common ancestor
|
||||||
env:
|
env:
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
dotnet build Jellyfin.Server -o ./out
|
dotnet build Jellyfin.Server -o ./out
|
||||||
|
|
||||||
- name: Upload Head
|
- name: Upload Head
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: abi-base
|
name: abi-base
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||||
|
|
||||||
name: ABI - Difference
|
name: ABI - Difference
|
||||||
if: ${{ github.event_name == 'pull_request_target' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- abi-head
|
- abi-head
|
||||||
@@ -85,13 +85,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download abi-head
|
- name: Download abi-head
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: abi-head
|
name: abi-head
|
||||||
path: abi-head
|
path: abi-head
|
||||||
|
|
||||||
- name: Download abi-base
|
- name: Download abi-base
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: abi-base
|
name: abi-base
|
||||||
path: abi-base
|
path: abi-base
|
||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo 'body<<EOF'
|
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
|
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 )"
|
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
|
if [ "APICompat ran successfully without finding any breaking changes." != "${COMPAT_OUTPUT}" ]; then
|
||||||
printf "\n${file}\n${COMPAT_OUTPUT}\n"
|
printf "\n${file}\n${COMPAT_OUTPUT}\n"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Format
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
# Run formatter against the forked branch, but
|
||||||
|
# do not allow access to secrets
|
||||||
|
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflows-in-forked-repositories
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
SDK_VERSION: "10.0.x"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
format-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.SDK_VERSION }}
|
||||||
|
|
||||||
|
- name: Run DotNet Format
|
||||||
|
run: dotnet format --verify-no-changes --verbosity minimal
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
name: OpenAPI
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
pull_request_target:
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
openapi-head:
|
|
||||||
name: OpenAPI - HEAD
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions: read-all
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
|
||||||
with:
|
|
||||||
dotnet-version: '9.0.x'
|
|
||||||
- name: Generate openapi.json
|
|
||||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
|
||||||
- name: Upload openapi.json
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: openapi-head
|
|
||||||
retention-days: 14
|
|
||||||
if-no-files-found: error
|
|
||||||
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net9.0/openapi.json
|
|
||||||
|
|
||||||
openapi-base:
|
|
||||||
name: OpenAPI - BASE
|
|
||||||
if: ${{ github.base_ref != '' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions: read-all
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
|
||||||
fetch-depth: 0
|
|
||||||
- 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: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
|
||||||
with:
|
|
||||||
dotnet-version: '9.0.x'
|
|
||||||
- name: Generate openapi.json
|
|
||||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
|
||||||
- name: Upload openapi.json
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: openapi-base
|
|
||||||
retention-days: 14
|
|
||||||
if-no-files-found: error
|
|
||||||
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net9.0/openapi.json
|
|
||||||
|
|
||||||
openapi-diff:
|
|
||||||
permissions:
|
|
||||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
|
||||||
|
|
||||||
name: OpenAPI - Difference
|
|
||||||
if: ${{ github.event_name == 'pull_request_target' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- openapi-head
|
|
||||||
- openapi-base
|
|
||||||
steps:
|
|
||||||
- name: Download openapi-head
|
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
|
||||||
with:
|
|
||||||
name: openapi-head
|
|
||||||
path: openapi-head
|
|
||||||
- name: Download openapi-base
|
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
|
||||||
with:
|
|
||||||
name: openapi-base
|
|
||||||
path: openapi-base
|
|
||||||
- name: Workaround openapi-diff issue
|
|
||||||
run: |
|
|
||||||
sed -i 's/"allOf"/"oneOf"/g' openapi-head/openapi.json
|
|
||||||
sed -i 's/"allOf"/"oneOf"/g' openapi-base/openapi.json
|
|
||||||
- name: Calculate OpenAPI difference
|
|
||||||
uses: docker://openapitools/openapi-diff
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
args: --fail-on-changed --markdown openapi-changes.md openapi-base/openapi.json openapi-head/openapi.json
|
|
||||||
- id: read-diff
|
|
||||||
name: Read openapi-diff output
|
|
||||||
run: |
|
|
||||||
# Read and fix markdown
|
|
||||||
body=$(cat openapi-changes.md)
|
|
||||||
# Write to workflow summary
|
|
||||||
echo "$body" >> $GITHUB_STEP_SUMMARY
|
|
||||||
# Set ApiChanged var
|
|
||||||
if [ "$body" != '' ]; then
|
|
||||||
echo "ApiChanged=1" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "ApiChanged=0" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
# Add header/footer for diff comment
|
|
||||||
echo '<!--openapi-diff-workflow-comment-->' > openapi-changes-reply.md
|
|
||||||
echo "<details>" >> openapi-changes-reply.md
|
|
||||||
echo "<summary>Changes in OpenAPI specification found. Expand to see details.</summary>" >> openapi-changes-reply.md
|
|
||||||
echo "" >> openapi-changes-reply.md
|
|
||||||
echo "$body" >> openapi-changes-reply.md
|
|
||||||
echo "" >> openapi-changes-reply.md
|
|
||||||
echo "</details>" >> openapi-changes-reply.md
|
|
||||||
- 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: openapi-diff-workflow-comment
|
|
||||||
- name: Reply or edit difference comment (changed)
|
|
||||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
|
||||||
if: ${{ steps.read-diff.outputs.ApiChanged == '1' }}
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.pull_request.number }}
|
|
||||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
|
||||||
edit-mode: replace
|
|
||||||
body-path: openapi-changes-reply.md
|
|
||||||
- name: Edit difference comment (unchanged)
|
|
||||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
|
||||||
if: ${{ steps.read-diff.outputs.ApiChanged == '0' && 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
|
|
||||||
body: |
|
|
||||||
<!--openapi-diff-workflow-comment-->
|
|
||||||
|
|
||||||
No changes to OpenAPI specification found. See history of this comment for previous changes.
|
|
||||||
|
|
||||||
publish-unstable:
|
|
||||||
name: OpenAPI - Publish Unstable Spec
|
|
||||||
if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- openapi-head
|
|
||||||
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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
|
||||||
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@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
|
||||||
with:
|
|
||||||
host: "${{ secrets.REPO_HOST }}"
|
|
||||||
username: "${{ secrets.REPO_USER }}"
|
|
||||||
key: "${{ secrets.REPO_KEY }}"
|
|
||||||
debug: false
|
|
||||||
script_stop: 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:
|
|
||||||
- openapi-head
|
|
||||||
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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
|
||||||
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@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
|
||||||
with:
|
|
||||||
host: "${{ secrets.REPO_HOST }}"
|
|
||||||
username: "${{ secrets.REPO_USER }}"
|
|
||||||
key: "${{ secrets.REPO_KEY }}"
|
|
||||||
debug: false
|
|
||||||
script_stop: 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
|
|
||||||
@@ -9,7 +9,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SDK_VERSION: "9.0.x"
|
SDK_VERSION: "10.0.x"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
run-tests:
|
run-tests:
|
||||||
@@ -20,9 +20,9 @@ jobs:
|
|||||||
|
|
||||||
runs-on: "${{ matrix.os }}"
|
runs-on: "${{ matrix.os }}"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.SDK_VERSION }}
|
dotnet-version: ${{ env.SDK_VERSION }}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
--verbosity minimal
|
--verbosity minimal
|
||||||
|
|
||||||
- name: Merge code coverage results
|
- name: Merge code coverage results
|
||||||
uses: danielpalme/ReportGenerator-GitHub-Action@9870ed167742d546b99962ff815fcc1098355ed8 # v5.4.17
|
uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # v5.5.11
|
||||||
with:
|
with:
|
||||||
reports: "**/coverage.cobertura.xml"
|
reports: "**/coverage.cobertura.xml"
|
||||||
targetdir: "merged/"
|
targetdir: "merged/"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
types:
|
types:
|
||||||
- created
|
- created
|
||||||
- edited
|
- edited
|
||||||
pull_request_target:
|
pull_request:
|
||||||
types:
|
types:
|
||||||
- labeled
|
- labeled
|
||||||
- synchronize
|
- synchronize
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
reactions: '+1'
|
reactions: '+1'
|
||||||
|
|
||||||
- name: Checkout the latest code
|
- name: Checkout the latest code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -36,20 +36,23 @@ jobs:
|
|||||||
|
|
||||||
rename:
|
rename:
|
||||||
name: Rename
|
name: Rename
|
||||||
if: contains(github.event.comment.body, '@jellyfin-bot rename') && github.event.comment.author_association == 'MEMBER'
|
if: contains(github.event.comment.body, '@jellyfin-bot rename')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: pull in script
|
- name: pull in script
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
repository: jellyfin/jellyfin-triage-script
|
repository: jellyfin/jellyfin-triage-script
|
||||||
|
|
||||||
- name: install python
|
- name: install python
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|
||||||
- name: install python packages
|
- name: install python packages
|
||||||
run: pip install -r rename/requirements.txt
|
run: pip install -r rename/requirements.txt
|
||||||
|
|
||||||
- name: run rename script
|
- name: run rename script
|
||||||
run: python3 rename.py
|
run: python3 rename.py
|
||||||
working-directory: ./rename
|
working-directory: ./rename
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
ascending: true
|
ascending: true
|
||||||
|
|||||||
@@ -10,16 +10,19 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: pull in script
|
- name: pull in script
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
repository: jellyfin/jellyfin-triage-script
|
repository: jellyfin/jellyfin-triage-script
|
||||||
|
|
||||||
- name: install python
|
- name: install python
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|
||||||
- name: install python packages
|
- name: install python packages
|
||||||
run: pip install -r main-repo-triage/requirements.txt
|
run: pip install -r main-repo-triage/requirements.txt
|
||||||
|
|
||||||
- name: check and comment issue
|
- name: check and comment issue
|
||||||
working-directory: ./main-repo-triage
|
working-directory: ./main-repo-triage
|
||||||
run: python3 single_issue_gha.py
|
run: python3 single_issue_gha.py
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: OpenAPI Generate
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
repository:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
artifact:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
main:
|
||||||
|
name: Main
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref }}
|
||||||
|
repository: ${{ inputs.repository }}
|
||||||
|
|
||||||
|
- name: Configure .NET
|
||||||
|
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
- name: Create File
|
||||||
|
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter Jellyfin.Server.Integration.Tests.OpenApiSpecTests
|
||||||
|
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.artifact }}
|
||||||
|
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json
|
||||||
|
retention-days: 14
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: OpenAPI Check
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ancestor:
|
||||||
|
name: Common Ancestor
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
base_ref: ${{ steps.ancestor.outputs.base_ref }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Search History
|
||||||
|
id: ancestor
|
||||||
|
run: |
|
||||||
|
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
|
||||||
|
git fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
|
||||||
|
|
||||||
|
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} HEAD)
|
||||||
|
|
||||||
|
echo "ref: ${ANCESTOR_REF}"
|
||||||
|
|
||||||
|
echo "base_ref=${ANCESTOR_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
head:
|
||||||
|
name: Head Artifact
|
||||||
|
uses: ./.github/workflows/openapi-generate.yml
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
artifact: openapi-head
|
||||||
|
|
||||||
|
base:
|
||||||
|
name: Base Artifact
|
||||||
|
uses: ./.github/workflows/openapi-generate.yml
|
||||||
|
needs:
|
||||||
|
- ancestor
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.ancestor.outputs.base_ref }}
|
||||||
|
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||||
|
artifact: openapi-base
|
||||||
|
|
||||||
|
diff:
|
||||||
|
name: Generate Report
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- head
|
||||||
|
- base
|
||||||
|
steps:
|
||||||
|
- name: Download Head
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: openapi-head
|
||||||
|
path: openapi-head
|
||||||
|
- name: Download Base
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: openapi-base
|
||||||
|
path: openapi-base
|
||||||
|
- name: Detect Changes
|
||||||
|
id: openapi-diff
|
||||||
|
run: |
|
||||||
|
sed -i 's:allOf:oneOf:g' openapi-head/openapi.json
|
||||||
|
sed -i 's:allOf:oneOf:g' openapi-base/openapi.json
|
||||||
|
|
||||||
|
mkdir -p /tmp/openapi-report
|
||||||
|
mv openapi-head/openapi.json /tmp/openapi-report/head.json
|
||||||
|
mv openapi-base/openapi.json /tmp/openapi-report/base.json
|
||||||
|
|
||||||
|
docker run -v /tmp/openapi-report:/data openapitools/openapi-diff:2.1.6 /data/base.json /data/head.json --state -l ERROR --markdown /data/openapi-report.md
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: openapi-report
|
||||||
|
path: /tmp/openapi-report/openapi-report.md
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request_target:
|
pull_request:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
@@ -21,6 +21,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
project: Current Release
|
project: Current Release
|
||||||
action: delete
|
action: delete
|
||||||
|
column: In progress
|
||||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
|
|
||||||
- name: Add to 'Release Next' project
|
- name: Add to 'Release Next' project
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
issue_comment:
|
types: [synchronize]
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
label:
|
main:
|
||||||
name: Labeling
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.repository == 'jellyfin/jellyfin' && github.event.issue.pull_request }}
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
if: ${{ github.repository == 'jellyfin/jellyfin' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Apply label
|
- name: Apply label
|
||||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
|
|
||||||
with:
|
with:
|
||||||
dirtyLabel: 'merge conflict'
|
dirtyLabel: 'merge conflict'
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
ascending: true
|
ascending: true
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ jobs:
|
|||||||
timeoutSeconds: 3600
|
timeoutSeconds: 3600
|
||||||
|
|
||||||
- name: Setup YQ
|
- name: Setup YQ
|
||||||
uses: chrisdickinson/setup-yq@latest
|
uses: chrisdickinson/setup-yq@fa3192edd79d6eb0e4e12de8dde3a0c26f2b853b # latest
|
||||||
with:
|
with:
|
||||||
yq-version: v4.9.8
|
yq-version: v4.9.8
|
||||||
|
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.TAG_BRANCH }}
|
ref: ${{ env.TAG_BRANCH }}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.TAG_BRANCH }}
|
ref: ${{ env.TAG_BRANCH }}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ local.properties
|
|||||||
.classpath
|
.classpath
|
||||||
.settings/
|
.settings/
|
||||||
.loadpath
|
.loadpath
|
||||||
|
*.lscache
|
||||||
|
|
||||||
# External tool builders
|
# External tool builders
|
||||||
.externalToolBuilders/
|
.externalToolBuilders/
|
||||||
@@ -277,3 +278,7 @@ apiclient/generated
|
|||||||
|
|
||||||
# Omnisharp crash logs
|
# Omnisharp crash logs
|
||||||
mono_crash.*.json
|
mono_crash.*.json
|
||||||
|
|
||||||
|
# Devcontainer temp files
|
||||||
|
.devcontainer/devcontainer-lock.json
|
||||||
|
dotnet/
|
||||||
|
|||||||
Vendored
+3
-3
@@ -6,7 +6,7 @@
|
|||||||
"type": "coreclr",
|
"type": "coreclr",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "build",
|
"preLaunchTask": "build",
|
||||||
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll",
|
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll",
|
||||||
"args": [],
|
"args": [],
|
||||||
"cwd": "${workspaceFolder}/Jellyfin.Server",
|
"cwd": "${workspaceFolder}/Jellyfin.Server",
|
||||||
"console": "internalConsole",
|
"console": "internalConsole",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"type": "coreclr",
|
"type": "coreclr",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "build",
|
"preLaunchTask": "build",
|
||||||
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll",
|
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll",
|
||||||
"args": ["--nowebclient"],
|
"args": ["--nowebclient"],
|
||||||
"cwd": "${workspaceFolder}/Jellyfin.Server",
|
"cwd": "${workspaceFolder}/Jellyfin.Server",
|
||||||
"console": "internalConsole",
|
"console": "internalConsole",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"type": "coreclr",
|
"type": "coreclr",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "build",
|
"preLaunchTask": "build",
|
||||||
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll",
|
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll",
|
||||||
"args": ["--nowebclient", "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"],
|
"args": ["--nowebclient", "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"],
|
||||||
"cwd": "${workspaceFolder}/Jellyfin.Server",
|
"cwd": "${workspaceFolder}/Jellyfin.Server",
|
||||||
"console": "internalConsole",
|
"console": "internalConsole",
|
||||||
|
|||||||
+32
-14
@@ -1,5 +1,6 @@
|
|||||||
# Jellyfin Contributors
|
# Jellyfin Contributors
|
||||||
|
|
||||||
|
- [0x25CBFC4F](https://github.com/0x25CBFC4F)
|
||||||
- [1337joe](https://github.com/1337joe)
|
- [1337joe](https://github.com/1337joe)
|
||||||
- [97carmine](https://github.com/97carmine)
|
- [97carmine](https://github.com/97carmine)
|
||||||
- [Abbe98](https://github.com/Abbe98)
|
- [Abbe98](https://github.com/Abbe98)
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
- [bilde2910](https://github.com/bilde2910)
|
- [bilde2910](https://github.com/bilde2910)
|
||||||
- [bfayers](https://github.com/bfayers)
|
- [bfayers](https://github.com/bfayers)
|
||||||
- [BnMcG](https://github.com/BnMcG)
|
- [BnMcG](https://github.com/BnMcG)
|
||||||
- [Bond-009](https://github.com/Bond-009)
|
- [Bond_009](https://github.com/Bond-009)
|
||||||
- [brianjmurrell](https://github.com/brianjmurrell)
|
- [brianjmurrell](https://github.com/brianjmurrell)
|
||||||
- [bugfixin](https://github.com/bugfixin)
|
- [bugfixin](https://github.com/bugfixin)
|
||||||
- [chaosinnovator](https://github.com/chaosinnovator)
|
- [chaosinnovator](https://github.com/chaosinnovator)
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
- [DaveChild](https://github.com/DaveChild)
|
- [DaveChild](https://github.com/DaveChild)
|
||||||
- [DavidFair](https://github.com/DavidFair)
|
- [DavidFair](https://github.com/DavidFair)
|
||||||
- [Delgan](https://github.com/Delgan)
|
- [Delgan](https://github.com/Delgan)
|
||||||
|
- [DerMaddis](https://github.com/dermaddis)
|
||||||
- [Derpipose](https://github.com/Derpipose)
|
- [Derpipose](https://github.com/Derpipose)
|
||||||
- [dcrdev](https://github.com/dcrdev)
|
- [dcrdev](https://github.com/dcrdev)
|
||||||
- [dhartung](https://github.com/dhartung)
|
- [dhartung](https://github.com/dhartung)
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
- [geilername](https://github.com/geilername)
|
- [geilername](https://github.com/geilername)
|
||||||
- [GermanCoding](https://github.com/GermanCoding)
|
- [GermanCoding](https://github.com/GermanCoding)
|
||||||
- [gnattu](https://github.com/gnattu)
|
- [gnattu](https://github.com/gnattu)
|
||||||
|
- [gnuyent](https://github.com/gnuyent)
|
||||||
- [GodTamIt](https://github.com/GodTamIt)
|
- [GodTamIt](https://github.com/GodTamIt)
|
||||||
- [grafixeyehero](https://github.com/grafixeyehero)
|
- [grafixeyehero](https://github.com/grafixeyehero)
|
||||||
- [h1nk](https://github.com/h1nk)
|
- [h1nk](https://github.com/h1nk)
|
||||||
@@ -61,6 +64,7 @@
|
|||||||
- [HelloWorld017](https://github.com/HelloWorld017)
|
- [HelloWorld017](https://github.com/HelloWorld017)
|
||||||
- [ikomhoog](https://github.com/ikomhoog)
|
- [ikomhoog](https://github.com/ikomhoog)
|
||||||
- [iwalton3](https://github.com/iwalton3)
|
- [iwalton3](https://github.com/iwalton3)
|
||||||
|
- [Jakob Kukla](https://github.com/jakobkukla)
|
||||||
- [jftuga](https://github.com/jftuga)
|
- [jftuga](https://github.com/jftuga)
|
||||||
- [jkhsjdhjs](https://github.com/jkhsjdhjs)
|
- [jkhsjdhjs](https://github.com/jkhsjdhjs)
|
||||||
- [jmshrv](https://github.com/jmshrv)
|
- [jmshrv](https://github.com/jmshrv)
|
||||||
@@ -69,8 +73,10 @@
|
|||||||
- [JustAMan](https://github.com/JustAMan)
|
- [JustAMan](https://github.com/JustAMan)
|
||||||
- [justinfenn](https://github.com/justinfenn)
|
- [justinfenn](https://github.com/justinfenn)
|
||||||
- [JPVenson](https://github.com/JPVenson)
|
- [JPVenson](https://github.com/JPVenson)
|
||||||
|
- [JPUC1143](https://github.com/Jpuc1143/)
|
||||||
- [KerryRJ](https://github.com/KerryRJ)
|
- [KerryRJ](https://github.com/KerryRJ)
|
||||||
- [Larvitar](https://github.com/Larvitar)
|
- [Larvitar](https://github.com/Larvitar)
|
||||||
|
- [lbenini](https://github.com/lbenini)
|
||||||
- [LeoVerto](https://github.com/LeoVerto)
|
- [LeoVerto](https://github.com/LeoVerto)
|
||||||
- [Liggy](https://github.com/Liggy)
|
- [Liggy](https://github.com/Liggy)
|
||||||
- [lmaonator](https://github.com/lmaonator)
|
- [lmaonator](https://github.com/lmaonator)
|
||||||
@@ -83,15 +89,20 @@
|
|||||||
- [marius-luca-87](https://github.com/marius-luca-87)
|
- [marius-luca-87](https://github.com/marius-luca-87)
|
||||||
- [mark-monteiro](https://github.com/mark-monteiro)
|
- [mark-monteiro](https://github.com/mark-monteiro)
|
||||||
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
|
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
|
||||||
|
- [Martin Reuter](https://github.com/reuterma24)
|
||||||
|
- [Matt Teahan](https://github.com/matt-teahan)
|
||||||
- [Matt07211](https://github.com/Matt07211)
|
- [Matt07211](https://github.com/Matt07211)
|
||||||
|
- [Matthew Jones](https://github.com/matthew-jones-uk)
|
||||||
- [Maxr1998](https://github.com/Maxr1998)
|
- [Maxr1998](https://github.com/Maxr1998)
|
||||||
- [mcarlton00](https://github.com/mcarlton00)
|
- [mcarlton00](https://github.com/mcarlton00)
|
||||||
|
- [Michael McElroy](https://github.com/mcmcelro)
|
||||||
- [mitchfizz05](https://github.com/mitchfizz05)
|
- [mitchfizz05](https://github.com/mitchfizz05)
|
||||||
- [mohd-akram](https://github.com/mohd-akram)
|
- [mohd-akram](https://github.com/mohd-akram)
|
||||||
- [MrTimscampi](https://github.com/MrTimscampi)
|
- [MrTimscampi](https://github.com/MrTimscampi)
|
||||||
- [n8225](https://github.com/n8225)
|
- [n8225](https://github.com/n8225)
|
||||||
- [Nalsai](https://github.com/Nalsai)
|
- [Nalsai](https://github.com/Nalsai)
|
||||||
- [Narfinger](https://github.com/Narfinger)
|
- [Narfinger](https://github.com/Narfinger)
|
||||||
|
- [Nathan McCrina](https://github.com/nfmccrina)
|
||||||
- [NathanPickard](https://github.com/NathanPickard)
|
- [NathanPickard](https://github.com/NathanPickard)
|
||||||
- [neilsb](https://github.com/neilsb)
|
- [neilsb](https://github.com/neilsb)
|
||||||
- [nevado](https://github.com/nevado)
|
- [nevado](https://github.com/nevado)
|
||||||
@@ -102,21 +113,26 @@
|
|||||||
- [OancaAndrei](https://github.com/OancaAndrei)
|
- [OancaAndrei](https://github.com/OancaAndrei)
|
||||||
- [obradovichv](https://github.com/obradovichv)
|
- [obradovichv](https://github.com/obradovichv)
|
||||||
- [oddstr13](https://github.com/oddstr13)
|
- [oddstr13](https://github.com/oddstr13)
|
||||||
|
- [olsh](https://github.com/olsh)
|
||||||
- [orryverducci](https://github.com/orryverducci)
|
- [orryverducci](https://github.com/orryverducci)
|
||||||
|
- [PCEWLKR](https://github.com/PCEWLKR)
|
||||||
- [petermcneil](https://github.com/petermcneil)
|
- [petermcneil](https://github.com/petermcneil)
|
||||||
- [Phlogi](https://github.com/Phlogi)
|
- [Phlogi](https://github.com/Phlogi)
|
||||||
- [pjeanjean](https://github.com/pjeanjean)
|
- [pjeanjean](https://github.com/pjeanjean)
|
||||||
- [ploughpuff](https://github.com/ploughpuff)
|
- [ploughpuff](https://github.com/ploughpuff)
|
||||||
|
- [poytiis](https://github.com/poytiis)
|
||||||
- [pR0Ps](https://github.com/pR0Ps)
|
- [pR0Ps](https://github.com/pR0Ps)
|
||||||
- [PrplHaz4](https://github.com/PrplHaz4)
|
- [PrplHaz4](https://github.com/PrplHaz4)
|
||||||
- [RazeLighter777](https://github.com/RazeLighter777)
|
- [RazeLighter777](https://github.com/RazeLighter777)
|
||||||
- [redSpoutnik](https://github.com/redSpoutnik)
|
- [redSpoutnik](https://github.com/redSpoutnik)
|
||||||
- [ringmatter](https://github.com/ringmatter)
|
- [ringmatter](https://github.com/ringmatter)
|
||||||
|
- [Robert Lützner](https://github.com/rluetzner)
|
||||||
- [ryan-hartzell](https://github.com/ryan-hartzell)
|
- [ryan-hartzell](https://github.com/ryan-hartzell)
|
||||||
- [s0urcelab](https://github.com/s0urcelab)
|
- [s0urcelab](https://github.com/s0urcelab)
|
||||||
- [sachk](https://github.com/sachk)
|
- [sachk](https://github.com/sachk)
|
||||||
- [sammyrc34](https://github.com/sammyrc34)
|
- [sammyrc34](https://github.com/sammyrc34)
|
||||||
- [samuel9554](https://github.com/samuel9554)
|
- [samuel9554](https://github.com/samuel9554)
|
||||||
|
- [SapientGuardian](https://github.com/SapientGuardian)
|
||||||
- [scheidleon](https://github.com/scheidleon)
|
- [scheidleon](https://github.com/scheidleon)
|
||||||
- [sebPomme](https://github.com/sebPomme)
|
- [sebPomme](https://github.com/sebPomme)
|
||||||
- [SegiH](https://github.com/SegiH)
|
- [SegiH](https://github.com/SegiH)
|
||||||
@@ -126,6 +142,7 @@
|
|||||||
- [sl1288](https://github.com/sl1288)
|
- [sl1288](https://github.com/sl1288)
|
||||||
- [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)
|
||||||
- [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)
|
||||||
@@ -148,12 +165,14 @@
|
|||||||
- [twinkybot](https://github.com/twinkybot)
|
- [twinkybot](https://github.com/twinkybot)
|
||||||
- [Ullmie02](https://github.com/Ullmie02)
|
- [Ullmie02](https://github.com/Ullmie02)
|
||||||
- [Unhelpful](https://github.com/Unhelpful)
|
- [Unhelpful](https://github.com/Unhelpful)
|
||||||
|
- [Utku Özdemir](https://github.com/utkuozdemir)
|
||||||
- [viaregio](https://github.com/viaregio)
|
- [viaregio](https://github.com/viaregio)
|
||||||
- [vitorsemeano](https://github.com/vitorsemeano)
|
- [vitorsemeano](https://github.com/vitorsemeano)
|
||||||
- [voodoos](https://github.com/voodoos)
|
- [voodoos](https://github.com/voodoos)
|
||||||
- [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)
|
||||||
@@ -163,6 +182,7 @@
|
|||||||
- [XVicarious](https://github.com/XVicarious)
|
- [XVicarious](https://github.com/XVicarious)
|
||||||
- [YouKnowBlom](https://github.com/YouKnowBlom)
|
- [YouKnowBlom](https://github.com/YouKnowBlom)
|
||||||
- [ZachPhelan](https://github.com/ZachPhelan)
|
- [ZachPhelan](https://github.com/ZachPhelan)
|
||||||
|
- [ZeusCraft10](https://github.com/ZeusCraft10)
|
||||||
- [KristupasSavickas](https://github.com/KristupasSavickas)
|
- [KristupasSavickas](https://github.com/KristupasSavickas)
|
||||||
- [Pusta](https://github.com/pusta)
|
- [Pusta](https://github.com/pusta)
|
||||||
- [nielsvanvelzen](https://github.com/nielsvanvelzen)
|
- [nielsvanvelzen](https://github.com/nielsvanvelzen)
|
||||||
@@ -205,6 +225,17 @@
|
|||||||
- [theshoeshiner](https://github.com/theshoeshiner)
|
- [theshoeshiner](https://github.com/theshoeshiner)
|
||||||
- [TokerX](https://github.com/TokerX)
|
- [TokerX](https://github.com/TokerX)
|
||||||
- [GeneMarks](https://github.com/GeneMarks)
|
- [GeneMarks](https://github.com/GeneMarks)
|
||||||
|
- [Kirill Nikiforov](https://github.com/allmazz)
|
||||||
|
- [bjorntp](https://github.com/bjorntp)
|
||||||
|
- [martenumberto](https://github.com/martenumberto)
|
||||||
|
- [ZeusCraft10](https://github.com/ZeusCraft10)
|
||||||
|
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
|
||||||
|
- [LiHRaM](https://github.com/LiHRaM)
|
||||||
|
- [MSalman5230](https://github.com/MSalman5230)
|
||||||
|
- [dwandw](https://github.com/dwandw)
|
||||||
|
- [Lampan-git](https://github.com/Lampan-git)
|
||||||
|
- [elio42](https://github.com/elio42)
|
||||||
|
- [rwebster85](https://github.com/rwebster85)
|
||||||
|
|
||||||
# Emby Contributors
|
# Emby Contributors
|
||||||
|
|
||||||
@@ -268,16 +299,3 @@
|
|||||||
- [tikuf](https://github.com/tikuf/)
|
- [tikuf](https://github.com/tikuf/)
|
||||||
- [Tim Hobbs](https://github.com/timhobbs)
|
- [Tim Hobbs](https://github.com/timhobbs)
|
||||||
- [SvenVandenbrande](https://github.com/SvenVandenbrande)
|
- [SvenVandenbrande](https://github.com/SvenVandenbrande)
|
||||||
- [olsh](https://github.com/olsh)
|
|
||||||
- [lbenini](https://github.com/lbenini)
|
|
||||||
- [gnuyent](https://github.com/gnuyent)
|
|
||||||
- [Matthew Jones](https://github.com/matthew-jones-uk)
|
|
||||||
- [Jakob Kukla](https://github.com/jakobkukla)
|
|
||||||
- [Utku Özdemir](https://github.com/utkuozdemir)
|
|
||||||
- [JPUC1143](https://github.com/Jpuc1143/)
|
|
||||||
- [0x25CBFC4F](https://github.com/0x25CBFC4F)
|
|
||||||
- [Robert Lützner](https://github.com/rluetzner)
|
|
||||||
- [Nathan McCrina](https://github.com/nfmccrina)
|
|
||||||
- [Martin Reuter](https://github.com/reuterma24)
|
|
||||||
- [Michael McElroy](https://github.com/mcmcelro)
|
|
||||||
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
|
|
||||||
|
|||||||
+52
-61
@@ -4,96 +4,87 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
||||||
<ItemGroup Label="Package Dependencies">
|
<ItemGroup Label="Package Dependencies">
|
||||||
<PackageVersion Include="AsyncKeyedLock" Version="7.1.7" />
|
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
|
||||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||||
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
|
||||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||||
<PackageVersion Include="BDInfo" Version="0.8.0" />
|
<PackageVersion Include="BDInfo" Version="0.8.0" />
|
||||||
<PackageVersion Include="BitFaster.Caching" Version="2.5.4" />
|
<PackageVersion Include="BitFaster.Caching" Version="2.6.0" />
|
||||||
<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" />
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||||
<PackageVersion Include="Diacritics" Version="4.0.17" />
|
<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" Version="3.3.1" />
|
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.4" />
|
||||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
|
<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" />
|
||||||
<PackageVersion Include="Ignore" Version="0.2.1" />
|
<PackageVersion Include="Ignore" Version="0.2.1" />
|
||||||
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
|
<PackageVersion Include="Jellyfin.XmlTv" Version="10.12.0-pre1" />
|
||||||
<PackageVersion Include="libse" Version="4.0.12" />
|
<PackageVersion Include="libse" Version="4.0.12" />
|
||||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
|
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
|
||||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||||
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
<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="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" />
|
||||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||||
<PackageVersion Include="Polly" Version="8.6.4" />
|
<PackageVersion Include="Polly" Version="8.7.0" />
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||||
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
|
<PackageVersion Include="SharpCompress" Version="0.50.1" />
|
||||||
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
|
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
|
||||||
<PackageVersion Include="SkiaSharp" Version="3.116.1" />
|
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
|
||||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.116.1" />
|
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
||||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
<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="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||||
<PackageVersion Include="Svg.Skia" Version="3.2.1" />
|
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
|
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
<PackageVersion Include="System.Text.Json" Version="10.0.10" />
|
||||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
|
||||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
|
|
||||||
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
|
|
||||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.10" />
|
|
||||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||||
<PackageVersion Include="z440.atl.core" Version="7.5.0" />
|
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
|
||||||
<PackageVersion Include="TMDbLib" Version="2.3.0" />
|
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||||
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
<PackageVersion Include="Xunit.v3.Priority" Version="1.1.18" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#pragma warning disable CA1815
|
||||||
|
|
||||||
namespace Emby.Naming.AudioBook
|
namespace Emby.Naming.AudioBook
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Book
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Helper class to retrieve basic metadata from a book filename.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class BookFileNameParser
|
||||||
|
{
|
||||||
|
private const string NameMatchGroup = "name";
|
||||||
|
private const string IndexMatchGroup = "index";
|
||||||
|
private const string YearMatchGroup = "year";
|
||||||
|
private const string SeriesNameMatchGroup = "seriesName";
|
||||||
|
|
||||||
|
private static readonly Regex[] _nameMatches =
|
||||||
|
[
|
||||||
|
// seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required
|
||||||
|
new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)(?:\.0)?((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||||
|
new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)(?:\.0)?((\s\((?<year>[0-9]{4})\))?)$"),
|
||||||
|
new Regex(@"^(?<index>[0-9]+)(?:\.0)?\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||||
|
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
|
||||||
|
// last resort matches the whole string as the name
|
||||||
|
new Regex(@"(?<name>.*)")
|
||||||
|
];
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(?<name>.+?)(\sv(?<volume>[0-9]+))?(\sc(?<chapter>[0-9]+))?$")]
|
||||||
|
private static partial Regex ComicRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a filename name to retrieve the book name, series name, index, and year.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Book filename to parse for information.</param>
|
||||||
|
/// <returns>Returns <see cref="BookFileNameParserResult"/> object.</returns>
|
||||||
|
public static BookFileNameParserResult Parse(string? name)
|
||||||
|
{
|
||||||
|
var result = new BookFileNameParserResult();
|
||||||
|
|
||||||
|
if (name == null)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var regex in _nameMatches)
|
||||||
|
{
|
||||||
|
var match = regex.Match(name);
|
||||||
|
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success)
|
||||||
|
{
|
||||||
|
var comicMatch = ComicRegex().Match(nameGroup.Value.Trim());
|
||||||
|
|
||||||
|
if (comicMatch.Success)
|
||||||
|
{
|
||||||
|
if (comicMatch.Groups.TryGetValue("volume", out Group? volumeGroup) && volumeGroup.Success && int.TryParse(volumeGroup.ValueSpan, out var volume))
|
||||||
|
{
|
||||||
|
result.ParentIndex = volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comicMatch.Groups.TryGetValue("chapter", out Group? chapterGroup) && chapterGroup.Success && int.TryParse(chapterGroup.ValueSpan, out var chapter))
|
||||||
|
{
|
||||||
|
result.Index = chapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Name = nameGroup.ValueSpan.Trim().ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index))
|
||||||
|
{
|
||||||
|
result.Index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups.TryGetValue(YearMatchGroup, out Group? yearGroup) && yearGroup.Success && int.TryParse(yearGroup.Value, out var year))
|
||||||
|
{
|
||||||
|
result.Year = year;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups.TryGetValue(SeriesNameMatchGroup, out Group? seriesGroup) && seriesGroup.Success)
|
||||||
|
{
|
||||||
|
result.SeriesName = seriesGroup.Value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace Emby.Naming.Book
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data object used to pass metadata parsed from a book filename.
|
||||||
|
/// </summary>
|
||||||
|
public class BookFileNameParserResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="BookFileNameParserResult"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public BookFileNameParserResult()
|
||||||
|
{
|
||||||
|
Name = null;
|
||||||
|
Index = null;
|
||||||
|
ParentIndex = null;
|
||||||
|
Year = null;
|
||||||
|
SeriesName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the book.
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the book index.
|
||||||
|
/// </summary>
|
||||||
|
public int? Index { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the parent index number.
|
||||||
|
/// </summary>
|
||||||
|
public int? ParentIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the publication year.
|
||||||
|
/// </summary>
|
||||||
|
public int? Year { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the series name.
|
||||||
|
/// </summary>
|
||||||
|
public string? SeriesName { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,6 @@ namespace Emby.Naming.Common
|
|||||||
".nrg",
|
".nrg",
|
||||||
".nsv",
|
".nsv",
|
||||||
".nuv",
|
".nuv",
|
||||||
".ogg",
|
|
||||||
".ogm",
|
".ogm",
|
||||||
".ogv",
|
".ogv",
|
||||||
".pva",
|
".pva",
|
||||||
@@ -152,8 +151,8 @@ 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|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS)(?=[ _\,\.\(\)\[\]\-]|$)",
|
||||||
@"^(?<cleaned>.+?)(\[.*\])",
|
@"^\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>.+)",
|
||||||
@"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$",
|
@"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$",
|
||||||
@@ -225,6 +224,7 @@ namespace Emby.Naming.Common
|
|||||||
".afc",
|
".afc",
|
||||||
".amf",
|
".amf",
|
||||||
".aif",
|
".aif",
|
||||||
|
".aifc",
|
||||||
".aiff",
|
".aiff",
|
||||||
".alac",
|
".alac",
|
||||||
".amr",
|
".amr",
|
||||||
@@ -361,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
|
||||||
},
|
},
|
||||||
@@ -378,6 +381,14 @@ namespace Emby.Naming.Common
|
|||||||
IsNamed = true
|
IsNamed = true
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// "Name - 101.mkv", "Name - 101 [720p].mkv", "Name - 101 (2020).mkv"
|
||||||
|
// Handles absolute episode numbers with hyphen delimiter (common in anime)
|
||||||
|
// Without brackets (bracketed version handled above)
|
||||||
|
new EpisodeExpression(@".*[\\\/](?<seriesname>[^\\\/]+?)[\s_]+-[\s_]+(?<epnumber>[0-9]+)[\s_]*(?:\[.*?\]|\(.*?\))*[\s_]*(?:\.\w+)?$")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
// /server/anything_102.mp4
|
// /server/anything_102.mp4
|
||||||
// /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv
|
// /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv
|
||||||
// /server/anything_1996.11.14.mp4
|
// /server/anything_1996.11.14.mp4
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Naming</PackageId>
|
<PackageId>Jellyfin.Naming</PackageId>
|
||||||
<VersionPrefix>10.11.0</VersionPrefix>
|
<VersionPrefix>12.0.0</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ namespace Emby.Naming.ExternalFiles
|
|||||||
|
|
||||||
if (lastSeparator == -1)
|
if (lastSeparator == -1)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
string currentSlice = languageString[lastSeparator..];
|
string currentSlice = languageString[lastSeparator..];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -10,12 +10,25 @@ namespace Emby.Naming.TV
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static partial class SeasonPathParser
|
public static partial class SeasonPathParser
|
||||||
{
|
{
|
||||||
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$")]
|
private const string SeasonKeywordPattern =
|
||||||
|
@"시즌|シーズン|сезон" +
|
||||||
|
@"|season|sæson|saison|staffel|series|stagione|säsong|seizoen|seasong" +
|
||||||
|
@"|sezon|sezona|sezóna|sezonul|série|séria|serie|seria|temporada|kausi";
|
||||||
|
|
||||||
|
private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:" + SeasonKeywordPattern + @")\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex ProcessPre();
|
private static partial Regex ProcessPre();
|
||||||
|
|
||||||
[GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$")]
|
[GeneratedRegex(@"^\s*(?:" + SeasonKeywordPattern + @")\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex ProcessPost();
|
private static partial Regex ProcessPost();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
|
||||||
|
private static partial Regex SeasonPrefix();
|
||||||
|
|
||||||
|
[GeneratedRegex(SeasonKeywordPattern, RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex SeasonKeyword();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to parse season number from path.
|
/// Attempts to parse season number from path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -56,54 +69,55 @@ namespace Emby.Naming.TV
|
|||||||
bool supportSpecialAliases,
|
bool supportSpecialAliases,
|
||||||
bool supportNumericSeasonFolders)
|
bool supportNumericSeasonFolders)
|
||||||
{
|
{
|
||||||
string filename = Path.GetFileName(path);
|
var fileName = Path.GetFileName(path);
|
||||||
filename = Regex.Replace(filename, "[ ._-]", string.Empty);
|
|
||||||
|
var seasonPrefixMatch = SeasonPrefix().Match(fileName);
|
||||||
|
if (seasonPrefixMatch.Success &&
|
||||||
|
int.TryParse(seasonPrefixMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
||||||
|
{
|
||||||
|
return (val, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
string filename = CleanNameRegex.Replace(fileName, string.Empty);
|
||||||
|
|
||||||
if (parentFolderName is not null)
|
if (parentFolderName is not null)
|
||||||
{
|
{
|
||||||
parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty);
|
var cleanParent = CleanNameRegex.Replace(parentFolderName, string.Empty);
|
||||||
filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase);
|
filename = filename.Replace(cleanParent, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportSpecialAliases)
|
if (supportSpecialAliases &&
|
||||||
|
(filename.Equals("specials", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
filename.Equals("extras", StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
if (string.Equals(filename, "specials", StringComparison.OrdinalIgnoreCase))
|
return (0, true);
|
||||||
{
|
|
||||||
return (0, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(filename, "extras", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return (0, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportNumericSeasonFolders)
|
if (supportNumericSeasonFolders &&
|
||||||
|
int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val))
|
||||||
{
|
{
|
||||||
if (int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
return (val, true);
|
||||||
{
|
|
||||||
return (val, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filename.StartsWith('s'))
|
|
||||||
{
|
|
||||||
var testFilename = filename.AsSpan()[1..];
|
|
||||||
|
|
||||||
if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
|
||||||
{
|
|
||||||
return (val, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isMixedLibrary = !supportNumericSeasonFolders && !supportSpecialAliases;
|
||||||
var preMatch = ProcessPre().Match(filename);
|
var preMatch = ProcessPre().Match(filename);
|
||||||
if (preMatch.Success)
|
if (preMatch.Success)
|
||||||
{
|
{
|
||||||
|
if (isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
|
||||||
|
{
|
||||||
|
return (null, false);
|
||||||
|
}
|
||||||
|
|
||||||
return CheckMatch(preMatch);
|
return CheckMatch(preMatch);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var postMatch = ProcessPost().Match(filename);
|
var postMatch = ProcessPost().Match(filename);
|
||||||
|
if (postMatch.Success && isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
|
||||||
|
{
|
||||||
|
return (null, false);
|
||||||
|
}
|
||||||
|
|
||||||
return CheckMatch(postMatch);
|
return CheckMatch(postMatch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,8 +127,10 @@ namespace Emby.Naming.TV
|
|||||||
var numberString = match.Groups["seasonnumber"];
|
var numberString = match.Groups["seasonnumber"];
|
||||||
if (numberString.Success)
|
if (numberString.Success)
|
||||||
{
|
{
|
||||||
var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture);
|
if (int.TryParse(numberString.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber))
|
||||||
return (seasonNumber, true);
|
{
|
||||||
|
return (seasonNumber, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (null, false);
|
return (null, false);
|
||||||
|
|||||||
@@ -25,5 +25,11 @@ namespace Emby.Naming.TV
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The name of the series.</value>
|
/// <value>The name of the series.</value>
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the year of the series.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The year of the series.</value>
|
||||||
|
public int? Year { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ namespace Emby.Naming.TV
|
|||||||
[GeneratedRegex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))")]
|
[GeneratedRegex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))")]
|
||||||
private static partial Regex SeriesNameRegex();
|
private static partial Regex SeriesNameRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regex that matches titles with year in parentheses. Captures the title (which may be
|
||||||
|
/// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"(?<title>.+?)\s*\((?<year>[0-9]{4})\)")]
|
||||||
|
private static partial Regex TitleWithYearRegex();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolve information about series from path.
|
/// Resolve information about series from path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -27,6 +34,21 @@ namespace Emby.Naming.TV
|
|||||||
{
|
{
|
||||||
string seriesName = Path.GetFileName(path);
|
string seriesName = Path.GetFileName(path);
|
||||||
|
|
||||||
|
// First check if the filename matches a title with year pattern (handles numeric titles)
|
||||||
|
if (!string.IsNullOrEmpty(seriesName))
|
||||||
|
{
|
||||||
|
var titleWithYearMatch = TitleWithYearRegex().Match(seriesName);
|
||||||
|
if (titleWithYearMatch.Success)
|
||||||
|
{
|
||||||
|
seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
|
||||||
|
return new SeriesInfo(path)
|
||||||
|
{
|
||||||
|
Name = seriesName,
|
||||||
|
Year = int.TryParse(titleWithYearMatch.Groups["year"].ValueSpan, out var year) ? year : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SeriesPathParserResult result = SeriesPathParser.Parse(options, path);
|
SeriesPathParserResult result = SeriesPathParser.Parse(options, path);
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public static class TvParserHelpers
|
|||||||
/// <param name="status">The status string.</param>
|
/// <param name="status">The status string.</param>
|
||||||
/// <param name="enumValue">The <see cref="SeriesStatus"/>.</param>
|
/// <param name="enumValue">The <see cref="SeriesStatus"/>.</param>
|
||||||
/// <returns>Returns true if parsing was successful.</returns>
|
/// <returns>Returns true if parsing was successful.</returns>
|
||||||
public static bool TryParseSeriesStatus(string status, out SeriesStatus? enumValue)
|
public static bool TryParseSeriesStatus(string? status, out SeriesStatus? enumValue)
|
||||||
{
|
{
|
||||||
if (Enum.TryParse(status, true, out SeriesStatus seriesStatus))
|
if (Enum.TryParse(status, true, out SeriesStatus seriesStatus))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#pragma warning disable CA1815
|
||||||
|
|
||||||
namespace Emby.Naming.Video
|
namespace Emby.Naming.Video
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ namespace Emby.Naming.Video
|
|||||||
var match = expression.Match(name);
|
var match = expression.Match(name);
|
||||||
if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned))
|
if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned))
|
||||||
{
|
{
|
||||||
newName = cleaned.Value;
|
newName = cleaned.Value.Trim();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ namespace Emby.Naming.Video
|
|||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
|
|
||||||
Files = Array.Empty<VideoFileInfo>();
|
Files = [];
|
||||||
AlternateVersions = Array.Empty<VideoFileInfo>();
|
AlternateVersions = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -40,10 +40,10 @@ namespace Emby.Naming.Video
|
|||||||
public IReadOnlyList<VideoFileInfo> Files { get; set; }
|
public IReadOnlyList<VideoFileInfo> Files { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the alternate versions.
|
/// Gets or sets the alternate versions. Each alternate may itself span multiple files.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The alternate versions.</value>
|
/// <value>The alternate versions.</value>
|
||||||
public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; }
|
public IReadOnlyList<VideoInfo> AlternateVersions { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the extra type.
|
/// Gets or sets the extra type.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Emby.Naming.Common;
|
using Emby.Naming.Common;
|
||||||
using Jellyfin.Extensions;
|
using Emby.Naming.TV;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
|
|
||||||
namespace Emby.Naming.Video
|
namespace Emby.Naming.Video
|
||||||
@@ -12,8 +14,23 @@ namespace Emby.Naming.Video
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves alternative versions and extras from list of video files.
|
/// Resolves alternative versions and extras from list of video files.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static partial class VideoListResolver
|
public partial class VideoListResolver
|
||||||
{
|
{
|
||||||
|
private static readonly StringComparer _numericOrdinalComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
|
||||||
|
|
||||||
|
private readonly NamingOptions _namingOptions;
|
||||||
|
private readonly EpisodePathParser _episodePathParser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="VideoListResolver"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
public VideoListResolver(NamingOptions namingOptions)
|
||||||
|
{
|
||||||
|
_namingOptions = namingOptions;
|
||||||
|
_episodePathParser = new EpisodePathParser(namingOptions);
|
||||||
|
}
|
||||||
|
|
||||||
[GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)]
|
[GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex ResolutionRegex();
|
private static partial Regex ResolutionRegex();
|
||||||
|
|
||||||
@@ -24,12 +41,12 @@ namespace Emby.Naming.Video
|
|||||||
/// Resolves alternative versions and extras from list of video files.
|
/// Resolves alternative versions and extras from list of video files.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="videoInfos">List of related video files.</param>
|
/// <param name="videoInfos">List of related video files.</param>
|
||||||
/// <param name="namingOptions">The naming options.</param>
|
|
||||||
/// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param>
|
/// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param>
|
||||||
/// <param name="parseName">Whether to parse the name or use the filename.</param>
|
/// <param name="parseName">Whether to parse the name or use the filename.</param>
|
||||||
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
|
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
|
||||||
|
/// <param name="collectionType">The type of the containing collection, if known.</param>
|
||||||
/// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
|
/// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
|
||||||
public static IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "")
|
public IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "", CollectionType? collectionType = null)
|
||||||
{
|
{
|
||||||
// Filter out all extras, otherwise they could cause stacks to not be resolved
|
// Filter out all extras, otherwise they could cause stacks to not be resolved
|
||||||
// See the unit test TestStackedWithTrailer
|
// See the unit test TestStackedWithTrailer
|
||||||
@@ -37,7 +54,7 @@ namespace Emby.Naming.Video
|
|||||||
.Where(i => i.ExtraType is null)
|
.Where(i => i.ExtraType is null)
|
||||||
.Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory });
|
.Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory });
|
||||||
|
|
||||||
var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList();
|
var stackResult = StackResolver.Resolve(nonExtras, _namingOptions).ToList();
|
||||||
|
|
||||||
var remainingFiles = new List<VideoFileInfo>();
|
var remainingFiles = new List<VideoFileInfo>();
|
||||||
var standaloneMedia = new List<VideoFileInfo>();
|
var standaloneMedia = new List<VideoFileInfo>();
|
||||||
@@ -66,7 +83,7 @@ namespace Emby.Naming.Video
|
|||||||
{
|
{
|
||||||
var info = new VideoInfo(stack.Name)
|
var info = new VideoInfo(stack.Name)
|
||||||
{
|
{
|
||||||
Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName, libraryRoot))
|
Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, _namingOptions, parseName, libraryRoot))
|
||||||
.OfType<VideoFileInfo>()
|
.OfType<VideoFileInfo>()
|
||||||
.ToList()
|
.ToList()
|
||||||
};
|
};
|
||||||
@@ -85,7 +102,9 @@ namespace Emby.Naming.Video
|
|||||||
|
|
||||||
if (supportMultiVersion)
|
if (supportMultiVersion)
|
||||||
{
|
{
|
||||||
list = GetVideosGroupedByVersion(list, namingOptions);
|
list = collectionType is CollectionType.tvshows
|
||||||
|
? GetEpisodesGroupedByVersion(list)
|
||||||
|
: GetVideosGroupedByVersion(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whatever files are left, just add them
|
// Whatever files are left, just add them
|
||||||
@@ -99,7 +118,7 @@ namespace Emby.Naming.Video
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions)
|
private List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos)
|
||||||
{
|
{
|
||||||
if (videos.Count == 0)
|
if (videos.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -123,7 +142,7 @@ namespace Emby.Naming.Video
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions))
|
if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension))
|
||||||
{
|
{
|
||||||
return videos;
|
return videos;
|
||||||
}
|
}
|
||||||
@@ -134,37 +153,9 @@ namespace Emby.Naming.Video
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videos.Count > 1)
|
var organized = OrganizeAlternateVersions(videos, primary, folderName.ToString());
|
||||||
{
|
|
||||||
var groups = videos.GroupBy(x => ResolutionRegex().IsMatch(x.Files[0].FileNameWithoutExtension)).ToList();
|
|
||||||
videos.Clear();
|
|
||||||
foreach (var group in groups)
|
|
||||||
{
|
|
||||||
if (group.Key)
|
|
||||||
{
|
|
||||||
videos.InsertRange(0, group
|
|
||||||
.OrderByDescending(x => ResolutionRegex().Match(x.Files[0].FileNameWithoutExtension.ToString()).Value, new AlphanumericComparator())
|
|
||||||
.ThenBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator()));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
videos.AddRange(group.OrderBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
primary ??= videos[0];
|
return [organized];
|
||||||
videos.Remove(primary);
|
|
||||||
|
|
||||||
var list = new List<VideoInfo>
|
|
||||||
{
|
|
||||||
primary
|
|
||||||
};
|
|
||||||
|
|
||||||
list[0].AlternateVersions = videos.Select(x => x.Files[0]).ToArray();
|
|
||||||
list[0].Name = folderName.ToString();
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos)
|
private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos)
|
||||||
@@ -186,7 +177,7 @@ namespace Emby.Naming.Video
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename, NamingOptions namingOptions)
|
private bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename)
|
||||||
{
|
{
|
||||||
if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
|
if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -200,7 +191,7 @@ namespace Emby.Naming.Video
|
|||||||
}
|
}
|
||||||
|
|
||||||
// There are no span overloads for regex unfortunately
|
// There are no span overloads for regex unfortunately
|
||||||
if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName))
|
if (CleanStringParser.TryClean(testFilename.ToString(), _namingOptions.CleanStringRegexes, out var cleanName))
|
||||||
{
|
{
|
||||||
testFilename = cleanName.AsSpan().Trim();
|
testFilename = cleanName.AsSpan().Trim();
|
||||||
}
|
}
|
||||||
@@ -208,7 +199,117 @@ namespace Emby.Naming.Video
|
|||||||
// The CleanStringParser should have removed common keywords etc.
|
// The CleanStringParser should have removed common keywords etc.
|
||||||
return testFilename.IsEmpty
|
return testFilename.IsEmpty
|
||||||
|| testFilename[0] == '-'
|
|| testFilename[0] == '-'
|
||||||
|
|| testFilename[0] == '_'
|
||||||
|
|| testFilename[0] == '.'
|
||||||
|| CheckMultiVersionRegex().IsMatch(testFilename);
|
|| CheckMultiVersionRegex().IsMatch(testFilename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<VideoInfo> GetEpisodesGroupedByVersion(List<VideoInfo> videos)
|
||||||
|
{
|
||||||
|
if (videos.Count < 2)
|
||||||
|
{
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<VideoInfo>();
|
||||||
|
var groups = new Dictionary<string, List<VideoInfo>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
for (var i = 0; i < videos.Count; i++)
|
||||||
|
{
|
||||||
|
var video = videos[i];
|
||||||
|
var episodeResult = _episodePathParser.Parse(video.Files[0].Path, false);
|
||||||
|
string? key = null;
|
||||||
|
if (episodeResult.Success)
|
||||||
|
{
|
||||||
|
if (episodeResult.IsByDate
|
||||||
|
&& episodeResult.Year.HasValue
|
||||||
|
&& episodeResult.Month.HasValue
|
||||||
|
&& episodeResult.Day.HasValue)
|
||||||
|
{
|
||||||
|
key = FormattableString.Invariant(
|
||||||
|
$"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}");
|
||||||
|
}
|
||||||
|
else if (episodeResult.EpisodeNumber.HasValue)
|
||||||
|
{
|
||||||
|
key = FormattableString.Invariant(
|
||||||
|
$"S{episodeResult.SeasonNumber ?? 0}E{episodeResult.EpisodeNumber.Value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key is null)
|
||||||
|
{
|
||||||
|
result.Add(video);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groups.TryGetValue(key, out var group))
|
||||||
|
{
|
||||||
|
group = [];
|
||||||
|
groups[key] = group;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.Add(video);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in groups.Values)
|
||||||
|
{
|
||||||
|
if (group.Count == 1)
|
||||||
|
{
|
||||||
|
result.Add(group[0]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(OrganizeAlternateVersions(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VideoInfo OrganizeAlternateVersions(
|
||||||
|
List<VideoInfo> videos,
|
||||||
|
VideoInfo? primaryOverride = null,
|
||||||
|
string? nameOverride = null)
|
||||||
|
{
|
||||||
|
if (videos.Count > 1)
|
||||||
|
{
|
||||||
|
var groups = videos
|
||||||
|
.Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x))
|
||||||
|
.Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value))
|
||||||
|
.GroupBy(x => x.resolutionMatch.Success)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
videos = [];
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
if (group.Key)
|
||||||
|
{
|
||||||
|
videos.InsertRange(0, group
|
||||||
|
.OrderByDescending(x => x.resolutionMatch.Value, _numericOrdinalComparer)
|
||||||
|
.ThenBy(x => x.filename, _numericOrdinalComparer)
|
||||||
|
.Select(x => x.value));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
videos.AddRange(group.OrderBy(x => x.filename, _numericOrdinalComparer).Select(x => x.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer a stacked entry (more than one part) as primary
|
||||||
|
var primary = primaryOverride
|
||||||
|
?? videos.FirstOrDefault(v => v.Files.Count > 1)
|
||||||
|
?? videos[0];
|
||||||
|
videos.Remove(primary);
|
||||||
|
|
||||||
|
primary.AlternateVersions = videos;
|
||||||
|
|
||||||
|
if (nameOverride is not null)
|
||||||
|
{
|
||||||
|
primary.Name = nameOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
return primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
CreateAndCheckMarker(ProgramDataPath, "data");
|
CreateAndCheckMarker(ProgramDataPath, "data");
|
||||||
CreateAndCheckMarker(CachePath, "cache");
|
CreateAndCheckMarker(CachePath, "cache");
|
||||||
CreateAndCheckMarker(DataPath, "data");
|
CreateAndCheckMarker(DataPath, "data");
|
||||||
|
CreateCacheDirTag(CachePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -100,6 +101,26 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
CheckOrCreateMarker(path, $".jellyfin-{markerName}", recursive);
|
CheckOrCreateMarker(path, $".jellyfin-{markerName}", recursive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a CACHEDIR.TAG file in the specified directory per the Cache Directory Tagging specification.
|
||||||
|
/// This signals to backup tools (e.g. Restic, Borg) that the directory contains cached data
|
||||||
|
/// and can be excluded from backups.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The cache directory path.</param>
|
||||||
|
internal static void CreateCacheDirTag(string path)
|
||||||
|
{
|
||||||
|
var tagPath = Path.Combine(path, "CACHEDIR.TAG");
|
||||||
|
if (!File.Exists(tagPath))
|
||||||
|
{
|
||||||
|
File.WriteAllText(
|
||||||
|
tagPath,
|
||||||
|
"Signature: 8a477f597d28d172789f06886806bc55\n"
|
||||||
|
+ "# This file is a cache directory tag created by Jellyfin.\n"
|
||||||
|
+ "# For information about cache directory tags, see:\n"
|
||||||
|
+ "#\thttps://bford.info/cachedir/\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private IEnumerable<string> GetMarkers(string path, bool recursive = false)
|
private IEnumerable<string> GetMarkers(string path, bool recursive = false)
|
||||||
{
|
{
|
||||||
return Directory.EnumerateFiles(path, ".jellyfin-*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);
|
return Directory.EnumerateFiles(path, ".jellyfin-*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);
|
||||||
@@ -107,10 +128,20 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
|
|
||||||
private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
|
private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
|
||||||
{
|
{
|
||||||
var otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => Path.GetFileName(e) != markerName);
|
string? otherMarkers = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => !Path.GetFileName(e.AsSpan()).Equals(markerName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Error while checking for marker files, assume none exist and keep going
|
||||||
|
// TODO: add some logging
|
||||||
|
}
|
||||||
|
|
||||||
if (otherMarkers is not null)
|
if (otherMarkers is not null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Exepected to find only {markerName} but found marker for {otherMarkers}.");
|
throw new InvalidOperationException($"Expected to find only {markerName} but found marker for {otherMarkers}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var markerPath = Path.Combine(path, markerName);
|
var markerPath = Path.Combine(path, markerName);
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_configurationFactories = [.._configurationFactories, factory];
|
_configurationFactories = [.. _configurationFactories, factory];
|
||||||
}
|
}
|
||||||
|
|
||||||
_configurationStores = _configurationFactories
|
_configurationStores = _configurationFactories
|
||||||
@@ -228,6 +228,7 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
Logger.LogInformation("Setting cache path: {Path}", cachePath);
|
Logger.LogInformation("Setting cache path: {Path}", cachePath);
|
||||||
((BaseApplicationPaths)CommonApplicationPaths).CachePath = cachePath;
|
((BaseApplicationPaths)CommonApplicationPaths).CachePath = cachePath;
|
||||||
CommonApplicationPaths.CreateAndCheckMarker(((BaseApplicationPaths)CommonApplicationPaths).CachePath, "cache");
|
CommonApplicationPaths.CreateAndCheckMarker(((BaseApplicationPaths)CommonApplicationPaths).CachePath, "cache");
|
||||||
|
BaseApplicationPaths.CreateCacheDirTag(cachePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using System.Reflection;
|
|||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Emby.Naming.Common;
|
using Emby.Naming.Common;
|
||||||
|
using Emby.Naming.Video;
|
||||||
using Emby.Photos;
|
using Emby.Photos;
|
||||||
using Emby.Server.Implementations.Chapters;
|
using Emby.Server.Implementations.Chapters;
|
||||||
using Emby.Server.Implementations.Collections;
|
using Emby.Server.Implementations.Collections;
|
||||||
@@ -25,6 +26,8 @@ using Emby.Server.Implementations.Dto;
|
|||||||
using Emby.Server.Implementations.HttpServer.Security;
|
using Emby.Server.Implementations.HttpServer.Security;
|
||||||
using Emby.Server.Implementations.IO;
|
using Emby.Server.Implementations.IO;
|
||||||
using Emby.Server.Implementations.Library;
|
using Emby.Server.Implementations.Library;
|
||||||
|
using Emby.Server.Implementations.Library.Search;
|
||||||
|
using Emby.Server.Implementations.Library.SimilarItems;
|
||||||
using Emby.Server.Implementations.Localization;
|
using Emby.Server.Implementations.Localization;
|
||||||
using Emby.Server.Implementations.Playlists;
|
using Emby.Server.Implementations.Playlists;
|
||||||
using Emby.Server.Implementations.Plugins;
|
using Emby.Server.Implementations.Plugins;
|
||||||
@@ -36,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;
|
||||||
@@ -90,9 +95,16 @@ using MediaBrowser.Model.Net;
|
|||||||
using MediaBrowser.Model.Serialization;
|
using MediaBrowser.Model.Serialization;
|
||||||
using MediaBrowser.Model.System;
|
using MediaBrowser.Model.System;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using MediaBrowser.Providers.Books;
|
||||||
|
using MediaBrowser.Providers.Books.ComicBookInfo;
|
||||||
|
using MediaBrowser.Providers.Books.ComicInfo;
|
||||||
using MediaBrowser.Providers.Lyric;
|
using MediaBrowser.Providers.Lyric;
|
||||||
using MediaBrowser.Providers.Manager;
|
using MediaBrowser.Providers.Manager;
|
||||||
|
using MediaBrowser.Providers.Plugins.ListenBrainz;
|
||||||
|
using MediaBrowser.Providers.Plugins.ListenBrainz.Api;
|
||||||
using MediaBrowser.Providers.Plugins.Tmdb;
|
using MediaBrowser.Providers.Plugins.Tmdb;
|
||||||
|
using MediaBrowser.Providers.Plugins.Tmdb.Movies;
|
||||||
|
using MediaBrowser.Providers.Plugins.Tmdb.TV;
|
||||||
using MediaBrowser.Providers.Subtitles;
|
using MediaBrowser.Providers.Subtitles;
|
||||||
using MediaBrowser.XbmcMetadata.Providers;
|
using MediaBrowser.XbmcMetadata.Providers;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -166,8 +178,6 @@ namespace Emby.Server.Implementations
|
|||||||
ConfigurationManager.Configuration,
|
ConfigurationManager.Configuration,
|
||||||
ApplicationPaths.PluginsPath,
|
ApplicationPaths.PluginsPath,
|
||||||
ApplicationVersion);
|
ApplicationVersion);
|
||||||
|
|
||||||
_disposableParts.Add(_pluginManager);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -409,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;
|
||||||
@@ -428,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)
|
||||||
{
|
{
|
||||||
@@ -485,6 +515,19 @@ namespace Emby.Server.Implementations
|
|||||||
serviceCollection.AddScoped<ISystemManager, SystemManager>();
|
serviceCollection.AddScoped<ISystemManager, SystemManager>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<TmdbClientManager>();
|
serviceCollection.AddSingleton<TmdbClientManager>();
|
||||||
|
serviceCollection.AddSingleton<TmdbMovieSimilarProvider>();
|
||||||
|
serviceCollection.AddSingleton<TmdbSeriesSimilarProvider>();
|
||||||
|
|
||||||
|
serviceCollection.AddSingleton<ListenBrainzLabsClient>();
|
||||||
|
serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>();
|
||||||
|
|
||||||
|
// register the generic local metadata provider for comic files
|
||||||
|
serviceCollection.AddSingleton<ComicProvider>();
|
||||||
|
|
||||||
|
// register the actual implementations of the local metadata provider for comic files
|
||||||
|
serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
|
||||||
|
serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
|
||||||
|
serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton(NetManager);
|
serviceCollection.AddSingleton(NetManager);
|
||||||
|
|
||||||
@@ -507,7 +550,13 @@ namespace Emby.Server.Implementations
|
|||||||
|
|
||||||
serviceCollection.AddSingleton<IUserDataManager, UserDataManager>();
|
serviceCollection.AddSingleton<IUserDataManager, UserDataManager>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<IItemRepository, BaseItemRepository>();
|
serviceCollection.AddSingleton<BaseItemRepository>();
|
||||||
|
serviceCollection.AddSingleton<IItemRepository>(sp => sp.GetRequiredService<BaseItemRepository>());
|
||||||
|
serviceCollection.AddSingleton<IItemQueryHelpers>(sp => sp.GetRequiredService<BaseItemRepository>());
|
||||||
|
serviceCollection.AddSingleton<IItemPersistenceService, ItemPersistenceService>();
|
||||||
|
serviceCollection.AddSingleton<INextUpService, NextUpService>();
|
||||||
|
serviceCollection.AddSingleton<IItemCountService, ItemCountService>();
|
||||||
|
serviceCollection.AddSingleton<ILinkedChildrenService, LinkedChildrenService>();
|
||||||
serviceCollection.AddSingleton<IPeopleRepository, PeopleRepository>();
|
serviceCollection.AddSingleton<IPeopleRepository, PeopleRepository>();
|
||||||
serviceCollection.AddSingleton<IChapterRepository, ChapterRepository>();
|
serviceCollection.AddSingleton<IChapterRepository, ChapterRepository>();
|
||||||
serviceCollection.AddSingleton<IMediaAttachmentRepository, MediaAttachmentRepository>();
|
serviceCollection.AddSingleton<IMediaAttachmentRepository, MediaAttachmentRepository>();
|
||||||
@@ -524,14 +573,20 @@ namespace Emby.Server.Implementations
|
|||||||
serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
|
serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
|
||||||
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
|
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
|
||||||
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
|
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
|
||||||
|
serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>));
|
||||||
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
|
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
|
||||||
serviceCollection.AddSingleton<NamingOptions>();
|
serviceCollection.AddSingleton<NamingOptions>();
|
||||||
|
serviceCollection.AddSingleton<VideoListResolver>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<IMusicManager, MusicManager>();
|
serviceCollection.AddSingleton<IMusicManager, MusicManager>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>();
|
serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>();
|
||||||
|
serviceCollection.AddSingleton<DotIgnoreIgnoreRule>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
|
serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>();
|
||||||
|
|
||||||
|
serviceCollection.AddSingleton<ISearchManager, SearchManager>();
|
||||||
|
serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
|
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
|
||||||
|
|
||||||
@@ -641,6 +696,7 @@ namespace Emby.Server.Implementations
|
|||||||
BaseItem.ConfigurationManager = ConfigurationManager;
|
BaseItem.ConfigurationManager = ConfigurationManager;
|
||||||
BaseItem.FileSystem = Resolve<IFileSystem>();
|
BaseItem.FileSystem = Resolve<IFileSystem>();
|
||||||
BaseItem.ItemRepository = Resolve<IItemRepository>();
|
BaseItem.ItemRepository = Resolve<IItemRepository>();
|
||||||
|
BaseItem.ItemCountService = Resolve<IItemCountService>();
|
||||||
BaseItem.LibraryManager = Resolve<ILibraryManager>();
|
BaseItem.LibraryManager = Resolve<ILibraryManager>();
|
||||||
BaseItem.LocalizationManager = Resolve<ILocalizationManager>();
|
BaseItem.LocalizationManager = Resolve<ILocalizationManager>();
|
||||||
BaseItem.Logger = Resolve<ILogger<BaseItem>>();
|
BaseItem.Logger = Resolve<ILogger<BaseItem>>();
|
||||||
@@ -687,6 +743,9 @@ namespace Emby.Server.Implementations
|
|||||||
GetExports<IExternalUrlProvider>());
|
GetExports<IExternalUrlProvider>());
|
||||||
|
|
||||||
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
|
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
|
||||||
|
|
||||||
|
Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>());
|
||||||
|
Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -928,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('/');
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1066,8 @@ namespace Emby.Server.Implementations
|
|||||||
}
|
}
|
||||||
|
|
||||||
_disposableParts.Clear();
|
_disposableParts.Clear();
|
||||||
|
|
||||||
|
_pluginManager?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
|||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Controller.Chapters;
|
using MediaBrowser.Controller.Chapters;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Audio;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.MediaEncoding;
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
@@ -128,7 +129,7 @@ public class ChapterManager : IChapterManager
|
|||||||
|
|
||||||
var averageChapterDuration = GetAverageDurationBetweenChapters(chapters);
|
var averageChapterDuration = GetAverageDurationBetweenChapters(chapters);
|
||||||
var threshold = TimeSpan.FromSeconds(1).Ticks;
|
var threshold = TimeSpan.FromSeconds(1).Ticks;
|
||||||
if (averageChapterDuration < threshold)
|
if (chapters.Count >= 2 && averageChapterDuration < threshold)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Skipping chapter image extraction for {Video} as the average chapter duration {AverageDuration} was lower than the minimum threshold {Threshold}", video.Name, averageChapterDuration, threshold);
|
_logger.LogInformation("Skipping chapter image extraction for {Video} as the average chapter duration {AverageDuration} was lower than the minimum threshold {Threshold}", video.Name, averageChapterDuration, threshold);
|
||||||
extractImages = false;
|
extractImages = false;
|
||||||
@@ -223,7 +224,7 @@ public class ChapterManager : IChapterManager
|
|||||||
|
|
||||||
if (saveChapters && changesMade)
|
if (saveChapters && changesMade)
|
||||||
{
|
{
|
||||||
_chapterRepository.SaveChapters(video.Id, chapters);
|
SaveChapters(video, chapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
DeleteDeadImages(currentImages, chapters);
|
DeleteDeadImages(currentImages, chapters);
|
||||||
@@ -232,9 +233,21 @@ public class ChapterManager : IChapterManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
|
public bool Supports(BaseItem item)
|
||||||
|
=> item is Video or Audio;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SaveChapters(BaseItem item, IReadOnlyList<ChapterInfo> chapters)
|
||||||
{
|
{
|
||||||
_chapterRepository.SaveChapters(video.Id, chapters);
|
if (!Supports(item))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Attempted to save chapters for unsupported item type {Type}: {Name} ({Id})", item.GetType().Name, item.Name, item.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any chapters that are outside of the runtime of the item
|
||||||
|
var validChapters = chapters.Where(c => c.StartPositionTicks < item.RunTimeTicks).ToList();
|
||||||
|
_chapterRepository.SaveChapters(item.Id, validChapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ using System.IO;
|
|||||||
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.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.Collections;
|
using MediaBrowser.Controller.Collections;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Entities.Movies;
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Persistence;
|
||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
using MediaBrowser.Model.Configuration;
|
using MediaBrowser.Model.Configuration;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
@@ -29,6 +32,7 @@ namespace Emby.Server.Implementations.Collections
|
|||||||
private readonly ILibraryMonitor _iLibraryMonitor;
|
private readonly ILibraryMonitor _iLibraryMonitor;
|
||||||
private readonly ILogger<CollectionManager> _logger;
|
private readonly ILogger<CollectionManager> _logger;
|
||||||
private readonly IProviderManager _providerManager;
|
private readonly IProviderManager _providerManager;
|
||||||
|
private readonly ILinkedChildrenService _linkedChildrenService;
|
||||||
private readonly ILocalizationManager _localizationManager;
|
private readonly ILocalizationManager _localizationManager;
|
||||||
private readonly IApplicationPaths _appPaths;
|
private readonly IApplicationPaths _appPaths;
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ namespace Emby.Server.Implementations.Collections
|
|||||||
/// <param name="iLibraryMonitor">The library monitor.</param>
|
/// <param name="iLibraryMonitor">The library monitor.</param>
|
||||||
/// <param name="loggerFactory">The logger factory.</param>
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
/// <param name="providerManager">The provider manager.</param>
|
/// <param name="providerManager">The provider manager.</param>
|
||||||
|
/// <param name="linkedChildrenService">The linked children service.</param>
|
||||||
public CollectionManager(
|
public CollectionManager(
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
IApplicationPaths appPaths,
|
IApplicationPaths appPaths,
|
||||||
@@ -49,13 +54,15 @@ namespace Emby.Server.Implementations.Collections
|
|||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
ILibraryMonitor iLibraryMonitor,
|
ILibraryMonitor iLibraryMonitor,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
IProviderManager providerManager)
|
IProviderManager providerManager,
|
||||||
|
ILinkedChildrenService linkedChildrenService)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
_iLibraryMonitor = iLibraryMonitor;
|
_iLibraryMonitor = iLibraryMonitor;
|
||||||
_logger = loggerFactory.CreateLogger<CollectionManager>();
|
_logger = loggerFactory.CreateLogger<CollectionManager>();
|
||||||
_providerManager = providerManager;
|
_providerManager = providerManager;
|
||||||
|
_linkedChildrenService = linkedChildrenService;
|
||||||
_localizationManager = localizationManager;
|
_localizationManager = localizationManager;
|
||||||
_appPaths = appPaths;
|
_appPaths = appPaths;
|
||||||
}
|
}
|
||||||
@@ -120,6 +127,22 @@ namespace Emby.Server.Implementations.Collections
|
|||||||
return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded);
|
return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
|
||||||
|
if (itemId.IsEmpty())
|
||||||
|
{
|
||||||
|
return Enumerable.Empty<BoxSet>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _linkedChildrenService
|
||||||
|
.GetManualLinkedParentIds(itemId, BaseItemKind.BoxSet)
|
||||||
|
.Select(parentId => _libraryManager.GetItemById<BoxSet>(parentId, user))
|
||||||
|
.OfType<BoxSet>();
|
||||||
|
}
|
||||||
|
|
||||||
private IEnumerable<BoxSet> GetCollections(User user)
|
private IEnumerable<BoxSet> GetCollections(User user)
|
||||||
{
|
{
|
||||||
var folder = GetCollectionsFolder(false).GetAwaiter().GetResult();
|
var folder = GetCollectionsFolder(false).GetAwaiter().GetResult();
|
||||||
@@ -272,7 +295,7 @@ namespace Emby.Server.Implementations.Collections
|
|||||||
{
|
{
|
||||||
var childItem = _libraryManager.GetItemById(guidId);
|
var childItem = _libraryManager.GetItemById(guidId);
|
||||||
|
|
||||||
var child = collection.LinkedChildren.FirstOrDefault(i => (i.ItemId.HasValue && i.ItemId.Value.Equals(guidId)) || (childItem is not null && string.Equals(childItem.Path, i.Path, StringComparison.OrdinalIgnoreCase)));
|
var child = collection.LinkedChildren.FirstOrDefault(i => i.ItemId.HasValue && i.ItemId.Value.Equals(guidId));
|
||||||
|
|
||||||
if (child is null)
|
if (child is null)
|
||||||
{
|
{
|
||||||
@@ -342,7 +365,7 @@ namespace Emby.Server.Implementations.Collections
|
|||||||
// this is kind of a performance hack because only Video has alternate versions that should be in a box set?
|
// this is kind of a performance hack because only Video has alternate versions that should be in a box set?
|
||||||
if (item is Video video)
|
if (item is Video video)
|
||||||
{
|
{
|
||||||
foreach (var childId in video.GetLocalAlternateVersionIds())
|
foreach (var childId in _libraryManager.GetLocalAlternateVersionIds(video))
|
||||||
{
|
{
|
||||||
if (!results.ContainsKey(childId))
|
if (!results.ContainsKey(childId))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,22 +39,24 @@ namespace Emby.Server.Implementations.Cryptography
|
|||||||
{
|
{
|
||||||
if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal))
|
if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
|
var iterations = GetIterationsParameter(hash);
|
||||||
return hash.Hash.SequenceEqual(
|
return hash.Hash.SequenceEqual(
|
||||||
Rfc2898DeriveBytes.Pbkdf2(
|
Rfc2898DeriveBytes.Pbkdf2(
|
||||||
password,
|
password,
|
||||||
hash.Salt,
|
hash.Salt,
|
||||||
int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
|
iterations,
|
||||||
HashAlgorithmName.SHA1,
|
HashAlgorithmName.SHA1,
|
||||||
32));
|
32));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal))
|
if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
|
var iterations = GetIterationsParameter(hash);
|
||||||
return hash.Hash.SequenceEqual(
|
return hash.Hash.SequenceEqual(
|
||||||
Rfc2898DeriveBytes.Pbkdf2(
|
Rfc2898DeriveBytes.Pbkdf2(
|
||||||
password,
|
password,
|
||||||
hash.Salt,
|
hash.Salt,
|
||||||
int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
|
iterations,
|
||||||
HashAlgorithmName.SHA512,
|
HashAlgorithmName.SHA512,
|
||||||
DefaultOutputLength));
|
DefaultOutputLength));
|
||||||
}
|
}
|
||||||
@@ -62,6 +64,27 @@ namespace Emby.Server.Implementations.Cryptography
|
|||||||
throw new NotSupportedException($"Can't verify hash with id: {hash.Id}");
|
throw new NotSupportedException($"Can't verify hash with id: {hash.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts and validates the iterations parameter from a password hash.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hash">The password hash containing parameters.</param>
|
||||||
|
/// <returns>The number of iterations.</returns>
|
||||||
|
/// <exception cref="FormatException">Thrown when iterations parameter is missing or invalid.</exception>
|
||||||
|
private static int GetIterationsParameter(PasswordHash hash)
|
||||||
|
{
|
||||||
|
if (!hash.Parameters.TryGetValue("iterations", out var iterationsStr))
|
||||||
|
{
|
||||||
|
throw new FormatException($"Password hash with id '{hash.Id}' is missing required 'iterations' parameter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(iterationsStr, CultureInfo.InvariantCulture, out var iterations))
|
||||||
|
{
|
||||||
|
throw new FormatException($"Password hash with id '{hash.Id}' has invalid 'iterations' parameter: '{iterationsStr}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return iterations;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public byte[] GenerateSalt()
|
public byte[] GenerateSalt()
|
||||||
=> GenerateSalt(DefaultSaltLength);
|
=> GenerateSalt(DefaultSaltLength);
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ using System.IO;
|
|||||||
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.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Playlists;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -35,7 +37,11 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
|
|||||||
|
|
||||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await CleanDeadItems(cancellationToken, progress).ConfigureAwait(false);
|
var deadItemsProgress = new Progress<double>(val => progress.Report(val * 0.8));
|
||||||
|
await CleanDeadItems(cancellationToken, deadItemsProgress).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var playlistProgress = new Progress<double>(val => progress.Report(80 + (val * 0.2)));
|
||||||
|
await CleanOrphanedFilePlaylistsAsync(cancellationToken, playlistProgress).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CleanDeadItems(CancellationToken cancellationToken, IProgress<double> progress)
|
private async Task CleanDeadItems(CancellationToken cancellationToken, IProgress<double> progress)
|
||||||
@@ -116,4 +122,32 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
|
|||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task CleanOrphanedFilePlaylistsAsync(CancellationToken cancellationToken, IProgress<double> progress)
|
||||||
|
{
|
||||||
|
var playlists = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [BaseItemKind.Playlist],
|
||||||
|
Recursive = true
|
||||||
|
}).OfType<Playlist>().ToList();
|
||||||
|
|
||||||
|
var numComplete = 0;
|
||||||
|
var numItems = Math.Max(playlists.Count, 1);
|
||||||
|
|
||||||
|
foreach (var playlist in playlists)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (playlist.IsFile && !File.Exists(playlist.Path))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Removing file-based playlist {Name} because source file {Path} no longer exists", playlist.Name, playlist.Path);
|
||||||
|
_libraryManager.DeleteItem(playlist, new DeleteOptions { DeleteFileLocation = false });
|
||||||
|
}
|
||||||
|
|
||||||
|
numComplete++;
|
||||||
|
progress.Report((double)numComplete / numItems * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.Report(100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -153,17 +155,107 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
|
private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null)
|
public IReadOnlyList<BaseItemDto> GetBaseItemDtos(
|
||||||
|
IReadOnlyList<BaseItem> items,
|
||||||
|
DtoOptions options,
|
||||||
|
User? user = null,
|
||||||
|
BaseItem? owner = null,
|
||||||
|
bool skipVisibilityCheck = false)
|
||||||
{
|
{
|
||||||
var accessibleItems = user is null ? items : items.Where(x => x.IsVisible(user)).ToList();
|
var accessibleItems = skipVisibilityCheck || user is null ? items : items.Where(x => x.IsVisible(user)).ToList();
|
||||||
var returnItems = new BaseItemDto[accessibleItems.Count];
|
var returnItems = new BaseItemDto[accessibleItems.Count];
|
||||||
List<(BaseItem, BaseItemDto)>? programTuples = null;
|
List<(BaseItem, BaseItemDto)>? programTuples = null;
|
||||||
List<(BaseItemDto, LiveTvChannel)>? channelTuples = null;
|
List<(BaseItemDto, LiveTvChannel)>? channelTuples = null;
|
||||||
|
|
||||||
|
// Batch-fetch user data for all items
|
||||||
|
Dictionary<Guid, UserItemData>? userDataBatch = null;
|
||||||
|
IReadOnlyDictionary<Guid, VersionResumeData>? resumeDataBatch = null;
|
||||||
|
if (user is not null && options.EnableUserData)
|
||||||
|
{
|
||||||
|
userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user);
|
||||||
|
|
||||||
|
// For items with alternate versions, the most recently played version drives resume.
|
||||||
|
resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-compute collection folders once to avoid N+1 queries in CanDelete
|
||||||
|
List<Folder>? allCollectionFolders = null;
|
||||||
|
if (user is not null && options.ContainsField(ItemFields.CanDelete))
|
||||||
|
{
|
||||||
|
allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-fetch child counts for all folders to avoid N+1 queries
|
||||||
|
Dictionary<Guid, int>? childCountBatch = null;
|
||||||
|
if (options.ContainsField(ItemFields.ChildCount))
|
||||||
|
{
|
||||||
|
var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
|
||||||
|
if (folderIds.Count > 0)
|
||||||
|
{
|
||||||
|
childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-fetch played/total counts for all folders to avoid N+1 queries
|
||||||
|
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null;
|
||||||
|
if (user is not null && options.EnableUserData)
|
||||||
|
{
|
||||||
|
var folderIds = accessibleItems.OfType<Folder>()
|
||||||
|
.Where(f => f.SupportsUserDataFromChildren && (f.SupportsPlayedStatus || options.ContainsField(ItemFields.RecursiveItemCount)))
|
||||||
|
.Select(f => f.Id).ToList();
|
||||||
|
if (folderIds.Count > 0)
|
||||||
|
{
|
||||||
|
playedCountBatch = _libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-fetch MusicArtist lookups across all items to avoid N+1 queries.
|
||||||
|
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null;
|
||||||
|
var artistNames = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var item in accessibleItems)
|
||||||
|
{
|
||||||
|
if (item is IHasArtist hasArtist)
|
||||||
|
{
|
||||||
|
foreach (var name in hasArtist.Artists)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
artistNames.Add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||||
|
{
|
||||||
|
foreach (var name in hasAlbumArtist.AlbumArtists)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
artistNames.Add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artistNames.Count > 0)
|
||||||
|
{
|
||||||
|
artistsBatch = _libraryManager.GetArtists(artistNames.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
for (int index = 0; index < accessibleItems.Count; index++)
|
for (int index = 0; index < accessibleItems.Count; index++)
|
||||||
{
|
{
|
||||||
var item = accessibleItems[index];
|
var item = accessibleItems[index];
|
||||||
var dto = GetBaseItemDtoInternal(item, options, user, owner);
|
var dto = GetBaseItemDtoInternal(
|
||||||
|
item,
|
||||||
|
options,
|
||||||
|
user,
|
||||||
|
owner,
|
||||||
|
userDataBatch?.GetValueOrDefault(item.Id),
|
||||||
|
allCollectionFolders,
|
||||||
|
childCountBatch,
|
||||||
|
playedCountBatch,
|
||||||
|
artistsBatch,
|
||||||
|
resumeDataBatch?.GetValueOrDefault(item.Id));
|
||||||
|
|
||||||
if (item is LiveTvChannel tvChannel)
|
if (item is LiveTvChannel tvChannel)
|
||||||
{
|
{
|
||||||
@@ -197,7 +289,7 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
public BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null)
|
public BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null)
|
||||||
{
|
{
|
||||||
var dto = GetBaseItemDtoInternal(item, options, user, owner);
|
var dto = GetBaseItemDtoInternal(item, options, user, owner, null);
|
||||||
if (item is LiveTvChannel tvChannel)
|
if (item is LiveTvChannel tvChannel)
|
||||||
{
|
{
|
||||||
LivetvManager.AddChannelInfo(new[] { (dto, tvChannel) }, options, user);
|
LivetvManager.AddChannelInfo(new[] { (dto, tvChannel) }, options, user);
|
||||||
@@ -215,7 +307,17 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null)
|
private BaseItemDto GetBaseItemDtoInternal(
|
||||||
|
BaseItem item,
|
||||||
|
DtoOptions options,
|
||||||
|
User? user = null,
|
||||||
|
BaseItem? owner = null,
|
||||||
|
UserItemData? userData = null,
|
||||||
|
List<Folder>? allCollectionFolders = null,
|
||||||
|
Dictionary<Guid, int>? childCountBatch = null,
|
||||||
|
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||||
|
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null,
|
||||||
|
VersionResumeData? resumeData = null)
|
||||||
{
|
{
|
||||||
var dto = new BaseItemDto
|
var dto = new BaseItemDto
|
||||||
{
|
{
|
||||||
@@ -252,7 +354,15 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
if (user is not null)
|
if (user is not null)
|
||||||
{
|
{
|
||||||
AttachUserSpecificInfo(dto, item, user, options);
|
AttachUserSpecificInfo(
|
||||||
|
dto,
|
||||||
|
item,
|
||||||
|
user,
|
||||||
|
options,
|
||||||
|
userData,
|
||||||
|
childCountBatch,
|
||||||
|
playedCountBatch,
|
||||||
|
resumeData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item is IHasMediaSources
|
if (item is IHasMediaSources
|
||||||
@@ -268,13 +378,15 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
AttachStudios(dto, item);
|
AttachStudios(dto, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
AttachBasicFields(dto, item, owner, options);
|
AttachBasicFields(dto, item, owner, options, artistsBatch, user);
|
||||||
|
|
||||||
if (options.ContainsField(ItemFields.CanDelete))
|
if (options.ContainsField(ItemFields.CanDelete))
|
||||||
{
|
{
|
||||||
dto.CanDelete = user is null
|
dto.CanDelete = user is null
|
||||||
? item.CanDelete()
|
? item.CanDelete()
|
||||||
: item.CanDelete(user);
|
: allCollectionFolders is not null
|
||||||
|
? item.CanDelete(user, allCollectionFolders)
|
||||||
|
: item.CanDelete(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.ContainsField(ItemFields.CanDownload))
|
if (options.ContainsField(ItemFields.CanDownload))
|
||||||
@@ -378,37 +490,7 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var query = new InternalItemsQuery(user)
|
var counts = _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user);
|
||||||
{
|
|
||||||
Recursive = true,
|
|
||||||
DtoOptions = new DtoOptions(false) { EnableImages = false },
|
|
||||||
IncludeItemTypes = relatedItemKinds
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (dto.Type)
|
|
||||||
{
|
|
||||||
case BaseItemKind.Genre:
|
|
||||||
case BaseItemKind.MusicGenre:
|
|
||||||
query.GenreIds = [dto.Id];
|
|
||||||
break;
|
|
||||||
case BaseItemKind.MusicArtist:
|
|
||||||
query.ArtistIds = [dto.Id];
|
|
||||||
break;
|
|
||||||
case BaseItemKind.Person:
|
|
||||||
query.PersonIds = [dto.Id];
|
|
||||||
break;
|
|
||||||
case BaseItemKind.Studio:
|
|
||||||
query.StudioIds = [dto.Id];
|
|
||||||
break;
|
|
||||||
case BaseItemKind.Year
|
|
||||||
when int.TryParse(dto.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year):
|
|
||||||
query.Years = [year];
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var counts = _libraryManager.GetItemCounts(query);
|
|
||||||
|
|
||||||
dto.AlbumCount = counts.AlbumCount;
|
dto.AlbumCount = counts.AlbumCount;
|
||||||
dto.ArtistCount = counts.ArtistCount;
|
dto.ArtistCount = counts.ArtistCount;
|
||||||
@@ -458,7 +540,15 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attaches the user specific info.
|
/// Attaches the user specific info.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void AttachUserSpecificInfo(BaseItemDto dto, BaseItem item, User user, DtoOptions options)
|
private void AttachUserSpecificInfo(
|
||||||
|
BaseItemDto dto,
|
||||||
|
BaseItem item,
|
||||||
|
User user,
|
||||||
|
DtoOptions options,
|
||||||
|
UserItemData? userData = null,
|
||||||
|
Dictionary<Guid, int>? childCountBatch = null,
|
||||||
|
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||||
|
VersionResumeData? resumeData = null)
|
||||||
{
|
{
|
||||||
if (item.IsFolder)
|
if (item.IsFolder)
|
||||||
{
|
{
|
||||||
@@ -466,7 +556,19 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
if (options.EnableUserData)
|
if (options.EnableUserData)
|
||||||
{
|
{
|
||||||
dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options);
|
if (userData is not null)
|
||||||
|
{
|
||||||
|
// Use pre-fetched user data
|
||||||
|
dto.UserData = GetUserItemDataDto(userData, item.Id);
|
||||||
|
(int Played, int Total)? precomputed = playedCountBatch is not null
|
||||||
|
&& playedCountBatch.TryGetValue(item.Id, out var counts) ? counts : null;
|
||||||
|
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options, precomputed);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fall back to individual fetch
|
||||||
|
dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dto.ChildCount.HasValue && item.SourceType == SourceType.Library)
|
if (!dto.ChildCount.HasValue && item.SourceType == SourceType.Library)
|
||||||
@@ -485,7 +587,7 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
if (options.ContainsField(ItemFields.ChildCount))
|
if (options.ContainsField(ItemFields.ChildCount))
|
||||||
{
|
{
|
||||||
dto.ChildCount ??= GetChildCount(folder, user);
|
dto.ChildCount ??= GetChildCount(folder, user, childCountBatch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,7 +605,20 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
{
|
{
|
||||||
if (options.EnableUserData)
|
if (options.EnableUserData)
|
||||||
{
|
{
|
||||||
dto.UserData = _userDataRepository.GetUserDataDto(item, user);
|
if (userData is not null)
|
||||||
|
{
|
||||||
|
// Use pre-fetched user data
|
||||||
|
dto.UserData = GetUserItemDataDto(userData, item.Id);
|
||||||
|
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
|
||||||
|
|
||||||
|
// For items with alternate versions, the most recently played version drives resume.
|
||||||
|
resumeData?.ApplyTo(dto.UserData);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fall back to individual fetch
|
||||||
|
dto.UserData = _userDataRepository.GetUserDataDto(item, user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +628,25 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int GetChildCount(Folder folder, User user)
|
private static UserItemDataDto GetUserItemDataDto(UserItemData data, Guid itemId)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(data);
|
||||||
|
|
||||||
|
return new UserItemDataDto
|
||||||
|
{
|
||||||
|
IsFavorite = data.IsFavorite,
|
||||||
|
Likes = data.Likes,
|
||||||
|
PlaybackPositionTicks = data.PlaybackPositionTicks,
|
||||||
|
PlayCount = data.PlayCount,
|
||||||
|
Rating = data.Rating,
|
||||||
|
Played = data.Played,
|
||||||
|
LastPlayedDate = data.LastPlayedDate,
|
||||||
|
ItemId = itemId,
|
||||||
|
Key = data.Key
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch)
|
||||||
{
|
{
|
||||||
// Right now this is too slow to calculate for top level folders on a per-user basis
|
// Right now this is too slow to calculate for top level folders on a per-user basis
|
||||||
// Just return something so that apps that are expecting a value won't think the folders are empty
|
// Just return something so that apps that are expecting a value won't think the folders are empty
|
||||||
@@ -522,6 +655,13 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
return Random.Shared.Next(1, 10);
|
return Random.Shared.Next(1, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use pre-fetched batch data if available
|
||||||
|
if (childCountBatch is not null && childCountBatch.TryGetValue(folder.Id, out var count))
|
||||||
|
{
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to individual query for special cases (Series, Season, etc.)
|
||||||
return folder.GetChildCount(user);
|
return folder.GetChildCount(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,7 +955,9 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
/// <param name="item">The item.</param>
|
/// <param name="item">The item.</param>
|
||||||
/// <param name="owner">The owner.</param>
|
/// <param name="owner">The owner.</param>
|
||||||
/// <param name="options">The options.</param>
|
/// <param name="options">The options.</param>
|
||||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options)
|
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
|
||||||
|
/// <param name="user">The user, for per-user values such as the accessible media source count.</param>
|
||||||
|
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null)
|
||||||
{
|
{
|
||||||
if (options.ContainsField(ItemFields.DateCreated))
|
if (options.ContainsField(ItemFields.DateCreated))
|
||||||
{
|
{
|
||||||
@@ -939,12 +1081,14 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
dto.OriginalTitle = item.OriginalTitle;
|
dto.OriginalTitle = item.OriginalTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dto.OriginalLanguage = item.OriginalLanguage;
|
||||||
|
|
||||||
if (options.ContainsField(ItemFields.ParentId))
|
if (options.ContainsField(ItemFields.ParentId))
|
||||||
{
|
{
|
||||||
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))
|
||||||
{
|
{
|
||||||
@@ -1019,6 +1163,15 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
{
|
{
|
||||||
dto.AlbumId = albumParent.Id;
|
dto.AlbumId = albumParent.Id;
|
||||||
dto.AlbumPrimaryImageTag = GetTagAndFillBlurhash(dto, albumParent, ImageType.Primary);
|
dto.AlbumPrimaryImageTag = GetTagAndFillBlurhash(dto, albumParent, ImageType.Primary);
|
||||||
|
if (albumParent.LUFS.HasValue)
|
||||||
|
{
|
||||||
|
// -18 LUFS reference, same as ReplayGain 2.0, compatible with ReplayGain 1.0
|
||||||
|
dto.AlbumNormalizationGain = -18f - albumParent.LUFS;
|
||||||
|
}
|
||||||
|
else if (albumParent.NormalizationGain.HasValue)
|
||||||
|
{
|
||||||
|
dto.AlbumNormalizationGain = albumParent.NormalizationGain;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (options.ContainsField(ItemFields.MediaSourceCount))
|
// if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||||
@@ -1051,16 +1204,17 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
||||||
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
||||||
dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))])
|
var artistsLookup = artistsBatch
|
||||||
.Where(e => e.Value.Length > 0)
|
?? _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||||
.Select(i =>
|
|
||||||
{
|
dto.ArtistItems = hasArtist.Artists
|
||||||
return new NameGuidPair
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
{
|
.Distinct()
|
||||||
Name = i.Key,
|
.Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
|
||||||
Id = i.Value.First().Id
|
? new NameGuidPair { Name = name, Id = artists[0].Id }
|
||||||
};
|
: null)
|
||||||
}).Where(i => i is not null).ToArray();
|
.Where(item => item is not null)
|
||||||
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item is IHasAlbumArtist hasAlbumArtist)
|
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||||
@@ -1085,31 +1239,17 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
// })
|
// })
|
||||||
// .ToList();
|
// .ToList();
|
||||||
|
|
||||||
|
var albumArtistsLookup = artistsBatch
|
||||||
|
?? _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||||
|
|
||||||
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
||||||
// .Except(foundArtists, new DistinctNameComparer())
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
.Select(i =>
|
.Distinct()
|
||||||
{
|
.Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
|
||||||
// This should not be necessary but we're seeing some cases of it
|
? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
|
||||||
if (string.IsNullOrEmpty(i))
|
: null)
|
||||||
{
|
.Where(item => item is not null)
|
||||||
return null;
|
.ToArray();
|
||||||
}
|
|
||||||
|
|
||||||
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
|
|
||||||
{
|
|
||||||
EnableImages = false
|
|
||||||
});
|
|
||||||
if (artist is not null)
|
|
||||||
{
|
|
||||||
return new NameGuidPair
|
|
||||||
{
|
|
||||||
Name = artist.Name,
|
|
||||||
Id = artist.Id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}).Where(i => i is not null).ToArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add video info
|
// Add video info
|
||||||
@@ -1131,18 +1271,18 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
if (options.ContainsField(ItemFields.MediaSourceCount))
|
if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||||
{
|
{
|
||||||
var mediaSourceCount = video.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)
|
if (mediaSourceCount != 1)
|
||||||
{
|
{
|
||||||
dto.MediaSourceCount = mediaSourceCount;
|
dto.MediaSourceCount = mediaSourceCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.ContainsField(ItemFields.Chapters))
|
|
||||||
{
|
|
||||||
dto.Chapters = _chapterManager.GetChapters(item.Id).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.ContainsField(ItemFields.Trickplay))
|
if (options.ContainsField(ItemFields.Trickplay))
|
||||||
{
|
{
|
||||||
var trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
|
var trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
|
||||||
@@ -1156,6 +1296,11 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
dto.ExtraType = video.ExtraType;
|
dto.ExtraType = video.ExtraType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.ContainsField(ItemFields.Chapters))
|
||||||
|
{
|
||||||
|
dto.Chapters = _chapterManager.GetChapters(item.Id).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
if (options.ContainsField(ItemFields.MediaStreams))
|
if (options.ContainsField(ItemFields.MediaStreams))
|
||||||
{
|
{
|
||||||
// Add VideoInfo
|
// Add VideoInfo
|
||||||
@@ -1240,6 +1385,25 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.GetImageLimit(ImageType.Primary) > 0)
|
||||||
|
{
|
||||||
|
var episodeSeason = episode.Season;
|
||||||
|
var seasonPrimaryTag = episodeSeason is not null
|
||||||
|
? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (seasonPrimaryTag is not null)
|
||||||
|
{
|
||||||
|
dto.ParentPrimaryImageItemId = episodeSeason!.Id;
|
||||||
|
dto.ParentPrimaryImageTag = seasonPrimaryTag;
|
||||||
|
}
|
||||||
|
else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
|
||||||
|
{
|
||||||
|
dto.ParentPrimaryImageItemId = episodeSeries.Id;
|
||||||
|
dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (options.ContainsField(ItemFields.SeriesStudio))
|
if (options.ContainsField(ItemFields.SeriesStudio))
|
||||||
{
|
{
|
||||||
episodeSeries ??= episode.Series;
|
episodeSeries ??= episode.Series;
|
||||||
@@ -1355,11 +1519,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;
|
||||||
@@ -1376,8 +1540,36 @@ 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
|
||||||
|
&& options.GetImageLimit(ImageType.Primary) > 0
|
||||||
|
&& !playlistsView.DisplayParentId.IsEmpty())
|
||||||
|
{
|
||||||
|
var displayParent = _libraryManager.GetItemById(playlistsView.DisplayParentId);
|
||||||
|
var displayParentPrimaryImage = displayParent?.GetImageInfo(ImageType.Primary, 0);
|
||||||
|
|
||||||
|
if (displayParentPrimaryImage is not null)
|
||||||
|
{
|
||||||
|
dto.ImageTags?.Remove(ImageType.Primary);
|
||||||
|
dto.ParentPrimaryImageItemId = displayParent!.Id;
|
||||||
|
dto.ParentPrimaryImageTag = GetTagAndFillBlurhash(dto, displayParent, displayParentPrimaryImage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!item.SupportsInheritedParentImages)
|
if (!item.SupportsInheritedParentImages)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -1406,7 +1598,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;
|
||||||
@@ -1465,7 +1657,7 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
parent = GetImageDisplayParent(parent, item);
|
parent = GetImageDisplayParent(parent, item, artistsBatch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
<PackageReference Include="prometheus-net.DotNetRuntime" />
|
<PackageReference Include="prometheus-net.DotNetRuntime" />
|
||||||
@@ -39,7 +38,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
|
using System.Globalization;
|
||||||
using System.IO.Pipelines;
|
using System.IO.Pipelines;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
@@ -69,6 +70,11 @@ namespace Emby.Server.Implementations.HttpServer
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IPAddress? RemoteEndPoint { get; }
|
public IPAddress? RemoteEndPoint { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or initializes the UI culture captured from the upgrade request.
|
||||||
|
/// </summary>
|
||||||
|
public CultureInfo? RequestUICulture { get; init; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Func<WebSocketMessageInfo, Task>? OnReceive { get; set; }
|
public Func<WebSocketMessageInfo, Task>? OnReceive { get; set; }
|
||||||
|
|
||||||
@@ -81,6 +87,17 @@ namespace Emby.Server.Implementations.HttpServer
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public WebSocketState State => _socket.State;
|
public WebSocketState State => _socket.State;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ApplyRequestCulture()
|
||||||
|
{
|
||||||
|
if (RequestUICulture is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CultureInfo.CurrentUICulture = RequestUICulture;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken)
|
public async Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -110,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -47,14 +48,18 @@ namespace Emby.Server.Implementations.HttpServer
|
|||||||
_logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
|
_logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
|
||||||
|
|
||||||
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
|
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
var connection = new WebSocketConnection(
|
var connection = new WebSocketConnection(
|
||||||
_loggerFactory.CreateLogger<WebSocketConnection>(),
|
_loggerFactory.CreateLogger<WebSocketConnection>(),
|
||||||
webSocket,
|
webSocket,
|
||||||
authorizationInfo,
|
authorizationInfo,
|
||||||
context.GetNormalizedRemoteIP())
|
context.GetNormalizedRemoteIP())
|
||||||
{
|
{
|
||||||
OnReceive = ProcessWebSocketMessageReceived
|
RequestUICulture = CultureInfo.CurrentUICulture
|
||||||
|
};
|
||||||
|
connection.OnReceive = result =>
|
||||||
|
{
|
||||||
|
connection.ApplyRequestCulture();
|
||||||
|
return ProcessWebSocketMessageReceived(result);
|
||||||
};
|
};
|
||||||
await using (connection.ConfigureAwait(false))
|
await using (connection.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,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 DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The file system watchers.
|
/// The file system watchers.
|
||||||
@@ -47,19 +48,23 @@ namespace Emby.Server.Implementations.IO
|
|||||||
/// <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="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
|
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
|
||||||
|
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||||
public LibraryMonitor(
|
public LibraryMonitor(
|
||||||
ILogger<LibraryMonitor> logger,
|
ILogger<LibraryMonitor> logger,
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
IServerConfigurationManager configurationManager,
|
IServerConfigurationManager configurationManager,
|
||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
IHostApplicationLifetime appLifetime)
|
IHostApplicationLifetime appLifetime,
|
||||||
|
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configurationManager = configurationManager;
|
_configurationManager = configurationManager;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
|
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||||
|
|
||||||
appLifetime.ApplicationStarted.Register(Start);
|
appLifetime.ApplicationStarted.Register(Start);
|
||||||
|
appLifetime.ApplicationStopping.Register(Stop);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -352,6 +357,12 @@ namespace Emby.Server.Implementations.IO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fileInfo = _fileSystem.GetFileSystemInfo(path);
|
||||||
|
if (_dotIgnoreIgnoreRule.ShouldIgnore(fileInfo, null))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
||||||
foreach (var i in _tempIgnoredPaths.Keys)
|
foreach (var i in _tempIgnoredPaths.Keys)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Linq;
|
|||||||
using System.Security;
|
using System.Security;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -152,6 +153,10 @@ namespace Emby.Server.Implementations.IO
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MoveDirectory(string source, string destination)
|
public void MoveDirectory(string source, string destination)
|
||||||
{
|
{
|
||||||
|
// Make sure parent directory of target exists
|
||||||
|
var parent = Directory.GetParent(destination);
|
||||||
|
parent?.Create();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.Move(source, destination);
|
Directory.Move(source, destination);
|
||||||
@@ -248,47 +253,40 @@ namespace Emby.Server.Implementations.IO
|
|||||||
{
|
{
|
||||||
result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
|
result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
|
||||||
|
|
||||||
// if (!result.IsDirectory)
|
|
||||||
// {
|
|
||||||
// result.IsHidden = (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden;
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (info is FileInfo fileInfo)
|
if (info is FileInfo fileInfo)
|
||||||
{
|
{
|
||||||
result.Length = fileInfo.Length;
|
result.CreationTimeUtc = GetCreationTimeUtc(info);
|
||||||
|
result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
|
||||||
// Issue #2354 get the size of files behind symbolic links. Also Enum.HasFlag is bad as it boxes!
|
if (fileInfo.LinkTarget is not null)
|
||||||
if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
var targetFileInfo = FileSystemHelper.ResolveLinkTarget(fileInfo, returnFinalTarget: true);
|
||||||
|
if (targetFileInfo is not null)
|
||||||
{
|
{
|
||||||
result.Length = RandomAccess.GetLength(fileHandle);
|
result.Exists = targetFileInfo.Exists;
|
||||||
|
if (result.Exists)
|
||||||
|
{
|
||||||
|
result.Length = targetFileInfo.Length;
|
||||||
|
result.CreationTimeUtc = GetCreationTimeUtc(targetFileInfo);
|
||||||
|
result.LastWriteTimeUtc = GetLastWriteTimeUtc(targetFileInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Exists = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (FileNotFoundException ex)
|
|
||||||
{
|
|
||||||
// Dangling symlinks cannot be detected before opening the file unfortunately...
|
|
||||||
_logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName);
|
|
||||||
result.Exists = false;
|
|
||||||
}
|
}
|
||||||
catch (UnauthorizedAccessException ex)
|
catch (UnauthorizedAccessException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName);
|
_logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName);
|
||||||
}
|
}
|
||||||
catch (IOException ex)
|
}
|
||||||
{
|
else
|
||||||
// IOException generally means the file is not accessible due to filesystem issues
|
{
|
||||||
// Catch this exception and mark the file as not exist to ignore it
|
result.Length = fileInfo.Length;
|
||||||
_logger.LogError(ex, "Reading the file at {Path} failed due to an IO Exception. Marking the file as not existing", fileInfo.FullName);
|
|
||||||
result.Exists = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.CreationTimeUtc = GetCreationTimeUtc(info);
|
|
||||||
result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -499,8 +497,17 @@ namespace Emby.Server.Implementations.IO
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public virtual bool AreEqual(string path1, string path2)
|
public virtual bool AreEqual(string path1, string path2)
|
||||||
{
|
{
|
||||||
return Path.TrimEndingDirectorySeparator(path1).Equals(
|
if (string.IsNullOrWhiteSpace(path1) || string.IsNullOrWhiteSpace(path2))
|
||||||
Path.TrimEndingDirectorySeparator(path2),
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized1 = Path.TrimEndingDirectorySeparator(path1);
|
||||||
|
var normalized2 = Path.TrimEndingDirectorySeparator(path2);
|
||||||
|
|
||||||
|
return string.Equals(
|
||||||
|
normalized1,
|
||||||
|
normalized2,
|
||||||
_isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
_isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,6 +586,12 @@ namespace Emby.Server.Implementations.IO
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, string searchPattern, IReadOnlyList<string>? extensions, bool enableCaseSensitiveExtensions, bool recursive = false)
|
public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, string searchPattern, IReadOnlyList<string>? extensions, bool enableCaseSensitiveExtensions, bool recursive = false)
|
||||||
{
|
{
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Directory does not exist: {Path}", path);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
var enumerationOptions = GetEnumerationOptions(recursive);
|
var enumerationOptions = GetEnumerationOptions(recursive);
|
||||||
|
|
||||||
// On linux and macOS the search pattern is case-sensitive
|
// On linux and macOS the search pattern is case-sensitive
|
||||||
@@ -678,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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,22 +267,24 @@ namespace Emby.Server.Implementations.Images
|
|||||||
{
|
{
|
||||||
var image = item.GetImageInfo(type, 0);
|
var image = item.GetImageInfo(type, 0);
|
||||||
|
|
||||||
if (image is not null)
|
if (image is null)
|
||||||
{
|
{
|
||||||
if (!image.IsLocalFile)
|
return GetItemsWithImages(item).Count is not 0;
|
||||||
{
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
|
if (!image.IsLocalFile)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!HasChangedByDate(item, image))
|
if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!HasChangedByDate(item, image))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -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.MusicAlbum };
|
|
||||||
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -98,5 +70,11 @@ namespace Emby.Server.Implementations.Images
|
|||||||
|
|
||||||
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image)
|
||||||
|
{
|
||||||
|
var age = DateTime.UtcNow - image.DateModified;
|
||||||
|
return age.TotalDays > 7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using BitFaster.Caching.Lru;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Resolvers;
|
using MediaBrowser.Controller.Resolvers;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
|
|
||||||
@@ -11,27 +15,37 @@ namespace Emby.Server.Implementations.Library;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||||
{
|
{
|
||||||
private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
|
private static readonly bool IsWindows = OperatingSystem.IsWindows();
|
||||||
|
|
||||||
|
private readonly FastConcurrentLru<string, IgnoreFileCacheEntry> _directoryCache;
|
||||||
|
private readonly FastConcurrentLru<string, ParsedIgnoreCacheEntry> _rulesCache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DotIgnoreIgnoreRule"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public DotIgnoreIgnoreRule()
|
||||||
{
|
{
|
||||||
var ignoreFile = new FileInfo(Path.Join(directory.FullName, ".ignore"));
|
var cacheSize = Math.Max(100, Environment.ProcessorCount * 100);
|
||||||
if (ignoreFile.Exists)
|
_directoryCache = new FastConcurrentLru<string, IgnoreFileCacheEntry>(
|
||||||
{
|
Environment.ProcessorCount,
|
||||||
return ignoreFile;
|
cacheSize,
|
||||||
}
|
StringComparer.Ordinal);
|
||||||
|
_rulesCache = new FastConcurrentLru<string, ParsedIgnoreCacheEntry>(
|
||||||
var parentDir = directory.Parent;
|
Environment.ProcessorCount,
|
||||||
if (parentDir is null)
|
Math.Max(32, cacheSize / 4),
|
||||||
{
|
StringComparer.Ordinal);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindIgnoreFile(parentDir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent)
|
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnoredInternal(fileInfo, parent);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the directory lookup cache. The parsed rules cache is not cleared
|
||||||
|
/// as it validates file modification time on each access.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearDirectoryCache()
|
||||||
{
|
{
|
||||||
return IsIgnored(fileInfo, parent);
|
_directoryCache.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -40,62 +54,286 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
|||||||
/// <param name="fileInfo">The file information.</param>
|
/// <param name="fileInfo">The file information.</param>
|
||||||
/// <param name="parent">The parent BaseItem.</param>
|
/// <param name="parent">The parent BaseItem.</param>
|
||||||
/// <returns>True if the file should be ignored.</returns>
|
/// <returns>True if the file should be ignored.</returns>
|
||||||
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
|
public bool IsIgnoredInternal(FileSystemMetadata fileInfo, BaseItem? parent)
|
||||||
{
|
{
|
||||||
if (fileInfo.IsDirectory)
|
var searchDirectory = fileInfo.IsDirectory
|
||||||
{
|
? fileInfo.FullName
|
||||||
var dirIgnoreFile = FindIgnoreFile(new DirectoryInfo(fileInfo.FullName));
|
: Path.GetDirectoryName(fileInfo.FullName);
|
||||||
if (dirIgnoreFile is null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fast path in case the ignore files isn't a symlink and is empty
|
if (string.IsNullOrEmpty(searchDirectory))
|
||||||
if ((dirIgnoreFile.Attributes & FileAttributes.ReparsePoint) == 0
|
|
||||||
&& dirIgnoreFile.Length == 0)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignore the directory only if the .ignore file is empty
|
|
||||||
// evaluate individual files otherwise
|
|
||||||
return string.IsNullOrWhiteSpace(GetFileContent(dirIgnoreFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
var parentDirPath = Path.GetDirectoryName(fileInfo.FullName);
|
|
||||||
if (string.IsNullOrEmpty(parentDirPath))
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var folder = new DirectoryInfo(parentDirPath);
|
var ignoreFile = FindIgnoreFileCached(searchDirectory);
|
||||||
var ignoreFile = FindIgnoreFile(folder);
|
|
||||||
if (ignoreFile is null)
|
if (ignoreFile is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
string ignoreFileString = GetFileContent(ignoreFile);
|
var parsedEntry = GetParsedRules(ignoreFile);
|
||||||
|
if (parsedEntry is null)
|
||||||
if (string.IsNullOrWhiteSpace(ignoreFileString))
|
{
|
||||||
|
// File was deleted after we cached the path - clear the directory cache entry and return false
|
||||||
|
_directoryCache.TryRemove(searchDirectory, out _);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty file means ignore everything
|
||||||
|
if (parsedEntry.IsEmpty)
|
||||||
{
|
{
|
||||||
// Ignore directory if we just have the file
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If file has content, base ignoring off the content .gitignore-style rules
|
return parsedEntry.Rules.IsIgnored(GetPathToCheck(fileInfo.FullName, fileInfo.IsDirectory));
|
||||||
var ignoreRules = ignoreFileString.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
var ignore = new Ignore.Ignore();
|
|
||||||
ignore.Add(ignoreRules);
|
|
||||||
|
|
||||||
return ignore.IsIgnored(fileInfo.FullName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetFileContent(FileInfo dirIgnoreFile)
|
/// <summary>
|
||||||
|
/// Checks whether a path should be ignored based on an array of ignore rules.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to check.</param>
|
||||||
|
/// <param name="rules">The array of ignore rules.</param>
|
||||||
|
/// <param name="isDirectory">Whether the path is a directory.</param>
|
||||||
|
/// <returns>True if the path should be ignored.</returns>
|
||||||
|
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory)
|
||||||
|
=> CheckIgnoreRules(path, rules, isDirectory, IsWindows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a path should be ignored based on an array of ignore rules.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to check.</param>
|
||||||
|
/// <param name="rules">The array of ignore rules.</param>
|
||||||
|
/// <param name="isDirectory">Whether the path is a directory.</param>
|
||||||
|
/// <param name="normalizePath">Whether to normalize backslashes to forward slashes (for Windows paths).</param>
|
||||||
|
/// <returns>True if the path should be ignored.</returns>
|
||||||
|
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory, bool normalizePath)
|
||||||
{
|
{
|
||||||
using (var reader = dirIgnoreFile.OpenText())
|
var ignore = new Ignore.Ignore();
|
||||||
|
|
||||||
|
// Add each rule individually to catch and skip invalid patterns
|
||||||
|
var validRulesAdded = 0;
|
||||||
|
foreach (var rule in rules)
|
||||||
{
|
{
|
||||||
return reader.ReadToEnd();
|
try
|
||||||
|
{
|
||||||
|
ignore.Add(rule);
|
||||||
|
validRulesAdded++;
|
||||||
|
}
|
||||||
|
catch (RegexParseException)
|
||||||
|
{
|
||||||
|
// Ignore invalid patterns
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no valid rules were added, fall back to ignoring everything (like an empty .ignore file)
|
||||||
|
if (validRulesAdded == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
|
||||||
|
// See https://github.com/jellyfin/jellyfin/issues/15484
|
||||||
|
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
|
||||||
|
|
||||||
|
// Add trailing slash for directories to match "folder/"
|
||||||
|
if (isDirectory)
|
||||||
|
{
|
||||||
|
pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ignore.IsIgnored(pathToCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileInfo? FindIgnoreFileCached(string directory)
|
||||||
|
{
|
||||||
|
// Check if we have a cached result for this directory
|
||||||
|
if (_directoryCache.TryGet(directory, out var cached))
|
||||||
|
{
|
||||||
|
return cached.IgnoreFileDirectory is null
|
||||||
|
? null
|
||||||
|
: new FileInfo(Path.Join(cached.IgnoreFileDirectory, ".ignore"));
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectoryInfo startDir;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
startDir = new DirectoryInfo(directory);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk up the directory tree to find .ignore file using DirectoryInfo.Parent
|
||||||
|
var checkedDirs = new List<string> { directory };
|
||||||
|
|
||||||
|
for (var current = startDir; current is not null; current = current.Parent)
|
||||||
|
{
|
||||||
|
var currentPath = current.FullName;
|
||||||
|
|
||||||
|
// Check if this intermediate directory is cached
|
||||||
|
if (current != startDir && _directoryCache.TryGet(currentPath, out var parentCached))
|
||||||
|
{
|
||||||
|
// Cache the result for all directories we checked
|
||||||
|
var entry = new IgnoreFileCacheEntry(parentCached.IgnoreFileDirectory);
|
||||||
|
foreach (var dir in checkedDirs)
|
||||||
|
{
|
||||||
|
_directoryCache.AddOrUpdate(dir, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parentCached.IgnoreFileDirectory is null
|
||||||
|
? null
|
||||||
|
: new FileInfo(Path.Join(parentCached.IgnoreFileDirectory, ".ignore"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var ignoreFile = new FileInfo(Path.Join(currentPath, ".ignore"));
|
||||||
|
if (ignoreFile.Exists)
|
||||||
|
{
|
||||||
|
// Cache for all directories we checked
|
||||||
|
var entry = new IgnoreFileCacheEntry(currentPath);
|
||||||
|
foreach (var dir in checkedDirs)
|
||||||
|
{
|
||||||
|
_directoryCache.AddOrUpdate(dir, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ignoreFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current != startDir)
|
||||||
|
{
|
||||||
|
checkedDirs.Add(currentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No .ignore file found - cache null result for all directories
|
||||||
|
var nullEntry = new IgnoreFileCacheEntry((string?)null);
|
||||||
|
foreach (var dir in checkedDirs)
|
||||||
|
{
|
||||||
|
_directoryCache.AddOrUpdate(dir, nullEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ParsedIgnoreCacheEntry? GetParsedRules(FileInfo ignoreFile)
|
||||||
|
{
|
||||||
|
if (!ignoreFile.Exists)
|
||||||
|
{
|
||||||
|
_rulesCache.TryRemove(ignoreFile.FullName, out _);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastModified = ignoreFile.LastWriteTimeUtc;
|
||||||
|
var fileLength = ignoreFile.Length;
|
||||||
|
var key = ignoreFile.FullName;
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if (_rulesCache.TryGet(key, out var cached))
|
||||||
|
{
|
||||||
|
if (cached.FileLastModified == lastModified && cached.FileLength == fileLength)
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale - need to reparse
|
||||||
|
_rulesCache.TryRemove(key, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the file
|
||||||
|
var parsedEntry = ParseIgnoreFile(ignoreFile, lastModified, fileLength);
|
||||||
|
_rulesCache.AddOrUpdate(key, parsedEntry);
|
||||||
|
return parsedEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ParsedIgnoreCacheEntry ParseIgnoreFile(FileInfo ignoreFile, DateTime lastModified, long fileLength)
|
||||||
|
{
|
||||||
|
if (ignoreFile.LinkTarget is null && fileLength == 0)
|
||||||
|
{
|
||||||
|
return new ParsedIgnoreCacheEntry
|
||||||
|
{
|
||||||
|
Rules = new Ignore.Ignore(),
|
||||||
|
FileLastModified = lastModified,
|
||||||
|
FileLength = fileLength,
|
||||||
|
IsEmpty = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve symlinks
|
||||||
|
var resolvedFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
|
||||||
|
if (!resolvedFile.Exists)
|
||||||
|
{
|
||||||
|
return new ParsedIgnoreCacheEntry
|
||||||
|
{
|
||||||
|
Rules = new Ignore.Ignore(),
|
||||||
|
FileLastModified = lastModified,
|
||||||
|
FileLength = fileLength,
|
||||||
|
IsEmpty = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = File.ReadAllText(resolvedFile.FullName);
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return new ParsedIgnoreCacheEntry
|
||||||
|
{
|
||||||
|
Rules = new Ignore.Ignore(),
|
||||||
|
FileLastModified = lastModified,
|
||||||
|
FileLength = fileLength,
|
||||||
|
IsEmpty = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
var ignore = new Ignore.Ignore();
|
||||||
|
var validRulesAdded = 0;
|
||||||
|
|
||||||
|
foreach (var rule in rules)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ignore.Add(rule);
|
||||||
|
validRulesAdded++;
|
||||||
|
}
|
||||||
|
catch (RegexParseException)
|
||||||
|
{
|
||||||
|
// Ignore invalid patterns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid rules means treat as empty (ignore all)
|
||||||
|
return new ParsedIgnoreCacheEntry
|
||||||
|
{
|
||||||
|
Rules = ignore,
|
||||||
|
FileLastModified = lastModified,
|
||||||
|
FileLength = fileLength,
|
||||||
|
IsEmpty = validRulesAdded == 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetPathToCheck(string path, bool isDirectory)
|
||||||
|
{
|
||||||
|
// Normalize Windows paths
|
||||||
|
var pathToCheck = IsWindows ? path.NormalizePath('/') : path;
|
||||||
|
|
||||||
|
// Add trailing slash for directories to match "folder/"
|
||||||
|
if (isDirectory)
|
||||||
|
{
|
||||||
|
pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathToCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct IgnoreFileCacheEntry(string? IgnoreFileDirectory);
|
||||||
|
|
||||||
|
private sealed class ParsedIgnoreCacheEntry
|
||||||
|
{
|
||||||
|
public required Ignore.Ignore Rules { get; init; }
|
||||||
|
|
||||||
|
public required DateTime FileLastModified { get; init; }
|
||||||
|
|
||||||
|
public required long FileLength { get; init; }
|
||||||
|
|
||||||
|
public required bool IsEmpty { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediaBrowser.Controller.Chapters;
|
using MediaBrowser.Controller.Chapters;
|
||||||
@@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
|
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList();
|
DeleteExternalItemFiles(item);
|
||||||
var itemId = item.Id;
|
|
||||||
if (validPaths.Count > 0)
|
|
||||||
{
|
|
||||||
foreach (var path in validPaths)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Directory.Delete(path, true);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var itemId = item.Id;
|
||||||
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
|
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void DeleteExternalItemFiles(BaseItem item)
|
||||||
|
{
|
||||||
|
foreach (var path in _pathManager.GetExtractedDataPaths(item))
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(path, true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ namespace Emby.Server.Implementations.Library
|
|||||||
"**/*.sample.?????",
|
"**/*.sample.?????",
|
||||||
"**/sample/*",
|
"**/sample/*",
|
||||||
|
|
||||||
|
// Avoid adding Hungarian sample files
|
||||||
|
// https://github.com/jellyfin/jellyfin/issues/16237
|
||||||
|
"**/minta.?",
|
||||||
|
"**/minta.??",
|
||||||
|
"**/minta.???", // Matches minta.mkv
|
||||||
|
"**/minta.????", // Matches minta.webm
|
||||||
|
"**/minta.?????",
|
||||||
|
"**/*.minta.?",
|
||||||
|
"**/*.minta.??",
|
||||||
|
"**/*.minta.???",
|
||||||
|
"**/*.minta.????",
|
||||||
|
"**/*.minta.?????",
|
||||||
|
"**/minta/*",
|
||||||
|
|
||||||
// Directories
|
// Directories
|
||||||
"**/metadata/**",
|
"**/metadata/**",
|
||||||
"**/metadata",
|
"**/metadata",
|
||||||
@@ -50,6 +64,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
"**/lost+found",
|
"**/lost+found",
|
||||||
"**/subs/**",
|
"**/subs/**",
|
||||||
"**/subs",
|
"**/subs",
|
||||||
|
"**/.snapshots/**",
|
||||||
|
"**/.snapshots",
|
||||||
|
"**/.snapshot/**",
|
||||||
|
"**/.snapshot",
|
||||||
|
|
||||||
// Trickplay files
|
// Trickplay files
|
||||||
"**/*.trickplay",
|
"**/*.trickplay",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ using MediaBrowser.Common.Configuration;
|
|||||||
using MediaBrowser.Common.Extensions;
|
using MediaBrowser.Common.Extensions;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
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.MediaEncoding;
|
||||||
@@ -126,6 +128,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stream.IsVobSubSubtitleStream)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +177,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
public async Task<IReadOnlyList<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
|
var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
|
||||||
|
ResolveSymlinkPaths(mediaSources, enablePathSubstitution);
|
||||||
|
|
||||||
// If file is strm or main media stream is missing, force a metadata refresh with remote probing
|
// If file is strm or main media stream is missing, force a metadata refresh with remote probing
|
||||||
if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder
|
if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder
|
||||||
@@ -186,6 +194,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
|
mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
|
||||||
|
ResolveSymlinkPaths(mediaSources, enablePathSubstitution);
|
||||||
}
|
}
|
||||||
|
|
||||||
var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false);
|
var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false);
|
||||||
@@ -220,12 +229,21 @@ namespace Emby.Server.Implementations.Library
|
|||||||
list.Add(source);
|
list.Add(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SortMediaSources(list).ToArray();
|
var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId)
|
||||||
|
? topSourceId
|
||||||
|
: item.Id;
|
||||||
|
|
||||||
|
return SortMediaSources(list, preferredId).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />>
|
/// <inheritdoc />>
|
||||||
public MediaProtocol GetPathProtocol(string path)
|
public MediaProtocol GetPathProtocol(string path)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return MediaProtocol.File;
|
||||||
|
}
|
||||||
|
|
||||||
if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase))
|
if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return MediaProtocol.Rtsp;
|
return MediaProtocol.Rtsp;
|
||||||
@@ -313,6 +331,28 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves symlinked file paths on the supplied sources to the real on-disk target.
|
||||||
|
/// Skipped when <paramref name="enablePathSubstitution"/> is set because the path may
|
||||||
|
/// already have been rewritten to a UNC/URL meant for the client to consume directly.
|
||||||
|
/// </summary>
|
||||||
|
private static void ResolveSymlinkPaths(IReadOnlyList<MediaSourceInfo> sources, bool enablePathSubstitution)
|
||||||
|
{
|
||||||
|
if (enablePathSubstitution)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
if (source.Protocol == MediaProtocol.File
|
||||||
|
&& FileSystemHelper.ResolveLinkTarget(source.Path, returnFinalTarget: true) is { Exists: true } target)
|
||||||
|
{
|
||||||
|
source.Path = target.FullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource)
|
private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource)
|
||||||
{
|
{
|
||||||
var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimiter;
|
var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimiter;
|
||||||
@@ -350,6 +390,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
if (user is not null)
|
if (user is not null)
|
||||||
{
|
{
|
||||||
|
sources = sources
|
||||||
|
.Where(source => !Guid.TryParse(source.Id, out var sourceId)
|
||||||
|
|| sourceId.Equals(item.Id)
|
||||||
|
|| _libraryManager.GetItemById<BaseItem>(sourceId, user) is not null)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
foreach (var source in sources)
|
foreach (var source in sources)
|
||||||
{
|
{
|
||||||
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
|
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
|
||||||
@@ -364,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;
|
||||||
@@ -418,7 +517,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLanguage);
|
MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
|
private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection, string originalLanguage)
|
||||||
{
|
{
|
||||||
if (userData is not null && userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection)
|
if (userData is not null && userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection)
|
||||||
{
|
{
|
||||||
@@ -432,7 +531,38 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference);
|
if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (user.PlayDefaultAudioTrack)
|
||||||
|
{
|
||||||
|
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(
|
||||||
|
source.MediaStreams,
|
||||||
|
NormalizeLanguage(originalLanguage),
|
||||||
|
user.PlayDefaultAudioTrack);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalIndex = source.MediaStreams.FindIndex(i => i.Type == MediaStreamType.Audio && i.IsOriginal);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(originalLanguage) && originalIndex != -1)
|
||||||
|
{
|
||||||
|
var mediaLanguageOriginal = source.MediaStreams[originalIndex].Language;
|
||||||
|
if (NormalizeLanguage(mediaLanguageOriginal).Contains(NormalizeLanguage(originalLanguage).FirstOrDefault()))
|
||||||
|
{
|
||||||
|
source.DefaultAudioStreamIndex = originalIndex;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (originalIndex != -1)
|
||||||
|
{
|
||||||
|
source.DefaultAudioStreamIndex = originalIndex;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var preferredAudio = string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(originalLanguage)
|
||||||
|
? NormalizeLanguage(originalLanguage)
|
||||||
|
: NormalizeLanguage(user.AudioLanguagePreference);
|
||||||
|
|
||||||
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack);
|
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack);
|
||||||
if (user.PlayDefaultAudioTrack)
|
if (user.PlayDefaultAudioTrack)
|
||||||
@@ -457,7 +587,9 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections;
|
var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections;
|
||||||
|
|
||||||
SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection);
|
var originalLanguage = item?.GetInheritedOriginalLanguage();
|
||||||
|
|
||||||
|
SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage);
|
||||||
SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection);
|
SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection);
|
||||||
}
|
}
|
||||||
else if (mediaType == MediaType.Audio)
|
else if (mediaType == MediaType.Audio)
|
||||||
@@ -471,24 +603,32 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources)
|
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources, Guid preferredItemId = default)
|
||||||
{
|
{
|
||||||
return sources.OrderBy(i =>
|
// The source belonging to the queried item sorts first so it stays the default that gets played.
|
||||||
{
|
var preferredId = preferredItemId.IsEmpty()
|
||||||
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
|
? null
|
||||||
|
: preferredItemId.ToString("N", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
return sources
|
||||||
|
.OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ThenBy(i =>
|
||||||
{
|
{
|
||||||
return 0;
|
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
|
||||||
}
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
|
})
|
||||||
.ThenByDescending(i =>
|
.ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
|
||||||
{
|
.ThenByDescending(i =>
|
||||||
var stream = i.VideoStream;
|
{
|
||||||
|
var stream = i.VideoStream;
|
||||||
|
|
||||||
return stream?.Width ?? 0;
|
return stream?.Width ?? 0;
|
||||||
})
|
})
|
||||||
.Where(i => i.Type != MediaSourceType.Placeholder);
|
.Where(i => i.Type != MediaSourceType.Placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
|
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions)
|
public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions)
|
||||||
{
|
{
|
||||||
return GetInstantMixFromGenres(item.Genres, user, dtoOptions);
|
var instantMixItems = GetInstantMixFromGenres(item.Genres, user, dtoOptions);
|
||||||
|
|
||||||
|
return [item, .. instantMixItems.Where(i => !i.Id.Equals(item.Id))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -29,28 +29,79 @@ 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",
|
||||||
if (attributeIndex > 0
|
_ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
|
||||||
&& str[attributeIndex - 1] == '['
|
_ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
|
||||||
&& (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
|
_ => 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)
|
||||||
{
|
{
|
||||||
var closingIndex = str[attributeEnd..].IndexOf(']');
|
// If we are using an alias it should be shorter (and a prefix), so let's search for that.
|
||||||
// Must be at least 1 character before the closing bracket.
|
attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase);
|
||||||
if (closingIndex > 1)
|
attributeEnd = attributeIndex + shortAttr.Length;
|
||||||
{
|
}
|
||||||
return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
|
else
|
||||||
}
|
{
|
||||||
|
attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
|
||||||
|
attributeEnd = attributeIndex + attribute.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
str = str[attributeEnd..];
|
// The next iteration should start at the end of the attribute we just found.
|
||||||
attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
|
// If attributeIndex < 0, the loop will end and strIndex won't be used again.
|
||||||
|
strIndex += attributeEnd;
|
||||||
|
|
||||||
|
if (attributeIndex > 0)
|
||||||
|
{
|
||||||
|
var attributeOpener = subStr[attributeIndex - 1];
|
||||||
|
var attributeCloser = attributeOpener switch
|
||||||
|
{
|
||||||
|
'[' => ']',
|
||||||
|
'(' => ')',
|
||||||
|
'{' => '}',
|
||||||
|
_ => '\0'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (attributeCloser != '\0')
|
||||||
|
{
|
||||||
|
if (shortAttr.Length > 0
|
||||||
|
&& attributeEnd + 1 < subStr.Length
|
||||||
|
&& (subStr[attributeEnd] is 'i' or 'I')
|
||||||
|
&& (subStr[attributeEnd + 1] is 'd' or 'D'))
|
||||||
|
{
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// for imdbid we also accept pattern matching
|
// for imdbid we also accept pattern matching
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ 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;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Library;
|
namespace Emby.Server.Implementations.Library;
|
||||||
|
|
||||||
@@ -14,18 +16,22 @@ namespace Emby.Server.Implementations.Library;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class PathManager : IPathManager
|
public class PathManager : IPathManager
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<PathManager> _logger;
|
||||||
private readonly IServerConfigurationManager _config;
|
private readonly IServerConfigurationManager _config;
|
||||||
private readonly IApplicationPaths _appPaths;
|
private readonly IApplicationPaths _appPaths;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="PathManager"/> class.
|
/// Initializes a new instance of the <see cref="PathManager"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
/// <param name="config">The server configuration manager.</param>
|
/// <param name="config">The server configuration manager.</param>
|
||||||
/// <param name="appPaths">The application paths.</param>
|
/// <param name="appPaths">The application paths.</param>
|
||||||
public PathManager(
|
public PathManager(
|
||||||
|
ILogger<PathManager> logger,
|
||||||
IServerConfigurationManager config,
|
IServerConfigurationManager config,
|
||||||
IApplicationPaths appPaths)
|
IApplicationPaths appPaths)
|
||||||
{
|
{
|
||||||
|
_logger = logger;
|
||||||
_config = config;
|
_config = config;
|
||||||
_appPaths = appPaths;
|
_appPaths = appPaths;
|
||||||
}
|
}
|
||||||
@@ -35,31 +41,55 @@ public class PathManager : IPathManager
|
|||||||
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
|
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string GetAttachmentPath(string mediaSourceId, string fileName)
|
public string? GetAttachmentPath(string mediaSourceId, string fileName)
|
||||||
{
|
{
|
||||||
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName);
|
var folder = GetAttachmentFolderPath(mediaSourceId);
|
||||||
|
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 />
|
||||||
public string GetAttachmentFolderPath(string mediaSourceId)
|
public string? GetAttachmentFolderPath(string mediaSourceId)
|
||||||
{
|
{
|
||||||
var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
if (!Guid.TryParse(mediaSourceId, out var parsed))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk attachment folder.", mediaSourceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
||||||
return Path.Join(AttachmentCachePath, id[..2], id);
|
return Path.Join(AttachmentCachePath, id[..2], id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string GetSubtitleFolderPath(string mediaSourceId)
|
public string? GetSubtitleFolderPath(string mediaSourceId)
|
||||||
{
|
{
|
||||||
var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
if (!Guid.TryParse(mediaSourceId, out var parsed))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk subtitle folder.", mediaSourceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan();
|
||||||
return Path.Join(SubtitleCachePath, id[..2], id);
|
return Path.Join(SubtitleCachePath, id[..2], id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension)
|
public string? GetSubtitlePath(string mediaSourceId, int streamIndex, string extension)
|
||||||
{
|
{
|
||||||
return Path.Combine(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
|
var folder = GetSubtitleFolderPath(mediaSourceId);
|
||||||
|
return folder is null ? null : Path.Combine(folder, streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -90,12 +120,27 @@ public class PathManager : IPathManager
|
|||||||
public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item)
|
public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item)
|
||||||
{
|
{
|
||||||
var mediaSourceId = item.Id.ToString("N", CultureInfo.InvariantCulture);
|
var mediaSourceId = item.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||||
return [
|
List<string> paths = [];
|
||||||
GetAttachmentFolderPath(mediaSourceId),
|
var attachmentFolder = GetAttachmentFolderPath(mediaSourceId);
|
||||||
GetSubtitleFolderPath(mediaSourceId),
|
if (attachmentFolder is not null)
|
||||||
GetTrickplayDirectory(item, false),
|
{
|
||||||
GetTrickplayDirectory(item, true),
|
paths.Add(attachmentFolder);
|
||||||
GetChapterImageFolderPath(item)
|
}
|
||||||
];
|
|
||||||
|
var subtitleFolder = GetSubtitleFolderPath(mediaSourceId);
|
||||||
|
if (subtitleFolder is not null)
|
||||||
|
{
|
||||||
|
paths.Add(subtitleFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
paths.Add(GetTrickplayDirectory(item, false));
|
||||||
|
if (!string.IsNullOrEmpty(item.Path))
|
||||||
|
{
|
||||||
|
paths.Add(GetTrickplayDirectory(item, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
paths.Add(GetChapterImageFolderPath(item));
|
||||||
|
|
||||||
|
return paths;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
#nullable disable
|
|
||||||
|
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Emby.Naming.Book;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Resolvers;
|
using MediaBrowser.Controller.Resolvers;
|
||||||
using MediaBrowser.Model.Entities;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Library.Resolvers.Books
|
namespace Emby.Server.Implementations.Library.Resolvers.Books
|
||||||
{
|
{
|
||||||
@@ -18,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
|||||||
{
|
{
|
||||||
private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" };
|
private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" };
|
||||||
|
|
||||||
protected override Book Resolve(ItemResolveArgs args)
|
protected override Book? Resolve(ItemResolveArgs args)
|
||||||
{
|
{
|
||||||
var collectionType = args.GetCollectionType();
|
var collectionType = args.GetCollectionType();
|
||||||
|
|
||||||
@@ -35,20 +33,26 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
|||||||
|
|
||||||
var extension = Path.GetExtension(args.Path.AsSpan());
|
var extension = Path.GetExtension(args.Path.AsSpan());
|
||||||
|
|
||||||
if (_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
if (!_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
// It's a book
|
return null;
|
||||||
return new Book
|
|
||||||
{
|
|
||||||
Path = args.Path,
|
|
||||||
IsInMixedFolder = true
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
var result = BookFileNameParser.Parse(Path.GetFileNameWithoutExtension(args.Path));
|
||||||
|
|
||||||
|
return new Book
|
||||||
|
{
|
||||||
|
Path = args.Path,
|
||||||
|
Name = result.Name ?? string.Empty,
|
||||||
|
IndexNumber = result.Index,
|
||||||
|
ParentIndexNumber = result.ParentIndex,
|
||||||
|
ProductionYear = result.Year,
|
||||||
|
SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)),
|
||||||
|
IsInMixedFolder = true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private Book GetBook(ItemResolveArgs args)
|
private Book? GetBook(ItemResolveArgs args)
|
||||||
{
|
{
|
||||||
var bookFiles = args.FileSystemChildren.Where(f =>
|
var bookFiles = args.FileSystemChildren.Where(f =>
|
||||||
{
|
{
|
||||||
@@ -59,15 +63,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
|||||||
StringComparison.OrdinalIgnoreCase);
|
StringComparison.OrdinalIgnoreCase);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
// Don't return a Book if there is more (or less) than one document in the directory
|
// directory is only considered a book when it contains exactly one supported file
|
||||||
|
// other library structures with multiple books to a directory will get picked up as individual files
|
||||||
if (bookFiles.Count != 1)
|
if (bookFiles.Count != 1)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var result = BookFileNameParser.Parse(Path.GetFileName(args.Path));
|
||||||
|
|
||||||
return new Book
|
return new Book
|
||||||
{
|
{
|
||||||
Path = bookFiles[0].FullName
|
Path = bookFiles[0].FullName,
|
||||||
|
Name = result.Name ?? string.Empty,
|
||||||
|
IndexNumber = result.Index,
|
||||||
|
ParentIndexNumber = result.ParentIndex,
|
||||||
|
ProductionYear = result.Year,
|
||||||
|
SeriesName = result.SeriesName ?? string.Empty,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,15 +28,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
|||||||
public partial class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver
|
public partial class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver
|
||||||
{
|
{
|
||||||
private readonly IImageProcessor _imageProcessor;
|
private readonly IImageProcessor _imageProcessor;
|
||||||
|
private readonly VideoListResolver _videoListResolver;
|
||||||
|
|
||||||
private static readonly CollectionType[] _validCollectionTypes = new[]
|
private static readonly CollectionType[] _validCollectionTypes =
|
||||||
{
|
[
|
||||||
CollectionType.movies,
|
CollectionType.movies,
|
||||||
CollectionType.homevideos,
|
CollectionType.homevideos,
|
||||||
CollectionType.musicvideos,
|
CollectionType.musicvideos,
|
||||||
CollectionType.tvshows,
|
CollectionType.tvshows,
|
||||||
CollectionType.photos
|
CollectionType.photos
|
||||||
};
|
];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="MovieResolver"/> class.
|
/// Initializes a new instance of the <see cref="MovieResolver"/> class.
|
||||||
@@ -45,10 +46,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
|||||||
/// <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 MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService)
|
/// <param name="videoListResolver">The video list resolver.</param>
|
||||||
|
public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService, VideoListResolver videoListResolver)
|
||||||
: base(logger, namingOptions, directoryService)
|
: base(logger, namingOptions, directoryService)
|
||||||
{
|
{
|
||||||
_imageProcessor = imageProcessor;
|
_imageProcessor = imageProcessor;
|
||||||
|
_videoListResolver = videoListResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -228,7 +231,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
|||||||
|
|
||||||
if (collectionType == CollectionType.tvshows)
|
if (collectionType == CollectionType.tvshows)
|
||||||
{
|
{
|
||||||
return ResolveVideos<Episode>(parent, files, false, collectionType, true);
|
return ResolveVideos<Episode>(parent, files, true, collectionType, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -274,7 +277,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
|||||||
.Where(f => f is not null)
|
.Where(f => f is not null)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var resolverResult = VideoListResolver.Resolve(videoInfos, NamingOptions, supportMultiEditions, parseName, parent.ContainingFolderPath);
|
var resolverResult = _videoListResolver.Resolve(videoInfos, supportMultiEditions, parseName, parent.ContainingFolderPath, collectionType);
|
||||||
|
|
||||||
var result = new MultiItemResolverResult
|
var result = new MultiItemResolverResult
|
||||||
{
|
{
|
||||||
@@ -302,7 +305,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
|||||||
ProductionYear = video.Year,
|
ProductionYear = video.Year,
|
||||||
Name = parseName ? video.Name : firstVideo.Name,
|
Name = parseName ? video.Name : firstVideo.Name,
|
||||||
AdditionalParts = additionalParts,
|
AdditionalParts = additionalParts,
|
||||||
LocalAlternateVersions = video.AlternateVersions.Select(i => i.Path).ToArray()
|
LocalAlternateVersions = video.AlternateVersions.Select(av => av.Files[0].Path).ToArray()
|
||||||
};
|
};
|
||||||
|
|
||||||
SetVideoType(videoItem, firstVideo);
|
SetVideoType(videoItem, firstVideo);
|
||||||
@@ -331,9 +334,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
|||||||
|
|
||||||
for (var j = 0; j < current.AlternateVersions.Count; j++)
|
for (var j = 0; j < current.AlternateVersions.Count; j++)
|
||||||
{
|
{
|
||||||
if (ContainsFile(current.AlternateVersions[j], file))
|
var alternate = current.AlternateVersions[j];
|
||||||
|
for (var k = 0; k < alternate.Files.Count; k++)
|
||||||
{
|
{
|
||||||
return true;
|
if (ContainsFile(alternate.Files[k], file))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,11 +376,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
|||||||
// We need to only look at the name of this actual item (not parents)
|
// We need to only look at the name of this actual item (not parents)
|
||||||
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
|
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
|
||||||
|
|
||||||
if (!justName.IsEmpty)
|
// 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());
|
||||||
|
|
||||||
|
item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid"));
|
||||||
|
item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid"));
|
||||||
|
|
||||||
|
string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute)
|
||||||
{
|
{
|
||||||
// Check for TMDb id
|
var id = name.GetAttributeValue(attribute);
|
||||||
var tmdbid = justName.GetAttributeValue("tmdbid");
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Emby.Naming.Common;
|
using Emby.Naming.Common;
|
||||||
|
using Emby.Server.Implementations.Library;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using MediaBrowser.Controller.Entities.TV;
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
@@ -81,10 +83,34 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||||||
episode.ParentIndexNumber = 1;
|
episode.ParentIndexNumber = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetProviderIdFromPath(episode, args.Path);
|
||||||
|
|
||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets provider ids from the episode file name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The episode.</param>
|
||||||
|
/// <param name="path">The episode file path.</param>
|
||||||
|
private static void SetProviderIdFromPath(Episode item, string path)
|
||||||
|
{
|
||||||
|
var justName = Path.GetFileNameWithoutExtension(path.AsSpan());
|
||||||
|
|
||||||
|
var imdbId = justName.GetAttributeValue("imdbid");
|
||||||
|
item.TrySetProviderId(MetadataProvider.Imdb, imdbId);
|
||||||
|
|
||||||
|
var tvdbId = justName.GetAttributeValue("tvdbid");
|
||||||
|
item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
|
||||||
|
|
||||||
|
var tvmazeId = justName.GetAttributeValue("tvmazeid");
|
||||||
|
item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId);
|
||||||
|
|
||||||
|
var tmdbId = justName.GetAttributeValue("tmdbid");
|
||||||
|
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using Emby.Naming.Common;
|
using Emby.Naming.Common;
|
||||||
using Emby.Naming.TV;
|
using Emby.Naming.TV;
|
||||||
|
using Emby.Server.Implementations.Library;
|
||||||
using MediaBrowser.Controller.Entities.TV;
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Model.Globalization;
|
using MediaBrowser.Model.Globalization;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -77,6 +82,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var hasAnyVideo = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)
|
||||||
|
.Any(file => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(file)));
|
||||||
|
|
||||||
|
if (!hasAnyVideo)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name))
|
if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name))
|
||||||
@@ -91,10 +104,31 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||||||
args.LibraryOptions.PreferredMetadataLanguage);
|
args.LibraryOptions.PreferredMetadataLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetProviderIdFromPath(season, path);
|
||||||
|
|
||||||
return season;
|
return season;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets provider ids from the season folder name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The season.</param>
|
||||||
|
/// <param name="path">The season folder path.</param>
|
||||||
|
private static void SetProviderIdFromPath(Season item, string path)
|
||||||
|
{
|
||||||
|
var justName = Path.GetFileName(path.AsSpan());
|
||||||
|
|
||||||
|
var tvdbId = justName.GetAttributeValue("tvdbid");
|
||||||
|
item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
|
||||||
|
|
||||||
|
var tvmazeId = justName.GetAttributeValue("tvmazeid");
|
||||||
|
item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId);
|
||||||
|
|
||||||
|
var tmdbId = justName.GetAttributeValue("tmdbid");
|
||||||
|
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||||||
/// </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>
|
||||||
public SeriesResolver(ILogger<SeriesResolver> logger, NamingOptions namingOptions)
|
public SeriesResolver(ILogger<SeriesResolver> logger, NamingOptions namingOptions)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_namingOptions = namingOptions;
|
_namingOptions = namingOptions;
|
||||||
@@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.Parent is not null && args.Parent.IsRoot)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
|
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
|
||||||
|
|
||||||
var collectionType = args.GetCollectionType();
|
var collectionType = args.GetCollectionType();
|
||||||
@@ -69,7 +74,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||||||
return new Series
|
return new Series
|
||||||
{
|
{
|
||||||
Path = args.Path,
|
Path = args.Path,
|
||||||
Name = seriesInfo.Name
|
Name = seriesInfo.Name,
|
||||||
|
ProductionYear = seriesInfo.Year
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Persistence;
|
||||||
|
using MediaBrowser.Model.Querying;
|
||||||
|
using MediaBrowser.Model.Search;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library.Search;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages search providers and orchestrates search operations.
|
||||||
|
/// </summary>
|
||||||
|
public class SearchManager : ISearchManager
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IUserManager _userManager;
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||||
|
private readonly IItemQueryHelpers _queryHelpers;
|
||||||
|
private readonly ILogger<SearchManager> _logger;
|
||||||
|
private IExternalSearchProvider[] _externalProviders = [];
|
||||||
|
private IInternalSearchProvider[] _internalProviders = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SearchManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
/// <param name="userManager">The user manager.</param>
|
||||||
|
/// <param name="dbProvider">The database context factory.</param>
|
||||||
|
/// <param name="queryHelpers">The shared item query helpers.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public SearchManager(
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IUserManager userManager,
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
|
IItemQueryHelpers queryHelpers,
|
||||||
|
ILogger<SearchManager> logger)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_userManager = userManager;
|
||||||
|
_dbProvider = dbProvider;
|
||||||
|
_queryHelpers = queryHelpers;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void AddParts(IEnumerable<ISearchProvider> providers)
|
||||||
|
{
|
||||||
|
var allProviders = providers.OrderBy(p => p.Priority).ToArray();
|
||||||
|
|
||||||
|
_externalProviders = allProviders.OfType<IExternalSearchProvider>().ToArray();
|
||||||
|
_internalProviders = allProviders.OfType<IInternalSearchProvider>().ToArray();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackProviders}",
|
||||||
|
_externalProviders.Length,
|
||||||
|
string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")),
|
||||||
|
string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public IReadOnlyList<ISearchProvider> GetProviders()
|
||||||
|
{
|
||||||
|
return [.. _externalProviders, .. _internalProviders];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync(
|
||||||
|
SearchProviderQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||||
|
|
||||||
|
var searchTerm = query.SearchTerm.Trim().RemoveDiacritics();
|
||||||
|
|
||||||
|
var externalTask = CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken);
|
||||||
|
var internalTask = _internalProviders.Length > 0
|
||||||
|
? CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken)
|
||||||
|
: Task.FromResult<IReadOnlyList<SearchResult>>([]);
|
||||||
|
|
||||||
|
await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var externalResults = await externalTask.ConfigureAwait(false);
|
||||||
|
var fromExternal = externalResults.Count > 0;
|
||||||
|
IReadOnlyList<SearchResult> results;
|
||||||
|
if (fromExternal)
|
||||||
|
{
|
||||||
|
results = externalResults;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
results = await internalTask.ConfigureAwait(false);
|
||||||
|
if (_internalProviders.Length > 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No results from external providers, using internal provider results");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal providers apply user-access filtering inline in their queries. External
|
||||||
|
// providers don't know about user permissions, so they may return IDs from hidden
|
||||||
|
// libraries or items the user is otherwise blocked from. Run the post-filter only
|
||||||
|
// when results came from externals to close that gap. The Items controller's second
|
||||||
|
// roundtrip via folder.GetItems applies most of these again, but it does not restrict
|
||||||
|
// by TopParentIds when ItemIds is set.
|
||||||
|
if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
|
||||||
|
{
|
||||||
|
var user = _userManager.GetUserById(query.UserId.Value);
|
||||||
|
if (user is not null)
|
||||||
|
{
|
||||||
|
results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
||||||
|
IReadOnlyList<SearchResult> candidates,
|
||||||
|
User user,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
|
||||||
|
// TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
|
||||||
|
// because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
|
||||||
|
var accessFilter = new InternalItemsQuery(user);
|
||||||
|
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||||
|
|
||||||
|
Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
|
||||||
|
|
||||||
|
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.ItemId)).ToList();
|
||||||
|
if (filtered.Count < candidates.Count)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Dropped {Dropped} of {Total} search candidates due to user access filtering",
|
||||||
|
candidates.Count - filtered.Count,
|
||||||
|
candidates.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||||
|
|
||||||
|
var providerQuery = BuildProviderQuery(query);
|
||||||
|
var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
return new QueryResult<SearchHintInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateScores = BuildScoreLookup(candidates);
|
||||||
|
var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId);
|
||||||
|
|
||||||
|
var excludeItemTypes = BuildExcludeItemTypes(query);
|
||||||
|
var includeItemTypes = BuildIncludeItemTypes(query);
|
||||||
|
|
||||||
|
var internalQuery = new InternalItemsQuery(user)
|
||||||
|
{
|
||||||
|
ItemIds = candidateScores.Keys.ToArray(),
|
||||||
|
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
||||||
|
IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [],
|
||||||
|
MediaTypes = query.MediaTypes.ToArray(),
|
||||||
|
IncludeItemsByName = !query.ParentId.HasValue,
|
||||||
|
ParentId = query.ParentId ?? Guid.Empty,
|
||||||
|
Recursive = true,
|
||||||
|
IsKids = query.IsKids,
|
||||||
|
IsMovie = query.IsMovie,
|
||||||
|
IsNews = query.IsNews,
|
||||||
|
IsSeries = query.IsSeries,
|
||||||
|
IsSports = query.IsSports,
|
||||||
|
DtoOptions = new DtoOptions
|
||||||
|
{
|
||||||
|
Fields =
|
||||||
|
[
|
||||||
|
ItemFields.AirTime,
|
||||||
|
ItemFields.DateCreated,
|
||||||
|
ItemFields.ChannelInfo,
|
||||||
|
ItemFields.ParentId
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name
|
||||||
|
// rather than being stored as regular library items. They require special handling:
|
||||||
|
// 1. Convert ParentId to AncestorIds (to filter by library folder)
|
||||||
|
// 2. Set IncludeItemsByName = true (to include these virtual items in results)
|
||||||
|
// 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally)
|
||||||
|
// 4. Use GetAllArtists() instead of GetItemList() to query the artist index
|
||||||
|
IReadOnlyList<BaseItem> items;
|
||||||
|
if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
|
||||||
|
{
|
||||||
|
if (!internalQuery.ParentId.IsEmpty())
|
||||||
|
{
|
||||||
|
internalQuery.AncestorIds = [internalQuery.ParentId];
|
||||||
|
internalQuery.ParentId = Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
internalQuery.IncludeItemsByName = true;
|
||||||
|
internalQuery.IncludeItemTypes = [];
|
||||||
|
items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
items = _libraryManager.GetItemList(internalQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderedResults = items
|
||||||
|
.Select(item => new SearchHintInfo { Item = item })
|
||||||
|
.OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var totalCount = orderedResults.Count;
|
||||||
|
|
||||||
|
if (query.StartIndex.HasValue)
|
||||||
|
{
|
||||||
|
orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.Limit.HasValue)
|
||||||
|
{
|
||||||
|
orderedResults = orderedResults.Take(query.Limit.Value).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new QueryResult<SearchHintInfo>(query.StartIndex, totalCount, orderedResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<SearchResult>> CollectFromProvidersAsync(
|
||||||
|
IEnumerable<ISearchProvider> providers,
|
||||||
|
SearchProviderQuery providerQuery,
|
||||||
|
string searchTerm,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestedLimit = providerQuery.Limit ?? 100;
|
||||||
|
var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray();
|
||||||
|
if (applicable.Length == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var perProvider = await Task.WhenAll(
|
||||||
|
applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationToken)))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var bestScores = new Dictionary<Guid, float>();
|
||||||
|
foreach (var providerResults in perProvider)
|
||||||
|
{
|
||||||
|
foreach (var result in providerResults)
|
||||||
|
{
|
||||||
|
UpdateBestScore(bestScores, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestScores
|
||||||
|
.Select(kvp => new SearchResult(kvp.Key, kvp.Value))
|
||||||
|
.OrderByDescending(r => r.Score)
|
||||||
|
.Take(requestedLimit)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<SearchResult>> CollectFromProviderAsync(
|
||||||
|
ISearchProvider provider,
|
||||||
|
SearchProviderQuery providerQuery,
|
||||||
|
string searchTerm,
|
||||||
|
int requestedLimit,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var results = provider is IExternalSearchProvider externalProvider
|
||||||
|
? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationToken).ConfigureAwait(false)
|
||||||
|
: await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'",
|
||||||
|
provider.Name,
|
||||||
|
results.Count,
|
||||||
|
searchTerm);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<SearchResult>> CollectFromExternalProviderAsync(
|
||||||
|
IExternalSearchProvider provider,
|
||||||
|
SearchProviderQuery providerQuery,
|
||||||
|
int requestedLimit,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var results = new List<SearchResult>();
|
||||||
|
await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
results.Add(result);
|
||||||
|
if (results.Count >= requestedLimit)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateBestScore(Dictionary<Guid, float> bestScores, SearchResult result)
|
||||||
|
{
|
||||||
|
if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore)
|
||||||
|
{
|
||||||
|
bestScores[result.ItemId] = result.Score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<Guid, float> BuildScoreLookup(IReadOnlyList<SearchResult> results)
|
||||||
|
{
|
||||||
|
var lookup = new Dictionary<Guid, float>(results.Count);
|
||||||
|
foreach (var result in results)
|
||||||
|
{
|
||||||
|
lookup[result.ItemId] = result.Score;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SearchProviderQuery BuildProviderQuery(SearchQuery query)
|
||||||
|
{
|
||||||
|
var excludeItemTypes = BuildExcludeItemTypes(query);
|
||||||
|
var includeItemTypes = BuildIncludeItemTypes(query);
|
||||||
|
|
||||||
|
// Remove any excluded types from includes
|
||||||
|
if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0)
|
||||||
|
{
|
||||||
|
includeItemTypes.RemoveAll(excludeItemTypes.Contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SearchProviderQuery
|
||||||
|
{
|
||||||
|
SearchTerm = query.SearchTerm,
|
||||||
|
UserId = query.UserId.IsEmpty() ? null : query.UserId,
|
||||||
|
IncludeItemTypes = includeItemTypes.ToArray(),
|
||||||
|
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
||||||
|
MediaTypes = query.MediaTypes.ToArray(),
|
||||||
|
Limit = query.Limit,
|
||||||
|
ParentId = query.ParentId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<BaseItemKind> BuildExcludeItemTypes(SearchQuery query)
|
||||||
|
{
|
||||||
|
var excludeItemTypes = query.ExcludeItemTypes.ToList();
|
||||||
|
|
||||||
|
excludeItemTypes.Add(BaseItemKind.Year);
|
||||||
|
excludeItemTypes.Add(BaseItemKind.Folder);
|
||||||
|
excludeItemTypes.Add(BaseItemKind.CollectionFolder);
|
||||||
|
|
||||||
|
if (!query.IncludeGenres)
|
||||||
|
{
|
||||||
|
AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
|
||||||
|
AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query.IncludePeople)
|
||||||
|
{
|
||||||
|
AddIfMissing(excludeItemTypes, BaseItemKind.Person);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query.IncludeStudios)
|
||||||
|
{
|
||||||
|
AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query.IncludeArtists)
|
||||||
|
{
|
||||||
|
AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
|
||||||
|
}
|
||||||
|
|
||||||
|
return excludeItemTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<BaseItemKind> BuildIncludeItemTypes(SearchQuery query)
|
||||||
|
{
|
||||||
|
var includeItemTypes = query.IncludeItemTypes.ToList();
|
||||||
|
if (query.IncludeMedia)
|
||||||
|
{
|
||||||
|
return includeItemTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre))
|
||||||
|
{
|
||||||
|
AddIfMissing(includeItemTypes, BaseItemKind.Genre);
|
||||||
|
AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person))
|
||||||
|
{
|
||||||
|
AddIfMissing(includeItemTypes, BaseItemKind.Person);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio))
|
||||||
|
{
|
||||||
|
AddIfMissing(includeItemTypes, BaseItemKind.Studio);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist))
|
||||||
|
{
|
||||||
|
AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
|
||||||
|
}
|
||||||
|
|
||||||
|
return includeItemTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEmptyOrContains(List<BaseItemKind> list, BaseItemKind value)
|
||||||
|
=> list.Count == 0 || list.Contains(value);
|
||||||
|
|
||||||
|
private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
|
||||||
|
{
|
||||||
|
if (!list.Contains(value))
|
||||||
|
{
|
||||||
|
list.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
#pragma warning disable RS0030 // Do not use banned APIs
|
||||||
|
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Persistence;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library.Search;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Built-in SQL-based search provider that queries the library database directly.
|
||||||
|
/// </summary>
|
||||||
|
public class SqlSearchProvider : IInternalSearchProvider
|
||||||
|
{
|
||||||
|
private const int DefaultSearchLimit = 100;
|
||||||
|
private const float ExactMatchScore = 100f;
|
||||||
|
private const float PrefixMatchScore = 80f;
|
||||||
|
private const float WordPrefixMatchScore = 75f;
|
||||||
|
private const float ContainsMatchScore = 50f;
|
||||||
|
|
||||||
|
private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||||
|
private readonly IItemTypeLookup _itemTypeLookup;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IUserManager _userManager;
|
||||||
|
private readonly IItemQueryHelpers _queryHelpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SqlSearchProvider"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dbProvider">The database context factory.</param>
|
||||||
|
/// <param name="itemTypeLookup">The item type lookup.</param>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
/// <param name="userManager">The user manager.</param>
|
||||||
|
/// <param name="queryHelpers">The shared item query helpers.</param>
|
||||||
|
public SqlSearchProvider(
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
|
IItemTypeLookup itemTypeLookup,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IUserManager userManager,
|
||||||
|
IItemQueryHelpers queryHelpers)
|
||||||
|
{
|
||||||
|
_dbProvider = dbProvider;
|
||||||
|
_itemTypeLookup = itemTypeLookup;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_userManager = userManager;
|
||||||
|
_queryHelpers = queryHelpers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string Name => "Database";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public MetadataPluginType Type => MetadataPluginType.SearchProvider;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public int Priority => 100; // Low priority - runs as fallback
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool CanSearch(SearchProviderQuery query)
|
||||||
|
{
|
||||||
|
// SQL search can always handle any query
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<SearchResult>> SearchAsync(SearchProviderQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||||
|
|
||||||
|
var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics();
|
||||||
|
if (string.IsNullOrEmpty(rawSearchTerm))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleanSearchTerm = rawSearchTerm.GetCleanValue();
|
||||||
|
if (string.IsNullOrEmpty(cleanSearchTerm))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleanPrefix = cleanSearchTerm + " ";
|
||||||
|
// OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName,
|
||||||
|
// so match it via a case-insensitive LIKE rather than a per-row case conversion
|
||||||
|
// that may not translate to SQL on every provider.
|
||||||
|
var likeOriginal = $"%{rawSearchTerm}%";
|
||||||
|
var limit = query.Limit ?? DefaultSearchLimit;
|
||||||
|
|
||||||
|
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
// Lightweight projection: select only what's needed to score and identify items.
|
||||||
|
var dbQuery = dbContext.BaseItems
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.Id != _placeholderId)
|
||||||
|
.Where(e => !e.IsVirtualItem)
|
||||||
|
.Where(e => e.CleanName!.Contains(cleanSearchTerm)
|
||||||
|
|| (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)));
|
||||||
|
|
||||||
|
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
|
||||||
|
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
|
||||||
|
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
|
||||||
|
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// directly without any per-row case conversion. Items that match only via
|
||||||
|
// OriginalTitle fall through to the Contains tier.
|
||||||
|
// Tie-break by Id for deterministic ordering so the explicit OrderBy + Take
|
||||||
|
// satisfies EF Core's row-limiting-with-OrderBy requirement.
|
||||||
|
var scored = dbQuery.Select(e => new
|
||||||
|
{
|
||||||
|
e.Id,
|
||||||
|
Score =
|
||||||
|
(e.CleanName == cleanSearchTerm) ? ExactMatchScore
|
||||||
|
: e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore
|
||||||
|
: e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore
|
||||||
|
: ContainsMatchScore
|
||||||
|
});
|
||||||
|
|
||||||
|
return await scored
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.Take(limit)
|
||||||
|
.Select(x => new SearchResult(x.Id, x.Score))
|
||||||
|
.ToArrayAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<BaseItemEntity> ApplyTypeFilter(
|
||||||
|
IQueryable<BaseItemEntity> query,
|
||||||
|
BaseItemKind[] includeItemTypes,
|
||||||
|
BaseItemKind[] excludeItemTypes)
|
||||||
|
{
|
||||||
|
if (includeItemTypes.Length > 0)
|
||||||
|
{
|
||||||
|
var includeTypeNames = MapKindsToTypeNames(includeItemTypes);
|
||||||
|
if (includeTypeNames.Count > 0)
|
||||||
|
{
|
||||||
|
query = query.Where(e => includeTypeNames.Contains(e.Type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (excludeItemTypes.Length > 0)
|
||||||
|
{
|
||||||
|
var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes);
|
||||||
|
if (excludeTypeNames.Count > 0)
|
||||||
|
{
|
||||||
|
query = query.Where(e => !excludeTypeNames.Contains(e.Type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<BaseItemEntity> ApplyMediaTypeFilter(
|
||||||
|
IQueryable<BaseItemEntity> query,
|
||||||
|
MediaType[] mediaTypes)
|
||||||
|
{
|
||||||
|
if (mediaTypes.Length == 0)
|
||||||
|
{
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray();
|
||||||
|
return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<BaseItemEntity> ApplyParentFilter(
|
||||||
|
IQueryable<BaseItemEntity> query,
|
||||||
|
Guid? parentId)
|
||||||
|
{
|
||||||
|
if (!parentId.HasValue || parentId.Value.IsEmpty())
|
||||||
|
{
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pid = parentId.Value;
|
||||||
|
return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
|
||||||
|
JellyfinDbContext dbContext,
|
||||||
|
IQueryable<BaseItemEntity> query,
|
||||||
|
Guid? userId)
|
||||||
|
{
|
||||||
|
if (!userId.HasValue || userId.Value.IsEmpty())
|
||||||
|
{
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = _userManager.GetUserById(userId.Value);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
var accessFilter = new InternalItemsQuery(user);
|
||||||
|
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||||
|
return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> MapKindsToTypeNames(BaseItemKind[] kinds)
|
||||||
|
{
|
||||||
|
var list = new List<string>(kinds.Length);
|
||||||
|
foreach (var kind in kinds)
|
||||||
|
{
|
||||||
|
if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null)
|
||||||
|
{
|
||||||
|
list.Add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Jellyfin.Data.Enums;
|
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
|
||||||
using Jellyfin.Database.Implementations.Enums;
|
|
||||||
using Jellyfin.Extensions;
|
|
||||||
using MediaBrowser.Controller.Dto;
|
|
||||||
using MediaBrowser.Controller.Entities;
|
|
||||||
using MediaBrowser.Controller.Library;
|
|
||||||
using MediaBrowser.Model.Querying;
|
|
||||||
using MediaBrowser.Model.Search;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Library
|
|
||||||
{
|
|
||||||
public class SearchEngine : ISearchEngine
|
|
||||||
{
|
|
||||||
private readonly ILibraryManager _libraryManager;
|
|
||||||
private readonly IUserManager _userManager;
|
|
||||||
|
|
||||||
public SearchEngine(ILibraryManager libraryManager, IUserManager userManager)
|
|
||||||
{
|
|
||||||
_libraryManager = libraryManager;
|
|
||||||
_userManager = userManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
public QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query)
|
|
||||||
{
|
|
||||||
User? user = null;
|
|
||||||
if (!query.UserId.IsEmpty())
|
|
||||||
{
|
|
||||||
user = _userManager.GetUserById(query.UserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var results = GetSearchHints(query, user);
|
|
||||||
var totalRecordCount = results.Count;
|
|
||||||
|
|
||||||
if (query.StartIndex.HasValue)
|
|
||||||
{
|
|
||||||
results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.Limit.HasValue)
|
|
||||||
{
|
|
||||||
results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new QueryResult<SearchHintInfo>(
|
|
||||||
query.StartIndex,
|
|
||||||
totalRecordCount,
|
|
||||||
results);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
|
|
||||||
{
|
|
||||||
if (!list.Contains(value))
|
|
||||||
{
|
|
||||||
list.Add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the search hints.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query">The query.</param>
|
|
||||||
/// <param name="user">The user.</param>
|
|
||||||
/// <returns>IEnumerable{SearchHintResult}.</returns>
|
|
||||||
/// <exception cref="ArgumentException"><c>query.SearchTerm</c> is <c>null</c> or empty.</exception>
|
|
||||||
private List<SearchHintInfo> GetSearchHints(SearchQuery query, User? user)
|
|
||||||
{
|
|
||||||
var searchTerm = query.SearchTerm;
|
|
||||||
|
|
||||||
ArgumentException.ThrowIfNullOrEmpty(searchTerm);
|
|
||||||
|
|
||||||
searchTerm = searchTerm.Trim().RemoveDiacritics();
|
|
||||||
|
|
||||||
var excludeItemTypes = query.ExcludeItemTypes.ToList();
|
|
||||||
var includeItemTypes = query.IncludeItemTypes.ToList();
|
|
||||||
|
|
||||||
excludeItemTypes.Add(BaseItemKind.Year);
|
|
||||||
excludeItemTypes.Add(BaseItemKind.Folder);
|
|
||||||
|
|
||||||
if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre)))
|
|
||||||
{
|
|
||||||
if (!query.IncludeMedia)
|
|
||||||
{
|
|
||||||
AddIfMissing(includeItemTypes, BaseItemKind.Genre);
|
|
||||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
|
|
||||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person)))
|
|
||||||
{
|
|
||||||
if (!query.IncludeMedia)
|
|
||||||
{
|
|
||||||
AddIfMissing(includeItemTypes, BaseItemKind.Person);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AddIfMissing(excludeItemTypes, BaseItemKind.Person);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio)))
|
|
||||||
{
|
|
||||||
if (!query.IncludeMedia)
|
|
||||||
{
|
|
||||||
AddIfMissing(includeItemTypes, BaseItemKind.Studio);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist)))
|
|
||||||
{
|
|
||||||
if (!query.IncludeMedia)
|
|
||||||
{
|
|
||||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
|
|
||||||
}
|
|
||||||
|
|
||||||
AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder);
|
|
||||||
AddIfMissing(excludeItemTypes, BaseItemKind.Folder);
|
|
||||||
var mediaTypes = query.MediaTypes.ToList();
|
|
||||||
|
|
||||||
if (includeItemTypes.Count > 0)
|
|
||||||
{
|
|
||||||
excludeItemTypes.Clear();
|
|
||||||
mediaTypes.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchQuery = new InternalItemsQuery(user)
|
|
||||||
{
|
|
||||||
SearchTerm = searchTerm,
|
|
||||||
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
|
||||||
IncludeItemTypes = includeItemTypes.ToArray(),
|
|
||||||
Limit = query.Limit,
|
|
||||||
IncludeItemsByName = !query.ParentId.HasValue,
|
|
||||||
ParentId = query.ParentId ?? Guid.Empty,
|
|
||||||
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
|
|
||||||
Recursive = true,
|
|
||||||
|
|
||||||
IsKids = query.IsKids,
|
|
||||||
IsMovie = query.IsMovie,
|
|
||||||
IsNews = query.IsNews,
|
|
||||||
IsSeries = query.IsSeries,
|
|
||||||
IsSports = query.IsSports,
|
|
||||||
MediaTypes = mediaTypes.ToArray(),
|
|
||||||
|
|
||||||
DtoOptions = new DtoOptions
|
|
||||||
{
|
|
||||||
Fields = new ItemFields[]
|
|
||||||
{
|
|
||||||
ItemFields.AirTime,
|
|
||||||
ItemFields.DateCreated,
|
|
||||||
ItemFields.ChannelInfo,
|
|
||||||
ItemFields.ParentId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<BaseItem> mediaItems;
|
|
||||||
|
|
||||||
if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
|
|
||||||
{
|
|
||||||
if (!searchQuery.ParentId.IsEmpty())
|
|
||||||
{
|
|
||||||
searchQuery.AncestorIds = [searchQuery.ParentId];
|
|
||||||
searchQuery.ParentId = Guid.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
searchQuery.IncludeItemsByName = true;
|
|
||||||
searchQuery.IncludeItemTypes = Array.Empty<BaseItemKind>();
|
|
||||||
mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mediaItems = _libraryManager.GetItemList(searchQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
return mediaItems.Select(i => new SearchHintInfo
|
|
||||||
{
|
|
||||||
Item = i
|
|
||||||
}).ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Audio;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides similar items for audio tracks.
|
||||||
|
/// </summary>
|
||||||
|
public class AudioSimilarItemsProvider : ILocalSimilarItemsProvider<Audio>
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AudioSimilarItemsProvider"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
public AudioSimilarItemsProvider(ILibraryManager libraryManager)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string Name => "Local Genre/Tag";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Audio item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var internalQuery = new InternalItemsQuery(query.User)
|
||||||
|
{
|
||||||
|
Genres = item.Genres,
|
||||||
|
Tags = item.Tags,
|
||||||
|
Limit = query.Limit,
|
||||||
|
DtoOptions = query.DtoOptions ?? new DtoOptions(),
|
||||||
|
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||||
|
ExcludeArtistIds = [.. query.ExcludeArtistIds],
|
||||||
|
IncludeItemTypes = [BaseItemKind.Audio],
|
||||||
|
EnableGroupByMetadataKey = false,
|
||||||
|
EnableTotalRecordCount = true,
|
||||||
|
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult(_libraryManager.GetItemList(internalQuery));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.LiveTv;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides similar items for Live TV programs.
|
||||||
|
/// </summary>
|
||||||
|
public class LiveTvProgramSimilarItemsProvider : ILocalSimilarItemsProvider<LiveTvProgram>
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="LiveTvProgramSimilarItemsProvider"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||||
|
public LiveTvProgramSimilarItemsProvider(
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IServerConfigurationManager serverConfigurationManager)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_serverConfigurationManager = serverConfigurationManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string Name => "Local Genre/Tag";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(LiveTvProgram item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
BaseItemKind[] includeItemTypes;
|
||||||
|
bool enableGroupByMetadataKey;
|
||||||
|
bool enableTotalRecordCount;
|
||||||
|
|
||||||
|
if (item.IsMovie)
|
||||||
|
{
|
||||||
|
// Movie-like program
|
||||||
|
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||||
|
|
||||||
|
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||||
|
{
|
||||||
|
itemTypes.Add(BaseItemKind.Trailer);
|
||||||
|
itemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||||
|
}
|
||||||
|
|
||||||
|
includeItemTypes = [.. itemTypes];
|
||||||
|
enableGroupByMetadataKey = true;
|
||||||
|
enableTotalRecordCount = false;
|
||||||
|
}
|
||||||
|
else if (item.IsSeries)
|
||||||
|
{
|
||||||
|
// Series-like program
|
||||||
|
includeItemTypes = [BaseItemKind.Series];
|
||||||
|
enableGroupByMetadataKey = false;
|
||||||
|
enableTotalRecordCount = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Default - match same type
|
||||||
|
includeItemTypes = [item.GetBaseItemKind()];
|
||||||
|
enableGroupByMetadataKey = false;
|
||||||
|
enableTotalRecordCount = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var internalQuery = new InternalItemsQuery(query.User)
|
||||||
|
{
|
||||||
|
Genres = item.Genres,
|
||||||
|
Tags = item.Tags,
|
||||||
|
Limit = query.Limit,
|
||||||
|
DtoOptions = query.DtoOptions ?? new DtoOptions(),
|
||||||
|
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||||
|
IncludeItemTypes = includeItemTypes,
|
||||||
|
EnableGroupByMetadataKey = enableGroupByMetadataKey,
|
||||||
|
EnableTotalRecordCount = enableTotalRecordCount,
|
||||||
|
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult(_libraryManager.GetItemList(internalQuery));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Persistence;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides similar items for movies and trailers using weighted scoring.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>, IBatchLocalSimilarItemsProvider
|
||||||
|
{
|
||||||
|
private const int GenreWeight = 10;
|
||||||
|
private const int TagWeight = 5;
|
||||||
|
private const int StudioWeight = 5;
|
||||||
|
private const int DirectorWeight = 50;
|
||||||
|
private const int ActorWeight = 15;
|
||||||
|
|
||||||
|
// Caps the batch fan-out so downstream IN-list sizes (per-source scores, accessible-id
|
||||||
|
// load, navigation includes) stay bounded regardless of caller input.
|
||||||
|
private const int MaxBatchSourceItems = 64;
|
||||||
|
|
||||||
|
private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions =
|
||||||
|
[
|
||||||
|
(ItemValueType.Genre, GenreWeight),
|
||||||
|
(ItemValueType.Tags, TagWeight),
|
||||||
|
(ItemValueType.Studios, StudioWeight)
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, int> _personTypeWeights = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[nameof(PersonKind.Director)] = DirectorWeight,
|
||||||
|
[nameof(PersonKind.Actor)] = ActorWeight,
|
||||||
|
[nameof(PersonKind.GuestStar)] = ActorWeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys];
|
||||||
|
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||||
|
private readonly IItemQueryHelpers _queryHelpers;
|
||||||
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dbProvider">The database context factory.</param>
|
||||||
|
/// <param name="queryHelpers">The shared query helpers.</param>
|
||||||
|
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
public MovieSimilarItemsProvider(
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
|
IItemQueryHelpers queryHelpers,
|
||||||
|
IServerConfigurationManager serverConfigurationManager,
|
||||||
|
ILibraryManager libraryManager)
|
||||||
|
{
|
||||||
|
_dbProvider = dbProvider;
|
||||||
|
_queryHelpers = queryHelpers;
|
||||||
|
_serverConfigurationManager = serverConfigurationManager;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string Name => "Local Genre/Tag";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
|
||||||
|
return results.TryGetValue(item.Id, out var items) ? items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
|
||||||
|
return results.TryGetValue(item.Id, out var items) ? items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ILocalSimilarItemsProvider.Supports(Type itemType)
|
||||||
|
=> typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync(BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||||
|
=> item switch
|
||||||
|
{
|
||||||
|
Movie movie => GetSimilarItemsAsync(movie, query, cancellationToken),
|
||||||
|
Trailer trailer => GetSimilarItemsAsync(trailer, query, cancellationToken),
|
||||||
|
_ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item))
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync(
|
||||||
|
IReadOnlyList<BaseItemDto> sourceItems,
|
||||||
|
SimilarItemsQuery query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||||
|
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||||
|
{
|
||||||
|
includeItemTypes.Add(BaseItemKind.Trailer);
|
||||||
|
includeItemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||||
|
}
|
||||||
|
|
||||||
|
var limit = query.Limit ?? 50;
|
||||||
|
var dtoOptions = query.DtoOptions ?? new DtoOptions();
|
||||||
|
|
||||||
|
if (sourceItems.Count > MaxBatchSourceItems)
|
||||||
|
{
|
||||||
|
sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (context.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
// Phase 1: Score all candidates per source item
|
||||||
|
var sourceIds = sourceItems.Select(i => i.Id).ToList();
|
||||||
|
var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var allCandidateIds = new HashSet<Guid>();
|
||||||
|
foreach (var (_, scores) in perSourceScores)
|
||||||
|
{
|
||||||
|
allCandidateIds.UnionWith(
|
||||||
|
scores.OrderByDescending(kvp => kvp.Value)
|
||||||
|
.Take(limit * 3)
|
||||||
|
.Select(kvp => kvp.Key));
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>();
|
||||||
|
if (allCandidateIds.Count == 0)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: One access filter for all candidates
|
||||||
|
var filter = new InternalItemsQuery(query.User)
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [.. includeItemTypes],
|
||||||
|
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||||
|
DtoOptions = dtoOptions,
|
||||||
|
EnableGroupByMetadataKey = true,
|
||||||
|
EnableTotalRecordCount = false,
|
||||||
|
IsMovie = true,
|
||||||
|
IsPlayed = false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.User is not null)
|
||||||
|
{
|
||||||
|
_libraryManager.ConfigureUserAccess(filter, query.User);
|
||||||
|
}
|
||||||
|
|
||||||
|
_queryHelpers.PrepareFilterQuery(filter);
|
||||||
|
var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
|
||||||
|
baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
|
||||||
|
|
||||||
|
var allCandidateIdsList = allCandidateIds.ToList();
|
||||||
|
var accessibleItems = await baseQuery
|
||||||
|
.WhereOneOrMany(allCandidateIdsList, e => e.Id)
|
||||||
|
.Select(e => new { e.Id, e.PresentationUniqueKey })
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey
|
||||||
|
var allOrderedIds = new HashSet<Guid>();
|
||||||
|
var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>();
|
||||||
|
|
||||||
|
foreach (var item in sourceItems)
|
||||||
|
{
|
||||||
|
if (!perSourceScores.TryGetValue(item.Id, out var scores))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderedIds = accessibleItems
|
||||||
|
.Where(x => scores.ContainsKey(x.Id))
|
||||||
|
.OrderByDescending(x => scores.GetValueOrDefault(x.Id))
|
||||||
|
.DistinctBy(x => x.PresentationUniqueKey)
|
||||||
|
.Take(limit)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (orderedIds.Count > 0)
|
||||||
|
{
|
||||||
|
perSourceOrderedIds[item.Id] = orderedIds;
|
||||||
|
allOrderedIds.UnionWith(orderedIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allOrderedIds.Count == 0)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4: One entity load for all results
|
||||||
|
var allOrderedIdsList = allOrderedIds.ToList();
|
||||||
|
var entities = await _queryHelpers.ApplyNavigations(
|
||||||
|
context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id),
|
||||||
|
filter)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var entitiesById = entities
|
||||||
|
.Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization))
|
||||||
|
.Where(dto => dto is not null)
|
||||||
|
.ToDictionary(i => i!.Id);
|
||||||
|
|
||||||
|
// Phase 5: Split by source, preserving score order
|
||||||
|
foreach (var (sourceId, orderedIds) in perSourceOrderedIds)
|
||||||
|
{
|
||||||
|
var items = orderedIds
|
||||||
|
.Where(entitiesById.ContainsKey)
|
||||||
|
.Select(id => entitiesById[id]!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (items.Count > 0)
|
||||||
|
{
|
||||||
|
result[sourceId] = items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<Guid, Dictionary<Guid, int>>> ComputeBatchScoresAsync(List<Guid> sourceIds, JellyfinDbContext context, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<Guid, Dictionary<Guid, int>>();
|
||||||
|
foreach (var id in sourceIds)
|
||||||
|
{
|
||||||
|
result[id] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (valueType, weight) in _itemValueDimensions)
|
||||||
|
{
|
||||||
|
var sourceRows = await context.ItemValuesMap.AsNoTracking()
|
||||||
|
.Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType)
|
||||||
|
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet());
|
||||||
|
var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList();
|
||||||
|
if (allKeys.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateRows = await context.ItemValuesMap.AsNoTracking()
|
||||||
|
.Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
|
||||||
|
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
|
||||||
|
ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
var personSourceRows = await context.PeopleBaseItemMap.AsNoTracking()
|
||||||
|
.Where(m => sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType))
|
||||||
|
.Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType })
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (personSourceRows.Count > 0)
|
||||||
|
{
|
||||||
|
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
|
||||||
|
.Where(m => context.PeopleBaseItemMap
|
||||||
|
.Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
|
||||||
|
.Select(s => s.PeopleId)
|
||||||
|
.Contains(m.PeopleId))
|
||||||
|
.Select(m => new { m.ItemId, m.PeopleId })
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var personToCandidates = personCandidateRows
|
||||||
|
.GroupBy(r => r.PeopleId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
|
||||||
|
|
||||||
|
foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!]))
|
||||||
|
{
|
||||||
|
var sourceMap = weightGroup
|
||||||
|
.GroupBy(r => r.ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet());
|
||||||
|
ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var sourceId in sourceIds)
|
||||||
|
{
|
||||||
|
var scoreMap = result[sourceId];
|
||||||
|
scoreMap.Remove(sourceId);
|
||||||
|
if (scoreMap.Count == 0)
|
||||||
|
{
|
||||||
|
result.Remove(sourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyDimensionScores<TKey>(
|
||||||
|
List<Guid> sourceIds,
|
||||||
|
Dictionary<Guid, HashSet<TKey>> sourceMap,
|
||||||
|
Dictionary<TKey, List<Guid>> keyToCandidates,
|
||||||
|
int weight,
|
||||||
|
Dictionary<Guid, Dictionary<Guid, int>> result)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
foreach (var sourceId in sourceIds)
|
||||||
|
{
|
||||||
|
if (!sourceMap.TryGetValue(sourceId, out var sourceKeys))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scoreMap = result[sourceId];
|
||||||
|
foreach (var key in sourceKeys)
|
||||||
|
{
|
||||||
|
if (!keyToCandidates.TryGetValue(key, out var candidates))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var candidateId in candidates)
|
||||||
|
{
|
||||||
|
scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Audio;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides similar items for music albums.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicAlbumSimilarItemsProvider : ILocalSimilarItemsProvider<MusicAlbum>
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="MusicAlbumSimilarItemsProvider"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
public MusicAlbumSimilarItemsProvider(ILibraryManager libraryManager)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string Name => "Local Genre/Tag";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicAlbum item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var internalQuery = new InternalItemsQuery(query.User)
|
||||||
|
{
|
||||||
|
Genres = item.Genres,
|
||||||
|
Tags = item.Tags,
|
||||||
|
Limit = query.Limit,
|
||||||
|
DtoOptions = query.DtoOptions ?? new DtoOptions(),
|
||||||
|
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||||
|
ExcludeArtistIds = [.. query.ExcludeArtistIds],
|
||||||
|
IncludeItemTypes = [BaseItemKind.MusicAlbum],
|
||||||
|
EnableGroupByMetadataKey = false,
|
||||||
|
EnableTotalRecordCount = true,
|
||||||
|
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult(_libraryManager.GetItemList(internalQuery));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Audio;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides similar items for music artists.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicArtistSimilarItemsProvider : ILocalSimilarItemsProvider<MusicArtist>
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="MusicArtistSimilarItemsProvider"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
public MusicArtistSimilarItemsProvider(ILibraryManager libraryManager)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string Name => "Local Genre/Tag";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicArtist item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var internalQuery = new InternalItemsQuery(query.User)
|
||||||
|
{
|
||||||
|
Genres = item.Genres,
|
||||||
|
Tags = item.Tags,
|
||||||
|
Limit = query.Limit,
|
||||||
|
DtoOptions = query.DtoOptions ?? new DtoOptions(),
|
||||||
|
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||||
|
ExcludeArtistIds = [.. query.ExcludeArtistIds],
|
||||||
|
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
||||||
|
EnableGroupByMetadataKey = false,
|
||||||
|
EnableTotalRecordCount = true,
|
||||||
|
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult(_libraryManager.GetItemList(internalQuery));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides similar items for TV series.
|
||||||
|
/// </summary>
|
||||||
|
public class SeriesSimilarItemsProvider : ILocalSimilarItemsProvider<Series>
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SeriesSimilarItemsProvider"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
public SeriesSimilarItemsProvider(ILibraryManager libraryManager)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string Name => "Local Genre/Tag";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Series item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var internalQuery = new InternalItemsQuery(query.User)
|
||||||
|
{
|
||||||
|
Genres = item.Genres,
|
||||||
|
Tags = item.Tags,
|
||||||
|
Limit = query.Limit,
|
||||||
|
DtoOptions = query.DtoOptions ?? new DtoOptions(),
|
||||||
|
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||||
|
IncludeItemTypes = [BaseItemKind.Series],
|
||||||
|
EnableGroupByMetadataKey = false,
|
||||||
|
EnableTotalRecordCount = true,
|
||||||
|
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult(_libraryManager.GetItemList(internalQuery));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using Jellyfin.Extensions.Json;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
|
using MediaBrowser.Model.Querying;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages similar items providers and orchestrates similar items operations.
|
||||||
|
/// </summary>
|
||||||
|
public class SimilarItemsManager : ISimilarItemsManager
|
||||||
|
{
|
||||||
|
private readonly ILogger<SimilarItemsManager> _logger;
|
||||||
|
private readonly IServerApplicationPaths _appPaths;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IFileSystem _fileSystem;
|
||||||
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
|
private ISimilarItemsProvider[] _similarItemsProviders = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SimilarItemsManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="appPaths">The server application paths.</param>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
/// <param name="fileSystem">The file system.</param>
|
||||||
|
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||||
|
public SimilarItemsManager(
|
||||||
|
ILogger<SimilarItemsManager> logger,
|
||||||
|
IServerApplicationPaths appPaths,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IFileSystem fileSystem,
|
||||||
|
IServerConfigurationManager serverConfigurationManager)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_appPaths = appPaths;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_fileSystem = fileSystem;
|
||||||
|
_serverConfigurationManager = serverConfigurationManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void AddParts(IEnumerable<ISimilarItemsProvider> providers)
|
||||||
|
{
|
||||||
|
_similarItemsProviders = providers.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>()
|
||||||
|
where T : BaseItem
|
||||||
|
{
|
||||||
|
var itemType = typeof(T);
|
||||||
|
return _similarItemsProviders
|
||||||
|
.Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType))
|
||||||
|
|| (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(
|
||||||
|
BaseItem item,
|
||||||
|
IReadOnlyList<Guid> excludeArtistIds,
|
||||||
|
User? user,
|
||||||
|
DtoOptions dtoOptions,
|
||||||
|
int? limit,
|
||||||
|
LibraryOptions? libraryOptions,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
ArgumentNullException.ThrowIfNull(excludeArtistIds);
|
||||||
|
|
||||||
|
var itemType = item.GetType();
|
||||||
|
var requestedLimit = limit ?? 50;
|
||||||
|
var itemKind = item.GetBaseItemKind();
|
||||||
|
|
||||||
|
// Ensure ProviderIds is included in DtoOptions for matching remote provider responses
|
||||||
|
if (!dtoOptions.Fields.Contains(ItemFields.ProviderIds))
|
||||||
|
{
|
||||||
|
dtoOptions.Fields = dtoOptions.Fields.Concat([ItemFields.ProviderIds]).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local providers are always enabled. Remote providers must be explicitly enabled.
|
||||||
|
var localProviders = _similarItemsProviders
|
||||||
|
.OfType<ILocalSimilarItemsProvider>()
|
||||||
|
.Where(p => p.Supports(itemType))
|
||||||
|
.ToList();
|
||||||
|
var remoteProviders = _similarItemsProviders
|
||||||
|
.OfType<IRemoteSimilarItemsProvider>()
|
||||||
|
.Where(p => p.Supports(itemType));
|
||||||
|
var matchingProviders = new List<ISimilarItemsProvider>(localProviders);
|
||||||
|
|
||||||
|
var typeOptions = libraryOptions?.GetTypeOptions(itemType.Name);
|
||||||
|
if (typeOptions?.SimilarItemProviders?.Length > 0)
|
||||||
|
{
|
||||||
|
matchingProviders.AddRange(remoteProviders
|
||||||
|
.Where(p => typeOptions.SimilarItemProviders.Contains(p.Name, StringComparer.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderConfig = typeOptions?.SimilarItemProviderOrder is { Length: > 0 } order
|
||||||
|
? order
|
||||||
|
: typeOptions?.SimilarItemProviders;
|
||||||
|
var orderedProviders = matchingProviders
|
||||||
|
.OrderBy(p => GetConfiguredSimilarProviderOrder(orderConfig, p.Name))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var allResults = new List<(BaseItem Item, float Score)>();
|
||||||
|
var excludeIds = new HashSet<Guid> { item.Id };
|
||||||
|
var excludeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() };
|
||||||
|
foreach (var (providerOrder, provider) in orderedProviders.Index())
|
||||||
|
{
|
||||||
|
if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (provider is ILocalSimilarItemsProvider localProvider)
|
||||||
|
{
|
||||||
|
var query = new SimilarItemsQuery
|
||||||
|
{
|
||||||
|
User = user,
|
||||||
|
Limit = requestedLimit - allResults.Count,
|
||||||
|
DtoOptions = dtoOptions,
|
||||||
|
ExcludeItemIds = [.. excludeIds],
|
||||||
|
ExcludeArtistIds = excludeArtistIds
|
||||||
|
};
|
||||||
|
|
||||||
|
var items = await localProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var (position, resultItem) in items.Index())
|
||||||
|
{
|
||||||
|
var isNewId = excludeIds.Add(resultItem.Id);
|
||||||
|
var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey());
|
||||||
|
if (isNewId && isNewKey)
|
||||||
|
{
|
||||||
|
var score = CalculateScore(null, providerOrder, position);
|
||||||
|
allResults.Add((resultItem, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (provider is IRemoteSimilarItemsProvider remoteProvider)
|
||||||
|
{
|
||||||
|
var cachePath = GetSimilarItemsCachePath(provider.Name, itemType.Name, item.Id);
|
||||||
|
|
||||||
|
var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (cachedReferences is not null)
|
||||||
|
{
|
||||||
|
var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
|
||||||
|
allResults.AddRange(resolvedItems);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = new SimilarItemsQuery
|
||||||
|
{
|
||||||
|
User = user,
|
||||||
|
Limit = requestedLimit - allResults.Count,
|
||||||
|
DtoOptions = dtoOptions,
|
||||||
|
ExcludeItemIds = [.. excludeIds],
|
||||||
|
ExcludeArtistIds = excludeArtistIds
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect references in batches and resolve against local library.
|
||||||
|
// Stop fetching once we have enough resolved local items.
|
||||||
|
const int BatchSize = 20;
|
||||||
|
var remaining = requestedLimit - allResults.Count;
|
||||||
|
var collectedReferences = new List<SimilarItemReference>();
|
||||||
|
var pendingBatch = new List<SimilarItemReference>();
|
||||||
|
|
||||||
|
await foreach (var reference in remoteProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
collectedReferences.Add(reference);
|
||||||
|
pendingBatch.Add(reference);
|
||||||
|
|
||||||
|
if (pendingBatch.Count >= BatchSize)
|
||||||
|
{
|
||||||
|
var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
|
||||||
|
allResults.AddRange(resolvedItems);
|
||||||
|
remaining -= resolvedItems.Count;
|
||||||
|
pendingBatch.Clear();
|
||||||
|
|
||||||
|
if (remaining <= 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve any remaining references in the last partial batch
|
||||||
|
if (pendingBatch.Count > 0)
|
||||||
|
{
|
||||||
|
var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
|
||||||
|
allResults.AddRange(resolvedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collectedReferences.Count > 0 && provider.CacheDuration is not null)
|
||||||
|
{
|
||||||
|
await SaveSimilarItemsCacheAsync(cachePath, collectedReferences, provider.CacheDuration.Value, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Similar items provider {ProviderName} failed for item {ItemId}", provider.Name, item.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allResults
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.Select(x => x.Item)
|
||||||
|
.Take(requestedLimit)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync(
|
||||||
|
User? user,
|
||||||
|
Guid parentId,
|
||||||
|
int categoryLimit,
|
||||||
|
int itemLimit,
|
||||||
|
DtoOptions dtoOptions,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dtoOptions);
|
||||||
|
|
||||||
|
var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [BaseItemKind.Movie],
|
||||||
|
OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)],
|
||||||
|
Limit = 7,
|
||||||
|
ParentId = parentId,
|
||||||
|
Recursive = true,
|
||||||
|
IsPlayed = true,
|
||||||
|
EnableGroupByMetadataKey = true,
|
||||||
|
DtoOptions = dtoOptions
|
||||||
|
});
|
||||||
|
|
||||||
|
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||||
|
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||||
|
{
|
||||||
|
itemTypes.Add(BaseItemKind.Trailer);
|
||||||
|
itemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||||
|
}
|
||||||
|
|
||||||
|
var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||||
|
{
|
||||||
|
IncludeItemTypes = itemTypes.ToArray(),
|
||||||
|
IsMovie = true,
|
||||||
|
OrderBy = [(ItemSortBy.Random, SortOrder.Descending)],
|
||||||
|
Limit = 10,
|
||||||
|
IsFavoriteOrLiked = true,
|
||||||
|
ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
|
||||||
|
EnableGroupByMetadataKey = true,
|
||||||
|
ParentId = parentId,
|
||||||
|
Recursive = true,
|
||||||
|
DtoOptions = dtoOptions
|
||||||
|
});
|
||||||
|
|
||||||
|
var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
|
||||||
|
var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]);
|
||||||
|
var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]);
|
||||||
|
|
||||||
|
// Cap baseline items to categoryLimit - the round-robin can't use more categories than that.
|
||||||
|
var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit
|
||||||
|
? recentlyPlayedMovies.Take(categoryLimit).ToList()
|
||||||
|
: recentlyPlayedMovies;
|
||||||
|
var likedBaseline = likedMovies.Count > categoryLimit
|
||||||
|
? likedMovies.Take(categoryLimit).ToList()
|
||||||
|
: likedMovies;
|
||||||
|
|
||||||
|
var batchQuery = new SimilarItemsQuery
|
||||||
|
{
|
||||||
|
User = user,
|
||||||
|
Limit = itemLimit,
|
||||||
|
DtoOptions = dtoOptions
|
||||||
|
};
|
||||||
|
|
||||||
|
var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync(
|
||||||
|
recentlyPlayedBaseline,
|
||||||
|
RecommendationType.SimilarToRecentlyPlayed,
|
||||||
|
batchQuery,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var similarToLiked = await GetSimilarItemsRecommendationsAsync(
|
||||||
|
likedBaseline,
|
||||||
|
RecommendationType.SimilarToLikedItem,
|
||||||
|
batchQuery,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes);
|
||||||
|
var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes);
|
||||||
|
|
||||||
|
// Use a single enumerator per list, listed twice so MoveNext advances it
|
||||||
|
// twice per round-robin pass (giving these categories double weight).
|
||||||
|
// IMPORTANT: Declare as IEnumerator<T> to box the List<T>.Enumerator struct once;
|
||||||
|
// using var would box separately per list insertion, creating independent copies.
|
||||||
|
IEnumerator<SimilarItemsRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator();
|
||||||
|
IEnumerator<SimilarItemsRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator();
|
||||||
|
|
||||||
|
var categoryTypes = new List<IEnumerator<SimilarItemsRecommendation>>
|
||||||
|
{
|
||||||
|
similarToRecentlyPlayedEnum,
|
||||||
|
similarToRecentlyPlayedEnum,
|
||||||
|
similarToLikedEnum,
|
||||||
|
similarToLikedEnum,
|
||||||
|
hasDirectorFromRecentlyPlayed.GetEnumerator(),
|
||||||
|
hasActorFromRecentlyPlayed.GetEnumerator()
|
||||||
|
};
|
||||||
|
|
||||||
|
var categories = new List<SimilarItemsRecommendation>();
|
||||||
|
while (categories.Count < categoryLimit)
|
||||||
|
{
|
||||||
|
var allEmpty = true;
|
||||||
|
foreach (var category in categoryTypes)
|
||||||
|
{
|
||||||
|
if (category.MoveNext())
|
||||||
|
{
|
||||||
|
categories.Add(category.Current);
|
||||||
|
allEmpty = false;
|
||||||
|
|
||||||
|
if (categories.Count >= categoryLimit)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allEmpty)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. categories.OrderBy(i => i.RecommendationType)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync(
|
||||||
|
IReadOnlyList<BaseItem> baselineItems,
|
||||||
|
RecommendationType recommendationType,
|
||||||
|
SimilarItemsQuery query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var batchProvider = _similarItemsProviders
|
||||||
|
.OfType<IBatchLocalSimilarItemsProvider>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (batchProvider is null || baselineItems.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
|
||||||
|
foreach (var baseline in baselineItems)
|
||||||
|
{
|
||||||
|
if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0)
|
||||||
|
{
|
||||||
|
recommendations.Add(new SimilarItemsRecommendation
|
||||||
|
{
|
||||||
|
BaselineItemName = baseline.Name,
|
||||||
|
CategoryId = baseline.Id,
|
||||||
|
RecommendationType = recommendationType,
|
||||||
|
Items = similar
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return recommendations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<SimilarItemsRecommendation> GetPersonRecommendations(
|
||||||
|
User? user,
|
||||||
|
IReadOnlyList<string> names,
|
||||||
|
int itemLimit,
|
||||||
|
DtoOptions dtoOptions,
|
||||||
|
RecommendationType type,
|
||||||
|
IReadOnlyList<BaseItemKind> itemTypes)
|
||||||
|
{
|
||||||
|
var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed
|
||||||
|
? [PersonType.Director]
|
||||||
|
: Array.Empty<string>();
|
||||||
|
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||||
|
{
|
||||||
|
Person = name,
|
||||||
|
Limit = itemLimit + 2,
|
||||||
|
PersonTypes = personTypes,
|
||||||
|
IncludeItemTypes = itemTypes.ToArray(),
|
||||||
|
IsMovie = true,
|
||||||
|
IsPlayed = false,
|
||||||
|
EnableGroupByMetadataKey = true,
|
||||||
|
DtoOptions = dtoOptions
|
||||||
|
})
|
||||||
|
.DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
|
||||||
|
.Take(itemLimit)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (items.Count > 0)
|
||||||
|
{
|
||||||
|
yield return new SimilarItemsRecommendation
|
||||||
|
{
|
||||||
|
BaselineItemName = name,
|
||||||
|
CategoryId = name.GetMD5(),
|
||||||
|
RecommendationType = type,
|
||||||
|
Items = items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> personTypes)
|
||||||
|
{
|
||||||
|
var itemIds = items.Select(i => i.Id).ToArray();
|
||||||
|
return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes)
|
||||||
|
.Values
|
||||||
|
.SelectMany(names => names)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
|
||||||
|
IReadOnlyList<SimilarItemReference> references,
|
||||||
|
int providerOrder,
|
||||||
|
User? user,
|
||||||
|
DtoOptions dtoOptions,
|
||||||
|
BaseItemKind itemKind,
|
||||||
|
HashSet<Guid> excludeIds,
|
||||||
|
HashSet<string> excludeKeys)
|
||||||
|
{
|
||||||
|
if (references.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedByKey = new Dictionary<string, (BaseItem Item, float Score)>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance);
|
||||||
|
|
||||||
|
foreach (var (position, match) in references.Index())
|
||||||
|
{
|
||||||
|
var lookupKey = (match.ProviderName, match.ProviderId);
|
||||||
|
if (!providerLookup.TryGetValue(lookupKey, out var existing))
|
||||||
|
{
|
||||||
|
providerLookup[lookupKey] = (match.Score, position);
|
||||||
|
}
|
||||||
|
else if (match.Score > existing.Score || (match.Score == existing.Score && position < existing.Position))
|
||||||
|
{
|
||||||
|
providerLookup[lookupKey] = (match.Score, position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allProviderIds = providerLookup
|
||||||
|
.GroupBy(kvp => kvp.Key.ProviderName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(x => x.Key.ProviderId).ToArray());
|
||||||
|
|
||||||
|
var query = new InternalItemsQuery(user)
|
||||||
|
{
|
||||||
|
HasAnyProviderIds = allProviderIds,
|
||||||
|
IncludeItemTypes = [itemKind],
|
||||||
|
DtoOptions = dtoOptions
|
||||||
|
};
|
||||||
|
|
||||||
|
var items = _libraryManager.GetItemList(query);
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (excludeIds.Contains(item.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var presentationKey = item.GetPresentationUniqueKey();
|
||||||
|
if (excludeKeys.Contains(presentationKey))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var providerName in allProviderIds.Keys)
|
||||||
|
{
|
||||||
|
if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo))
|
||||||
|
{
|
||||||
|
var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position);
|
||||||
|
if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score)
|
||||||
|
{
|
||||||
|
resolvedByKey[presentationKey] = (item, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (key, entry) in resolvedByKey)
|
||||||
|
{
|
||||||
|
excludeIds.Add(entry.Item.Id);
|
||||||
|
excludeKeys.Add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. resolvedByKey.Values];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float CalculateScore(float? matchScore, int providerOrder, int position)
|
||||||
|
{
|
||||||
|
// Use provider-supplied score if available, otherwise derive from position
|
||||||
|
var baseScore = matchScore ?? (1.0f - (position * 0.02f));
|
||||||
|
|
||||||
|
// Apply small boost based on provider order (higher priority providers get small bonus)
|
||||||
|
var priorityBoost = Math.Max(0, 10 - providerOrder) * 0.005f;
|
||||||
|
|
||||||
|
return Math.Clamp(baseScore + priorityBoost, 0f, 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetConfiguredSimilarProviderOrder(string[]? orderConfig, string providerName)
|
||||||
|
{
|
||||||
|
if (orderConfig is null || orderConfig.Length == 0)
|
||||||
|
{
|
||||||
|
return int.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = Array.FindIndex(orderConfig, name => string.Equals(name, providerName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return index >= 0 ? index : int.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetSimilarItemsCachePath(string providerName, string baseItemType, Guid itemId)
|
||||||
|
{
|
||||||
|
var dataPath = Path.Combine(
|
||||||
|
_appPaths.CachePath,
|
||||||
|
$"{providerName.ToLowerInvariant()}-similar-{baseItemType.ToLowerInvariant()}");
|
||||||
|
return Path.Combine(dataPath, $"{itemId.ToString("N", CultureInfo.InvariantCulture)}.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<SimilarItemReference>?> TryReadSimilarItemsCacheAsync(string cachePath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var fileInfo = _fileSystem.GetFileSystemInfo(cachePath);
|
||||||
|
if (!fileInfo.Exists || fileInfo.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stream = File.OpenRead(cachePath);
|
||||||
|
await using (stream.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (cache?.References is not null && DateTime.UtcNow < cache.ExpiresAt)
|
||||||
|
{
|
||||||
|
return cache.References;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to read similar items cache from {CachePath}", cachePath);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to parse similar items cache from {CachePath}", cachePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cacheDuration, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(cachePath);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cache = new SimilarItemsCache
|
||||||
|
{
|
||||||
|
References = references,
|
||||||
|
ExpiresAt = DateTime.UtcNow.Add(cacheDuration)
|
||||||
|
};
|
||||||
|
|
||||||
|
var stream = File.Create(cachePath);
|
||||||
|
await using (stream.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SimilarItemsCache
|
||||||
|
{
|
||||||
|
public List<SimilarItemReference>? References { get; set; }
|
||||||
|
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StringTupleComparer : IEqualityComparer<(string Key, string Value)>
|
||||||
|
{
|
||||||
|
public static readonly StringTupleComparer Instance = new();
|
||||||
|
|
||||||
|
public bool Equals((string Key, string Value) x, (string Key, string Value) y)
|
||||||
|
=> string.Equals(x.Key, y.Key, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public int GetHashCode((string Key, string Value) obj)
|
||||||
|
=> HashCode.Combine(
|
||||||
|
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key),
|
||||||
|
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ public class SplashscreenPostScanTask : ILibraryPostScanTask
|
|||||||
ImageTypes = [imageType],
|
ImageTypes = [imageType],
|
||||||
Limit = 30,
|
Limit = 30,
|
||||||
// TODO max parental rating configurable
|
// TODO max parental rating configurable
|
||||||
MaxParentalRating = new(10, null),
|
MaxParentalRating = new(13, null),
|
||||||
OrderBy =
|
OrderBy =
|
||||||
[
|
[
|
||||||
(ItemSortBy.Random, SortOrder.Ascending)
|
(ItemSortBy.Random, SortOrder.Ascending)
|
||||||
|
|||||||
@@ -177,53 +177,167 @@ namespace Emby.Server.Implementations.Library
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserItemData? GetUserData(User user, Guid itemId, List<string> keys)
|
/// <inheritdoc />
|
||||||
|
public Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
||||||
{
|
{
|
||||||
var cacheKey = GetCacheKey(user.InternalId, itemId);
|
var result = new Dictionary<Guid, UserItemData>(items.Count);
|
||||||
|
var itemsNeedingQuery = new List<(BaseItem Item, List<string> Keys)>();
|
||||||
|
|
||||||
if (_cache.TryGet(cacheKey, out var data))
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
return data;
|
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||||
}
|
if (_cache.TryGet(cacheKey, out var cachedData))
|
||||||
|
|
||||||
data = GetUserDataInternal(user.Id, itemId, keys);
|
|
||||||
|
|
||||||
if (data is null)
|
|
||||||
{
|
|
||||||
return new UserItemData()
|
|
||||||
{
|
{
|
||||||
Key = keys[0],
|
result[item.Id] = cachedData;
|
||||||
};
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
||||||
|
var userData = userDataRow is not null ? Map(userDataRow) : null;
|
||||||
|
if (userData is not null)
|
||||||
|
{
|
||||||
|
result[item.Id] = userData;
|
||||||
|
_cache.AddOrUpdate(cacheKey, userData);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var keys = item.GetUserDataKeys();
|
||||||
|
itemsNeedingQuery.Add((item, keys));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return _cache.GetOrAdd(cacheKey, _ => data);
|
if (itemsNeedingQuery.Count == 0)
|
||||||
}
|
|
||||||
|
|
||||||
private UserItemData? GetUserDataInternal(Guid userId, Guid itemId, List<string> keys)
|
|
||||||
{
|
|
||||||
if (keys.Count == 0)
|
|
||||||
{
|
{
|
||||||
return null;
|
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 way as the in-memory path.
|
||||||
|
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
|
||||||
using var context = _repository.CreateDbContext();
|
using var context = _repository.CreateDbContext();
|
||||||
var userData = context.UserData.AsNoTracking().Where(e => e.ItemId == itemId && keys.Contains(e.CustomDataKey) && e.UserId.Equals(userId)).ToArray();
|
var userDataArray = context.UserData
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.UserId.Equals(user.Id))
|
||||||
|
.WhereOneOrMany(allItemIds, e => e.ItemId)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
if (userData.Length > 0)
|
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
|
||||||
|
foreach (var (item, keys) in itemsNeedingQuery)
|
||||||
{
|
{
|
||||||
var directDataReference = userData.FirstOrDefault(e => e.CustomDataKey == itemId.ToString("N"));
|
UserItemData userData;
|
||||||
if (directDataReference is not null)
|
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
|
||||||
{
|
{
|
||||||
return Map(directDataReference);
|
userData = Map(ResolveUserDataRow(item, itemUserData)!);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
|
||||||
}
|
}
|
||||||
|
|
||||||
return Map(userData.First());
|
result[item.Id] = userData;
|
||||||
|
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||||
|
_cache.AddOrUpdate(cacheKey, userData);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new UserItemData
|
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)
|
||||||
{
|
{
|
||||||
Key = keys.Last()!
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -238,12 +352,41 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <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);
|
||||||
|
var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
||||||
|
return row is not null ? Map(row) : new UserItemData()
|
||||||
{
|
{
|
||||||
Key = item.GetUserDataKeys()[0],
|
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());
|
||||||
@@ -260,6 +403,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,5 +511,41 @@ 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();
|
||||||
|
|
||||||
|
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||||
|
if (_cache.TryGet(cacheKey, out var cached))
|
||||||
|
{
|
||||||
|
cached.AudioStreamIndex = null;
|
||||||
|
cached.SubtitleStreamIndex = null;
|
||||||
|
_cache.AddOrUpdate(cacheKey, cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var collectionFolder = folder as ICollectionFolder;
|
var collectionFolder = folder as ICollectionFolder;
|
||||||
var folderViewType = collectionFolder?.CollectionType;
|
var folderViewType = collectionFolder?.CollectionType;
|
||||||
|
|
||||||
// Playlist library requires special handling because the folder only references user playlists
|
// Playlist and BoxSet libraries require special handling because the folder only references linked items
|
||||||
if (folderViewType == CollectionType.playlists)
|
if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets)
|
||||||
{
|
{
|
||||||
var items = folder.GetItemList(new InternalItemsQuery(user)
|
var items = folder.GetItemList(new InternalItemsQuery(user)
|
||||||
{
|
{
|
||||||
@@ -138,7 +138,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
list = list.Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes).Contains(i.Id)).ToList();
|
list = list.Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes).Contains(i.Id)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList();
|
var sorted = _libraryManager.Sort(list, user, [ItemSortBy.SortName], SortOrder.Ascending).ToList();
|
||||||
var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews);
|
var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews);
|
||||||
|
|
||||||
return list
|
return list
|
||||||
@@ -205,7 +205,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var libraryItems = GetItemsForLatestItems(request.User, request, options);
|
var libraryItems = GetItemsForLatestItems(request.User, request, options);
|
||||||
|
|
||||||
var list = new List<Tuple<BaseItem, List<BaseItem>>>();
|
var list = new List<Tuple<BaseItem, List<BaseItem>>>();
|
||||||
|
var containerIndexMap = new Dictionary<Guid, int>();
|
||||||
foreach (var item in libraryItems)
|
foreach (var item in libraryItems)
|
||||||
{
|
{
|
||||||
// Only grab the index container for media
|
// Only grab the index container for media
|
||||||
@@ -213,20 +213,16 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
if (container is null)
|
if (container is null)
|
||||||
{
|
{
|
||||||
list.Add(new Tuple<BaseItem, List<BaseItem>>(null, new List<BaseItem> { item }));
|
list.Add(new Tuple<BaseItem, List<BaseItem>>(null!, new List<BaseItem> { item }));
|
||||||
|
}
|
||||||
|
else if (containerIndexMap.TryGetValue(container.Id, out var existingIndex))
|
||||||
|
{
|
||||||
|
list[existingIndex].Item2.Add(item);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var current = list.FirstOrDefault(i => i.Item1 is not null && i.Item1.Id.Equals(container.Id));
|
containerIndexMap[container.Id] = list.Count;
|
||||||
|
list.Add(new Tuple<BaseItem, List<BaseItem>>(container, new List<BaseItem> { item }));
|
||||||
if (current is not null)
|
|
||||||
{
|
|
||||||
current.Item2.Add(item);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
list.Add(new Tuple<BaseItem, List<BaseItem>>(container, new List<BaseItem> { item }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (list.Count >= request.Limit)
|
if (list.Count >= request.Limit)
|
||||||
@@ -255,7 +251,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return _channelManager.GetLatestChannelItemsInternal(
|
return _channelManager.GetLatestChannelItemsInternal(
|
||||||
new InternalItemsQuery(user)
|
new InternalItemsQuery(user)
|
||||||
{
|
{
|
||||||
ChannelIds = new[] { parentId },
|
ChannelIds = [parentId],
|
||||||
IsPlayed = request.IsPlayed,
|
IsPlayed = request.IsPlayed,
|
||||||
StartIndex = request.StartIndex,
|
StartIndex = request.StartIndex,
|
||||||
Limit = request.Limit,
|
Limit = request.Limit,
|
||||||
@@ -301,11 +297,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
if (hasCollectionType.All(i => i.CollectionType == CollectionType.movies))
|
if (hasCollectionType.All(i => i.CollectionType == CollectionType.movies))
|
||||||
{
|
{
|
||||||
includeItemTypes = new[] { BaseItemKind.Movie };
|
includeItemTypes = [BaseItemKind.Movie];
|
||||||
}
|
}
|
||||||
else if (hasCollectionType.All(i => i.CollectionType == CollectionType.tvshows))
|
else if (hasCollectionType.All(i => i.CollectionType == CollectionType.tvshows))
|
||||||
{
|
{
|
||||||
includeItemTypes = new[] { BaseItemKind.Episode };
|
includeItemTypes = [BaseItemKind.Episode];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,29 +340,29 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
|
|
||||||
var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Length == 0
|
var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Length == 0
|
||||||
? new[]
|
?
|
||||||
{
|
[
|
||||||
BaseItemKind.Person,
|
BaseItemKind.Person,
|
||||||
BaseItemKind.Studio,
|
BaseItemKind.Studio,
|
||||||
BaseItemKind.Year,
|
BaseItemKind.Year,
|
||||||
BaseItemKind.MusicGenre,
|
BaseItemKind.MusicGenre,
|
||||||
BaseItemKind.Genre
|
BaseItemKind.Genre
|
||||||
}
|
]
|
||||||
: Array.Empty<BaseItemKind>();
|
: Array.Empty<BaseItemKind>();
|
||||||
|
|
||||||
var query = new InternalItemsQuery(user)
|
var query = new InternalItemsQuery(user)
|
||||||
{
|
{
|
||||||
IncludeItemTypes = includeItemTypes,
|
IncludeItemTypes = includeItemTypes,
|
||||||
OrderBy = new[]
|
OrderBy =
|
||||||
{
|
[
|
||||||
(ItemSortBy.DateCreated, SortOrder.Descending),
|
(ItemSortBy.DateCreated, SortOrder.Descending),
|
||||||
(ItemSortBy.SortName, SortOrder.Descending),
|
(ItemSortBy.SortName, SortOrder.Descending),
|
||||||
(ItemSortBy.ProductionYear, SortOrder.Descending)
|
(ItemSortBy.ProductionYear, SortOrder.Descending)
|
||||||
},
|
],
|
||||||
IsFolder = includeItemTypes.Length == 0 ? false : null,
|
IsFolder = includeItemTypes.Length == 0 ? false : null,
|
||||||
ExcludeItemTypes = excludeItemTypes,
|
ExcludeItemTypes = excludeItemTypes,
|
||||||
IsVirtualItem = false,
|
IsVirtualItem = false,
|
||||||
Limit = limit * 5,
|
Limit = limit * 2,
|
||||||
IsPlayed = isPlayed,
|
IsPlayed = isPlayed,
|
||||||
DtoOptions = options,
|
DtoOptions = options,
|
||||||
MediaTypes = mediaTypes
|
MediaTypes = mediaTypes
|
||||||
@@ -394,6 +390,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
query.Limit = limit;
|
query.Limit = limit;
|
||||||
return _libraryManager.GetLatestItemList(query, parents, CollectionType.music);
|
return _libraryManager.GetLatestItemList(query, parents, CollectionType.music);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (collectionType == CollectionType.movies)
|
||||||
|
{
|
||||||
|
query.Limit = limit;
|
||||||
|
return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return _libraryManager.GetItemList(query, parents);
|
return _libraryManager.GetItemList(query, parents);
|
||||||
|
|||||||
@@ -50,21 +50,40 @@ public class ArtistsValidator
|
|||||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var names = _itemRepo.GetAllArtistNames();
|
var names = _itemRepo.GetAllArtistNames();
|
||||||
|
var existingArtistIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [BaseItemKind.MusicArtist]
|
||||||
|
}).ToHashSet();
|
||||||
|
|
||||||
|
var existingArtists = _libraryManager.GetArtists(names);
|
||||||
|
|
||||||
var numComplete = 0;
|
var numComplete = 0;
|
||||||
var count = names.Count;
|
var count = names.Count;
|
||||||
|
var refreshed = 0;
|
||||||
|
|
||||||
foreach (var name in names)
|
foreach (var name in names)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var item = _libraryManager.GetArtist(name);
|
MusicArtist? item = null;
|
||||||
|
if (existingArtists.TryGetValue(name, out var artists) && artists.Length > 0)
|
||||||
|
{
|
||||||
|
item = artists.OrderBy(i => i.IsAccessedByName ? 1 : 0).First();
|
||||||
|
}
|
||||||
|
|
||||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
// Fall back to GetArtist if not found (creates new item if needed)
|
||||||
|
item ??= _libraryManager.GetArtist(name);
|
||||||
|
var isNew = !existingArtistIds.Contains(item.Id);
|
||||||
|
var neverRefreshed = item.DateLastRefreshed == default;
|
||||||
|
|
||||||
|
if (isNew || neverRefreshed)
|
||||||
|
{
|
||||||
|
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||||
|
refreshed++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// Don't clutter the log
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -80,31 +99,24 @@ public class ArtistsValidator
|
|||||||
progress.Report(percent);
|
progress.Report(percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count);
|
||||||
|
|
||||||
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
{
|
{
|
||||||
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
||||||
IsDeadArtist = true,
|
IsDeadArtist = true,
|
||||||
IsLocked = false
|
IsLocked = false
|
||||||
}).Cast<MusicArtist>().ToList();
|
}).Cast<MusicArtist>()
|
||||||
|
.Where(item => item.IsAccessedByName)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
foreach (var item in deadEntities)
|
foreach (var item in deadEntities)
|
||||||
{
|
{
|
||||||
if (!item.IsAccessedByName)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
||||||
|
|
||||||
_libraryManager.DeleteItem(
|
|
||||||
item,
|
|
||||||
new DeleteOptions
|
|
||||||
{
|
|
||||||
DeleteFileLocation = false
|
|
||||||
},
|
|
||||||
false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
|
||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public class CollectionPostScanTask : ILibraryPostScanTask
|
|||||||
|
|
||||||
foreach (var m in movies)
|
foreach (var m in movies)
|
||||||
{
|
{
|
||||||
if (m is Movie movie && !string.IsNullOrEmpty(movie.CollectionName))
|
if (m is Movie movie && !string.IsNullOrEmpty(movie.CollectionName) && !movie.PrimaryVersionId.HasValue)
|
||||||
{
|
{
|
||||||
if (collectionNameMoviesMap.TryGetValue(movie.CollectionName, out var movieList))
|
if (collectionNameMoviesMap.TryGetValue(movie.CollectionName, out var movieList))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
@@ -48,17 +49,40 @@ public class GenresValidator
|
|||||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var names = _itemRepo.GetGenreNames();
|
var names = _itemRepo.GetGenreNames();
|
||||||
|
var existingGenreIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [BaseItemKind.Genre]
|
||||||
|
}).ToHashSet();
|
||||||
|
|
||||||
|
var existingGenres = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [BaseItemKind.Genre]
|
||||||
|
}).Cast<Genre>()
|
||||||
|
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var numComplete = 0;
|
var numComplete = 0;
|
||||||
var count = names.Count;
|
var count = names.Count;
|
||||||
|
var refreshed = 0;
|
||||||
|
|
||||||
foreach (var name in names)
|
foreach (var name in names)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var item = _libraryManager.GetGenre(name);
|
Genre? item = null;
|
||||||
|
if (existingGenres.TryGetValue(name, out var existingGenre))
|
||||||
|
{
|
||||||
|
item = existingGenre;
|
||||||
|
}
|
||||||
|
|
||||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
// Fall back to GetGenre if not found (creates new item if needed)
|
||||||
|
item ??= _libraryManager.GetGenre(name);
|
||||||
|
|
||||||
|
if (!existingGenreIds.Contains(item.Id))
|
||||||
|
{
|
||||||
|
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||||
|
refreshed++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -78,6 +102,8 @@ public class GenresValidator
|
|||||||
progress.Report(percent);
|
progress.Report(percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new genres out of {TotalCount} total", refreshed, count);
|
||||||
|
|
||||||
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
{
|
{
|
||||||
IncludeItemTypes = [BaseItemKind.Genre, BaseItemKind.MusicGenre],
|
IncludeItemTypes = [BaseItemKind.Genre, BaseItemKind.MusicGenre],
|
||||||
@@ -88,16 +114,10 @@ public class GenresValidator
|
|||||||
foreach (var item in deadEntities)
|
foreach (var item in deadEntities)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
||||||
|
|
||||||
_libraryManager.DeleteItem(
|
|
||||||
item,
|
|
||||||
new DeleteOptions
|
|
||||||
{
|
|
||||||
DeleteFileLocation = false
|
|
||||||
},
|
|
||||||
false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
|
||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Persistence;
|
using MediaBrowser.Controller.Persistence;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -45,17 +48,25 @@ public class MusicGenresValidator
|
|||||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var names = _itemRepo.GetMusicGenreNames();
|
var names = _itemRepo.GetMusicGenreNames();
|
||||||
|
var existingMusicGenreIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [BaseItemKind.MusicGenre]
|
||||||
|
}).ToHashSet();
|
||||||
|
|
||||||
var numComplete = 0;
|
var numComplete = 0;
|
||||||
var count = names.Count;
|
var count = names.Count;
|
||||||
|
var refreshed = 0;
|
||||||
|
|
||||||
foreach (var name in names)
|
foreach (var name in names)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var item = _libraryManager.GetMusicGenre(name);
|
var item = _libraryManager.GetMusicGenre(name);
|
||||||
|
if (!existingMusicGenreIds.Contains(item.Id))
|
||||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
{
|
||||||
|
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||||
|
refreshed++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -75,6 +86,8 @@ public class MusicGenresValidator
|
|||||||
progress.Report(percent);
|
progress.Report(percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new music genres out of {TotalCount} total", refreshed, count);
|
||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ public class PeopleValidator
|
|||||||
var i = 0;
|
var i = 0;
|
||||||
foreach (var item in deadEntities.Chunk(500))
|
foreach (var item in deadEntities.Chunk(500))
|
||||||
{
|
{
|
||||||
_libraryManager.DeleteItemsUnsafeFast(item);
|
_libraryManager.DeleteItemsUnsafeFast(item, true);
|
||||||
subProgress.Report(100f / deadEntities.Count * (i++ * 100));
|
subProgress.Report(100f / deadEntities.Count * (i++ * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
@@ -49,17 +50,40 @@ public class StudiosValidator
|
|||||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var names = _itemRepo.GetStudioNames();
|
var names = _itemRepo.GetStudioNames();
|
||||||
|
var existingStudioIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [BaseItemKind.Studio]
|
||||||
|
}).ToHashSet();
|
||||||
|
|
||||||
|
var existingStudios = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [BaseItemKind.Studio]
|
||||||
|
}).Cast<Studio>()
|
||||||
|
.GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var numComplete = 0;
|
var numComplete = 0;
|
||||||
var count = names.Count;
|
var count = names.Count;
|
||||||
|
var refreshed = 0;
|
||||||
|
|
||||||
foreach (var name in names)
|
foreach (var name in names)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var item = _libraryManager.GetStudio(name);
|
Studio? item = null;
|
||||||
|
if (existingStudios.TryGetValue(name, out var existingStudio))
|
||||||
|
{
|
||||||
|
item = existingStudio;
|
||||||
|
}
|
||||||
|
|
||||||
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
// Fall back to GetStudio if not found (creates new item if needed)
|
||||||
|
item ??= _libraryManager.GetStudio(name);
|
||||||
|
|
||||||
|
if (!existingStudioIds.Contains(item.Id))
|
||||||
|
{
|
||||||
|
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||||
|
refreshed++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -79,6 +103,8 @@ public class StudiosValidator
|
|||||||
progress.Report(percent);
|
progress.Report(percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new studios out of {TotalCount} total", refreshed, count);
|
||||||
|
|
||||||
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
{
|
{
|
||||||
IncludeItemTypes = [BaseItemKind.Studio],
|
IncludeItemTypes = [BaseItemKind.Studio],
|
||||||
@@ -89,16 +115,10 @@ public class StudiosValidator
|
|||||||
foreach (var item in deadEntities)
|
foreach (var item in deadEntities)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
|
||||||
|
|
||||||
_libraryManager.DeleteItem(
|
|
||||||
item,
|
|
||||||
new DeleteOptions
|
|
||||||
{
|
|
||||||
DeleteFileLocation = false
|
|
||||||
},
|
|
||||||
false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
|
||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"Albums": "аальбомқәа"
|
"AppDeviceValues": "Апп: {0}, Априбор: {1}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
{
|
{
|
||||||
"Artists": "Kunstenare",
|
"Artists": "Kunstenare",
|
||||||
"Channels": "Kanale",
|
|
||||||
"Folders": "Lêergidse",
|
"Folders": "Lêergidse",
|
||||||
"Favorites": "Gunstelinge",
|
"Favorites": "Gunstelinge",
|
||||||
"HeaderFavoriteShows": "Gunsteling Vertonings",
|
"HeaderFavoriteShows": "Gunsteling Vertonings",
|
||||||
"ValueSpecialEpisodeName": "Spesiale - {0}",
|
|
||||||
"HeaderAlbumArtists": "Album kunstenaars",
|
|
||||||
"Books": "Boeke",
|
"Books": "Boeke",
|
||||||
"HeaderNextUp": "Volgende",
|
"HeaderNextUp": "Volgende",
|
||||||
"Movies": "Flieks",
|
"Movies": "Flieks",
|
||||||
@@ -13,24 +10,13 @@
|
|||||||
"HeaderContinueWatching": "Hou aan kyk",
|
"HeaderContinueWatching": "Hou aan kyk",
|
||||||
"HeaderFavoriteEpisodes": "Gunsteling Episodes",
|
"HeaderFavoriteEpisodes": "Gunsteling Episodes",
|
||||||
"Photos": "Foto's",
|
"Photos": "Foto's",
|
||||||
"Playlists": "Snitlyste",
|
|
||||||
"HeaderFavoriteArtists": "Gunsteling Kunstenaars",
|
|
||||||
"HeaderFavoriteAlbums": "Gunsteling Albums",
|
|
||||||
"Sync": "Sinkroniseer",
|
|
||||||
"HeaderFavoriteSongs": "Gunsteling Liedjies",
|
|
||||||
"Songs": "Liedjies",
|
|
||||||
"DeviceOnlineWithName": "{0} is aanlyn",
|
|
||||||
"DeviceOfflineWithName": "{0} is ontkoppel",
|
|
||||||
"Collections": "Versamelings",
|
"Collections": "Versamelings",
|
||||||
"Inherit": "Ontvang",
|
"Inherit": "Ontvang",
|
||||||
"HeaderLiveTV": "Lewendige TV",
|
"HeaderLiveTV": "Lewendige TV",
|
||||||
"Application": "Program",
|
|
||||||
"AppDeviceValues": "App: {0}, Toestel: {1}",
|
"AppDeviceValues": "App: {0}, Toestel: {1}",
|
||||||
"VersionNumber": "Weergawe {0}",
|
"VersionNumber": "Weergawe {0}",
|
||||||
"ValueHasBeenAddedToLibrary": "{0} is by jou media biblioteek bygevoeg",
|
|
||||||
"UserStoppedPlayingItemWithValues": "{0} het klaar {1} op {2} gespeel",
|
"UserStoppedPlayingItemWithValues": "{0} het klaar {1} op {2} gespeel",
|
||||||
"UserStartedPlayingItemWithValues": "{0} is besig om {1} op {2} te speel",
|
"UserStartedPlayingItemWithValues": "{0} is besig om {1} op {2} te speel",
|
||||||
"UserPolicyUpdatedWithName": "Gebruiker beleid is verander vir {0}",
|
|
||||||
"UserPasswordChangedWithName": "Gebruiker {0} se wagwoord is verander",
|
"UserPasswordChangedWithName": "Gebruiker {0} se wagwoord is verander",
|
||||||
"UserOnlineFromDevice": "{0} is aanlyn van {1}",
|
"UserOnlineFromDevice": "{0} is aanlyn van {1}",
|
||||||
"UserOfflineFromDevice": "{0} is ontkoppel van {1}",
|
"UserOfflineFromDevice": "{0} is ontkoppel van {1}",
|
||||||
@@ -38,19 +24,13 @@
|
|||||||
"UserDownloadingItemWithValues": "{0} is besig om {1} af te laai",
|
"UserDownloadingItemWithValues": "{0} is besig om {1} af te laai",
|
||||||
"UserDeletedWithName": "Gebruiker {0} is verwyder",
|
"UserDeletedWithName": "Gebruiker {0} is verwyder",
|
||||||
"UserCreatedWithName": "Gebruiker {0} is geskep",
|
"UserCreatedWithName": "Gebruiker {0} is geskep",
|
||||||
"User": "Gebruiker",
|
|
||||||
"TvShows": "TV Programme",
|
"TvShows": "TV Programme",
|
||||||
"System": "Stelsel",
|
|
||||||
"SubtitleDownloadFailureFromForItem": "Ondertitels het misluk om af te laai van {0} vir {1}",
|
"SubtitleDownloadFailureFromForItem": "Ondertitels het misluk om af te laai van {0} vir {1}",
|
||||||
"StartupEmbyServerIsLoading": "Jellyfin Bediener is besig om te laai. Probeer weer in 'n kort tyd.",
|
"StartupEmbyServerIsLoading": "Jellyfin Bediener is besig om te laai. Probeer weer in 'n kort tyd.",
|
||||||
"ServerNameNeedsToBeRestarted": "{0} moet herbegin word",
|
|
||||||
"ScheduledTaskStartedWithName": "{0} het begin",
|
|
||||||
"ScheduledTaskFailedWithName": "{0} het misluk",
|
"ScheduledTaskFailedWithName": "{0} het misluk",
|
||||||
"ProviderValue": "Voorsiener: {0}",
|
|
||||||
"PluginUpdatedWithName": "{0} was opgedateer",
|
"PluginUpdatedWithName": "{0} was opgedateer",
|
||||||
"PluginUninstalledWithName": "{0} was verwyder",
|
"PluginUninstalledWithName": "{0} was verwyder",
|
||||||
"PluginInstalledWithName": "{0} is geïnstalleer",
|
"PluginInstalledWithName": "{0} is geïnstalleer",
|
||||||
"Plugin": "Inprop module",
|
|
||||||
"NotificationOptionVideoPlaybackStopped": "Video terugspeel het gestop",
|
"NotificationOptionVideoPlaybackStopped": "Video terugspeel het gestop",
|
||||||
"NotificationOptionVideoPlayback": "Video terugspeel het begin",
|
"NotificationOptionVideoPlayback": "Video terugspeel het begin",
|
||||||
"NotificationOptionUserLockedOut": "Gebruiker uitgeslyt",
|
"NotificationOptionUserLockedOut": "Gebruiker uitgeslyt",
|
||||||
@@ -74,23 +54,14 @@
|
|||||||
"MusicVideos": "Musiek Videos",
|
"MusicVideos": "Musiek Videos",
|
||||||
"Music": "Musiek",
|
"Music": "Musiek",
|
||||||
"MixedContent": "Gemengde inhoud",
|
"MixedContent": "Gemengde inhoud",
|
||||||
"MessageServerConfigurationUpdated": "Bediener konfigurasie is opgedateer",
|
|
||||||
"MessageNamedServerConfigurationUpdatedWithValue": "Bediener konfigurasie seksie {0} is opgedateer",
|
|
||||||
"MessageApplicationUpdatedTo": "Jellyfin Bediener is opgedateer na {0}",
|
|
||||||
"MessageApplicationUpdated": "Jellyfin Bediener is opgedateer",
|
|
||||||
"Latest": "Nuutste",
|
"Latest": "Nuutste",
|
||||||
"LabelRunningTimeValue": "Werktyd: {0}",
|
"LabelRunningTimeValue": "Werktyd: {0}",
|
||||||
"LabelIpAddressValue": "IP adres: {0}",
|
"LabelIpAddressValue": "IP adres: {0}",
|
||||||
"ItemRemovedWithName": "{0} is uit versameling verwyder",
|
|
||||||
"ItemAddedWithName": "{0} is by die versameling gevoeg",
|
|
||||||
"HomeVideos": "Tuis Videos",
|
"HomeVideos": "Tuis Videos",
|
||||||
"HeaderRecordingGroups": "Groep Opnames",
|
|
||||||
"Genres": "Genres",
|
"Genres": "Genres",
|
||||||
"FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}",
|
"FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}",
|
||||||
"ChapterNameValue": "Hoofstuk {0}",
|
"ChapterNameValue": "Hoofstuk {0}",
|
||||||
"CameraImageUploadedFrom": "'n Nuwe kamera foto is opgelaai vanaf {0}",
|
|
||||||
"AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer",
|
"AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer",
|
||||||
"Albums": "Albums",
|
|
||||||
"TasksChannelsCategory": "Internet kanale",
|
"TasksChannelsCategory": "Internet kanale",
|
||||||
"TasksApplicationCategory": "aansoek",
|
"TasksApplicationCategory": "aansoek",
|
||||||
"TasksLibraryCategory": "biblioteek",
|
"TasksLibraryCategory": "biblioteek",
|
||||||
@@ -128,12 +99,12 @@
|
|||||||
"TaskRefreshTrickplayImagesDescription": "Skep fopspeel voorskou vir videos in aangeskakelde media versameling.",
|
"TaskRefreshTrickplayImagesDescription": "Skep fopspeel voorskou vir videos in aangeskakelde media versameling.",
|
||||||
"TaskAudioNormalizationDescription": "Skandeer lêers vir oudio-normaliseringsdata.",
|
"TaskAudioNormalizationDescription": "Skandeer lêers vir oudio-normaliseringsdata.",
|
||||||
"TaskAudioNormalization": "Odio Normalisering",
|
"TaskAudioNormalization": "Odio Normalisering",
|
||||||
"TaskCleanCollectionsAndPlaylists": "Maak versamelings en snitlyste skoon",
|
|
||||||
"TaskCleanCollectionsAndPlaylistsDescription": "Verwyder items uit versamelings en snitlyste wat nie meer bestaan nie.",
|
|
||||||
"TaskDownloadMissingLyrics": "Laai tekorte lirieke af",
|
"TaskDownloadMissingLyrics": "Laai tekorte lirieke af",
|
||||||
"TaskDownloadMissingLyricsDescription": "Laai lirieke af vir liedjies",
|
"TaskDownloadMissingLyricsDescription": "Laai lirieke af vir liedjies",
|
||||||
"TaskExtractMediaSegments": "Media Segment Skandeer",
|
"TaskExtractMediaSegments": "Media Segment Skandeer",
|
||||||
"TaskExtractMediaSegmentsDescription": "Onttrek of verkry mediasegmente van MediaSegment-geaktiveerde inproppe.",
|
"TaskExtractMediaSegmentsDescription": "Onttrek of verkry mediasegmente van MediaSegment-geaktiveerde inproppe.",
|
||||||
"TaskMoveTrickplayImages": "Migreer Trickplay Beeldligging",
|
"TaskMoveTrickplayImages": "Migreer Trickplay Beeldligging",
|
||||||
"TaskMoveTrickplayImagesDescription": "Skuif ontstaande trickplay lêers volgens die biblioteekinstellings."
|
"TaskMoveTrickplayImagesDescription": "Skuif ontstaande trickplay lêers volgens die biblioteekinstellings.",
|
||||||
|
"CleanupUserDataTask": "Gebruikers data skoon maak taak",
|
||||||
|
"CleanupUserDataTaskDescription": "Maak alle gebruikers data (kykstatus, gunstelingstatus, ens.) skoon van media wat nie meer vir ten minste 90 dae teenwoordig is nie."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +1,112 @@
|
|||||||
{
|
{
|
||||||
"Albums": "ألبومات",
|
"AppDeviceValues": "التطبيق: {0}، الجهاز: {1}",
|
||||||
"AppDeviceValues": "تطبيق: {0}, جهاز: {1}",
|
|
||||||
"Application": "تطبيق",
|
|
||||||
"Artists": "الفنانون",
|
"Artists": "الفنانون",
|
||||||
"AuthenticationSucceededWithUserName": "نجحت عملية التوثيق بـ {0}",
|
"AuthenticationSucceededWithUserName": "تمت مصادقة {0} بنجاح",
|
||||||
"Books": "الكتب",
|
"Books": "الكتب",
|
||||||
"CameraImageUploadedFrom": "رُفعت صورة الكاميرا الجديدة من {0}",
|
|
||||||
"Channels": "القنوات",
|
|
||||||
"ChapterNameValue": "الفصل {0}",
|
"ChapterNameValue": "الفصل {0}",
|
||||||
"Collections": "المجموعات",
|
"Collections": "المجموعات",
|
||||||
"DeviceOfflineWithName": "قُطِع الاتصال ب{0}",
|
"FailedLoginAttemptWithUserName": "محاولة تسجيل دخول فاشلة من {0}",
|
||||||
"DeviceOnlineWithName": "{0} متصل",
|
|
||||||
"FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فاشلة من {0}",
|
|
||||||
"Favorites": "المفضلة",
|
"Favorites": "المفضلة",
|
||||||
"Folders": "المجلدات",
|
"Folders": "المجلدات",
|
||||||
"Genres": "التصنيفات",
|
"Genres": "الأنواع",
|
||||||
"HeaderAlbumArtists": "فناني الألبوم",
|
"HeaderContinueWatching": "متابعة المشاهدة",
|
||||||
"HeaderContinueWatching": "إستئناف المشاهدة",
|
|
||||||
"HeaderFavoriteAlbums": "الألبومات المفضلة",
|
|
||||||
"HeaderFavoriteArtists": "الفنانون المفضلون",
|
|
||||||
"HeaderFavoriteEpisodes": "الحلقات المفضلة",
|
"HeaderFavoriteEpisodes": "الحلقات المفضلة",
|
||||||
"HeaderFavoriteShows": "المسلسلات المفضلة",
|
"HeaderFavoriteShows": "المسلسلات المفضلة",
|
||||||
"HeaderFavoriteSongs": "الأغاني المفضلة",
|
"HeaderLiveTV": "البث التلفزيوني المباشر",
|
||||||
"HeaderLiveTV": "التلفاز المباشر",
|
|
||||||
"HeaderNextUp": "التالي",
|
"HeaderNextUp": "التالي",
|
||||||
"HeaderRecordingGroups": "مجموعات التسجيل",
|
"HomeVideos": "فيديوهات منزلية",
|
||||||
"HomeVideos": "الفيديوهات الشخصية",
|
"Inherit": "وراثة",
|
||||||
"Inherit": "توريث",
|
"LabelIpAddressValue": "عنوان IP: {0}",
|
||||||
"ItemAddedWithName": "أُضيف {0} للمكتبة",
|
|
||||||
"ItemRemovedWithName": "أُزيل {0} من المكتبة",
|
|
||||||
"LabelIpAddressValue": "عنوان الآي بي: {0}",
|
|
||||||
"LabelRunningTimeValue": "مدة التشغيل: {0}",
|
"LabelRunningTimeValue": "مدة التشغيل: {0}",
|
||||||
"Latest": "الأحدث",
|
"Latest": "الأحدث",
|
||||||
"MessageApplicationUpdated": "حُدث خادم Jellyfin",
|
|
||||||
"MessageApplicationUpdatedTo": "حُدث خادم Jellyfin إلى {0}",
|
|
||||||
"MessageNamedServerConfigurationUpdatedWithValue": "حُدثت إعدادات الخادم في قسم {0}",
|
|
||||||
"MessageServerConfigurationUpdated": "حُدثت إعدادات الخادم",
|
|
||||||
"MixedContent": "محتوى مختلط",
|
"MixedContent": "محتوى مختلط",
|
||||||
"Movies": "الأفلام",
|
"Movies": "الأفلام",
|
||||||
"Music": "الموسيقى",
|
"Music": "الموسيقى",
|
||||||
"MusicVideos": "الفيديوهات الموسيقية",
|
"MusicVideos": "الفيديوهات الموسيقية",
|
||||||
"NameInstallFailed": "فشل تثبيت {0}",
|
"NameInstallFailed": "فشل تثبيت {0}",
|
||||||
"NameSeasonNumber": "الموسم {0}",
|
"NameSeasonNumber": "الموسم {0}",
|
||||||
"NameSeasonUnknown": "الموسم غير معروف",
|
"NameSeasonUnknown": "موسم غير معروف",
|
||||||
"NewVersionIsAvailable": "نسخة جديدة من خادم Jellyfin متوفرة للتحميل.",
|
"NewVersionIsAvailable": "يتوفر إصدار جديد من خادم Jellyfin للتنزيل.",
|
||||||
"NotificationOptionApplicationUpdateAvailable": "يوجد تحديث للتطبيق",
|
"NotificationOptionApplicationUpdateAvailable": "تحديث التطبيق متاح",
|
||||||
"NotificationOptionApplicationUpdateInstalled": "نُصب تحديث التطبيق",
|
"NotificationOptionApplicationUpdateInstalled": "تم تثبيت تحديث التطبيق",
|
||||||
"NotificationOptionAudioPlayback": "بدأ تشغيل المقطع الصوتي",
|
"NotificationOptionAudioPlayback": "بدأ تشغيل الصوت",
|
||||||
"NotificationOptionAudioPlaybackStopped": "أُوقف تشغيل المقطع الصوتي",
|
"NotificationOptionAudioPlaybackStopped": "توقف تشغيل الصوت",
|
||||||
"NotificationOptionCameraImageUploaded": "رُفعت صورة الكاميرا",
|
"NotificationOptionCameraImageUploaded": "تم رفع صورة كاميرا",
|
||||||
"NotificationOptionInstallationFailed": "فشل في التثبيت",
|
"NotificationOptionInstallationFailed": "فشل التثبيت",
|
||||||
"NotificationOptionNewLibraryContent": "أُضيف محتوى جديدا",
|
"NotificationOptionNewLibraryContent": "تمت إضافة محتوى جديد",
|
||||||
"NotificationOptionPluginError": "فشل في الملحق",
|
"NotificationOptionPluginError": "خطأ في الملحق",
|
||||||
"NotificationOptionPluginInstalled": "ثُبتت الملحق",
|
"NotificationOptionPluginInstalled": "تم تثبيت الملحق",
|
||||||
"NotificationOptionPluginUninstalled": "تمت إزالة الملحق",
|
"NotificationOptionPluginUninstalled": "تمت إزالة الملحق",
|
||||||
"NotificationOptionPluginUpdateInstalled": "تم تثبيت تحديثات الملحق",
|
"NotificationOptionPluginUpdateInstalled": "تم تحديث الملحق",
|
||||||
"NotificationOptionServerRestartRequired": "يجب إعادة تشغيل الخادم",
|
"NotificationOptionServerRestartRequired": "مطلوب إعادة تشغيل الخادم",
|
||||||
"NotificationOptionTaskFailed": "فشل في المهمة المجدولة",
|
"NotificationOptionTaskFailed": "فشل المهمة المجدولة",
|
||||||
"NotificationOptionUserLockedOut": "تم إقفال حساب المستخدم",
|
"NotificationOptionUserLockedOut": "تم قفل حساب المستخدم",
|
||||||
"NotificationOptionVideoPlayback": "بدأ تشغيل الفيديو",
|
"NotificationOptionVideoPlayback": "بدأ تشغيل الفيديو",
|
||||||
"NotificationOptionVideoPlaybackStopped": "تم إيقاف تشغيل الفيديو",
|
"NotificationOptionVideoPlaybackStopped": "توقف تشغيل الفيديو",
|
||||||
"Photos": "الصور",
|
"Photos": "الصور",
|
||||||
"Playlists": "قوائم التشغيل",
|
|
||||||
"Plugin": "الملحق",
|
|
||||||
"PluginInstalledWithName": "تم تثبيت {0}",
|
"PluginInstalledWithName": "تم تثبيت {0}",
|
||||||
"PluginUninstalledWithName": "تمت إزالة {0}",
|
"PluginUninstalledWithName": "تمت إزالة {0}",
|
||||||
"PluginUpdatedWithName": "تم تحديث {0}",
|
"PluginUpdatedWithName": "تم تحديث {0}",
|
||||||
"ProviderValue": "المزود: {0}",
|
"ScheduledTaskFailedWithName": "فشلت {0}",
|
||||||
"ScheduledTaskFailedWithName": "فشلت العملية {0}",
|
"Shows": "المسلسلات",
|
||||||
"ScheduledTaskStartedWithName": "تم بدء العملية {0}",
|
"StartupEmbyServerIsLoading": "يتم الآن تحميل خادم Jellyfin. يرجى المحاولة مرة أخرى بعد قليل.",
|
||||||
"ServerNameNeedsToBeRestarted": "يحتاج {0} لإعادة التشغيل",
|
"SubtitleDownloadFailureFromForItem": "فشل تنزيل الترجمات من {0} لـ {1}",
|
||||||
"Shows": "العروض",
|
|
||||||
"Songs": "الأغاني",
|
|
||||||
"StartupEmbyServerIsLoading": "يتم تحميل خادم Jellyfin . الرجاء المحاولة بعد قليل.",
|
|
||||||
"SubtitleDownloadFailureForItem": "عملية إنزال الترجمة فشلت لـ{0}",
|
|
||||||
"SubtitleDownloadFailureFromForItem": "فشل تحميل الترجمات من {0} ل {1}",
|
|
||||||
"Sync": "مزامنة",
|
|
||||||
"System": "النظام",
|
|
||||||
"TvShows": "البرامج التلفزيونية",
|
"TvShows": "البرامج التلفزيونية",
|
||||||
"User": "المستخدم",
|
|
||||||
"UserCreatedWithName": "تم إنشاء المستخدم {0}",
|
"UserCreatedWithName": "تم إنشاء المستخدم {0}",
|
||||||
"UserDeletedWithName": "تم حذف المستخدم {0}",
|
"UserDeletedWithName": "تم حذف المستخدم {0}",
|
||||||
"UserDownloadingItemWithValues": "يقوم {0} بتنزيل {1}",
|
"UserDownloadingItemWithValues": "{0} يقوم بتنزيل {1}",
|
||||||
"UserLockedOutWithName": "تم منع المستخدم {0} من الدخول",
|
"UserLockedOutWithName": "تم قفل حساب المستخدم {0}",
|
||||||
"UserOfflineFromDevice": "تم قطع اتصال {0} من {1}",
|
"UserOfflineFromDevice": "انقطع اتصال {0} من {1}",
|
||||||
"UserOnlineFromDevice": "{0} متصل عبر {1}",
|
"UserOnlineFromDevice": "{0} متصل من {1}",
|
||||||
"UserPasswordChangedWithName": "تم تغيير كلمة السر للمستخدم {0}",
|
"UserPasswordChangedWithName": "تم تغيير كلمة المرور للمستخدم {0}",
|
||||||
"UserPolicyUpdatedWithName": "تم تحديث سياسة المستخدم {0}",
|
"UserStartedPlayingItemWithValues": "{0} يقوم بتشغيل {1} على {2}",
|
||||||
"UserStartedPlayingItemWithValues": "قام {0} ببدء تشغيل {1} على {2}",
|
"UserStoppedPlayingItemWithValues": "أنهى {0} تشغيل {1} على {2}",
|
||||||
"UserStoppedPlayingItemWithValues": "قام {0} بإيقاف تشغيل {1} على {2}",
|
|
||||||
"ValueHasBeenAddedToLibrary": "تمت اضافت {0} إلى مكتبة الوسائط",
|
|
||||||
"ValueSpecialEpisodeName": "حلقة خاصه - {0}",
|
|
||||||
"VersionNumber": "الإصدار {0}",
|
"VersionNumber": "الإصدار {0}",
|
||||||
"TaskCleanCacheDescription": "يحذف الملفات المؤقتة التي لم يعد النظام بحاجة إليها.",
|
"TaskCleanCacheDescription": "يحذف ملفات ذاكرة التخزين المؤقت التي لم يعد النظام بحاجة إليها.",
|
||||||
"TaskCleanCache": "حذف الملفات المؤقتة",
|
"TaskCleanCache": "تنظيف مجلد ذاكرة التخزين المؤقت",
|
||||||
"TasksChannelsCategory": "قنوات الإنترنت",
|
"TasksChannelsCategory": "قنوات الإنترنت",
|
||||||
"TasksLibraryCategory": "مكتبة",
|
"TasksLibraryCategory": "المكتبة",
|
||||||
"TasksMaintenanceCategory": "صيانة",
|
"TasksMaintenanceCategory": "الصيانة",
|
||||||
"TaskRefreshLibraryDescription": "يفحص مكتبة الوسائط الخاصة بك باحثا عن ملفات جديدة، ومن ثم يُحدث البيانات الوصفية.",
|
"TaskRefreshLibraryDescription": "يفحص مكتبة المحتوى الخاصة بك بحثاً عن ملفات جديدة ويحدّث البيانات الوصفية.",
|
||||||
"TaskRefreshLibrary": "افحص مكتبة الوسائط",
|
"TaskRefreshLibrary": "فحص مكتبة المحتوى",
|
||||||
"TaskRefreshChapterImagesDescription": "يُنشئ صور مصغرة لمقاطع الفيديو التي تحتوي على فصول.",
|
"TaskRefreshChapterImagesDescription": "ينشئ صوراً مصغرة للفيديوهات التي تحتوي على فصول.",
|
||||||
"TaskRefreshChapterImages": "استخراج صور الفصل",
|
"TaskRefreshChapterImages": "استخراج صور الفصول",
|
||||||
"TasksApplicationCategory": "تطبيق",
|
"TasksApplicationCategory": "التطبيق",
|
||||||
"TaskDownloadMissingSubtitlesDescription": "يبحث في الإنترنت على الترجمات الناقصة استنادا على البيانات الوصفية.",
|
"TaskDownloadMissingSubtitlesDescription": "يبحث في الإنترنت عن الترجمات المفقودة بناءً على إعدادات البيانات الوصفية.",
|
||||||
"TaskDownloadMissingSubtitles": "تحميل الترجمات الناقصة",
|
"TaskDownloadMissingSubtitles": "تنزيل الترجمات المفقودة",
|
||||||
"TaskRefreshChannelsDescription": "يحدث معلومات قنوات الإنترنت.",
|
"TaskRefreshChannelsDescription": "يحدّث معلومات قنوات الإنترنت.",
|
||||||
"TaskRefreshChannels": "إعادة تحديث القنوات",
|
"TaskRefreshChannels": "تحديث القنوات",
|
||||||
"TaskCleanTranscodeDescription": "يحذف ملفات الترميز الأقدم من يوم واحد.",
|
"TaskCleanTranscodeDescription": "يحذف ملفات تحويل الترميز التي مر عليها أكثر من يوم واحد.",
|
||||||
"TaskCleanTranscode": "حذف ما بمجلد الترميز",
|
"TaskCleanTranscode": "تنظيف مجلد تحويل الترميز",
|
||||||
"TaskUpdatePluginsDescription": "تحميل وتثبيت الإضافات التي تم تفعيل التحديث التلقائي لها.",
|
"TaskUpdatePluginsDescription": "ينزّل ويثبّت التحديثات للملحقات المهيأة للتحديث التلقائي.",
|
||||||
"TaskUpdatePlugins": "تحديث الإضافات",
|
"TaskUpdatePlugins": "تحديث الملحقات",
|
||||||
"TaskRefreshPeopleDescription": "يقوم بتحديث البيانات الوصفية للممثلين والمخرجين في مكتبة الوسائط الخاصة بك.",
|
"TaskRefreshPeopleDescription": "يحدّث البيانات الوصفية للممثلين والمخرجين في مكتبة المحتوى الخاصة بك.",
|
||||||
"TaskRefreshPeople": "إعادة تحميل الأشخاص",
|
"TaskRefreshPeople": "تحديث الأشخاص",
|
||||||
"TaskCleanLogsDescription": "يحذف السجلات الأقدم من {0} يوم.",
|
"TaskCleanLogsDescription": "يحذف ملفات السجل التي يزيد عمرها عن {0} أيام.",
|
||||||
"TaskCleanLogs": "حذف مسار السجل",
|
"TaskCleanLogs": "تنظيف مجلد السجلات",
|
||||||
"TaskCleanActivityLogDescription": "يحذف سجل الأنشطة الأقدم من الوقت الذي تم تحديده.",
|
"TaskCleanActivityLogDescription": "يحذف إدخالات سجل النشاط الأقدم من العمر المحدد.",
|
||||||
"TaskCleanActivityLog": "حذف سجل الأنشطة",
|
"TaskCleanActivityLog": "تنظيف سجل النشاط",
|
||||||
"Default": "افتراضي",
|
"Default": "الافتراضي",
|
||||||
"Undefined": "غير معرف",
|
"Undefined": "غير محدد",
|
||||||
"Forced": "ملحقة",
|
"Forced": "إجباري",
|
||||||
"TaskOptimizeDatabaseDescription": "يضغط قاعدة البيانات ويقتطع المساحة الحرة. تشغيل هذه المهمة بعد فحص المكتبة أو إجراء تغييرات أخرى تتضمن تعديلات في قاعدة البيانات قد تؤدي إلى تحسين الأداء.",
|
"TaskOptimizeDatabaseDescription": "يضغط قاعدة البيانات ويقلل المساحة الحرة. قد يؤدي تشغيل هذه المهمة بعد فحص المكتبة أو إجراء تغييرات أخرى تتضمن تعديلات على قاعدة البيانات إلى تحسين الأداء.",
|
||||||
"TaskOptimizeDatabase": "تحسين قاعدة البيانات",
|
"TaskOptimizeDatabase": "تحسين قاعدة البيانات",
|
||||||
"TaskKeyframeExtractorDescription": "يستخرج الإطارات الرئيسية من ملفات الفيديو لكي ينشئ قوائم تشغيل بث HTTP المباشر. قد تستمر هذه العملية لوقت طويل.",
|
"TaskKeyframeExtractorDescription": "يستخرج الإطارات الرئيسية من ملفات الفيديو لإنشاء قوائم تشغيل HLS أكثر دقة. قد يستغرق تشغيل هذه المهمة وقتاً طويلاً.",
|
||||||
"TaskKeyframeExtractor": "مستخرج الإطار الرئيسي",
|
"TaskKeyframeExtractor": "مستخرج الإطارات الرئيسية",
|
||||||
"External": "خارجي",
|
"External": "خارجي",
|
||||||
"HearingImpaired": "ضعاف السمع",
|
"HearingImpaired": "لضعاف السمع",
|
||||||
"TaskRefreshTrickplayImages": "توليد صور المعاينة السريعة",
|
"TaskRefreshTrickplayImages": "إنشاء صور معاينات التنقل (Trickplay)",
|
||||||
"TaskRefreshTrickplayImagesDescription": "يُولّد معاينات تنقل سريع لمقاطع الفيديو ضمن المكتبات المفعّلة.",
|
"TaskRefreshTrickplayImagesDescription": "ينشئ صور معاينات التنقل السريع للفيديوهات في المكتبات المفعّلة.",
|
||||||
"TaskCleanCollectionsAndPlaylists": "حذف المجموعات وقوائم التشغيل",
|
"TaskAudioNormalization": "تطبيع الصوت",
|
||||||
"TaskCleanCollectionsAndPlaylistsDescription": "حذف عناصر من المجموعات وقوائم التشغيل التي لم تعد موجودة.",
|
"TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.",
|
||||||
"TaskAudioNormalization": "تسوية الصوت",
|
"TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة",
|
||||||
"TaskAudioNormalizationDescription": "مسح الملفات لتطبيع بيانات الصوت.",
|
"TaskDownloadMissingLyricsDescription": "ينزّل الكلمات للأغاني.",
|
||||||
"TaskDownloadMissingLyrics": "تنزيل عبارات القصيدة",
|
"TaskExtractMediaSegments": "فحص مقاطع المحتوى",
|
||||||
"TaskDownloadMissingLyricsDescription": "كلمات",
|
"TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).",
|
||||||
"TaskExtractMediaSegments": "فحص مقاطع الوسائط",
|
"TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل",
|
||||||
"TaskExtractMediaSegmentsDescription": "يستخرج مقاطع وسائط من إضافات MediaSegment المُفعّلة.",
|
"TaskMoveTrickplayImagesDescription": "ينقل ملفات معاينات التنقل الحالية وفقاً لإعدادات المكتبة.",
|
||||||
"TaskMoveTrickplayImages": "تغيير مكان صور المعاينة السريعة",
|
|
||||||
"TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة.",
|
|
||||||
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
|
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
|
||||||
"CleanupUserDataTaskDescription": "مسح جميع بيانات المستخدم (حالة المشاهدة، والحالة المفضلة وما إلى ذلك) من الوسائط التي لم تعد موجودة لمدة 90 يومًا على الأقل."
|
"CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.",
|
||||||
|
"Original": "فريد",
|
||||||
|
"LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {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