Compare commits
3208 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd9028354b | |||
| c77489a5be | |||
| 25f204b330 | |||
| 89dded387d | |||
| a9b677f0ce | |||
| 6b918be799 | |||
| f259682391 | |||
| 8a059c988e | |||
| 8512940ccf | |||
| de2b11f69a | |||
| df1fba83a8 | |||
| d98ae74c8a | |||
| 6484646122 | |||
| 52bac14a2e | |||
| 0be589bc5f | |||
| e083e133eb | |||
| c787e671c3 | |||
| 31ff93c3f1 | |||
| 289f174e2b | |||
| ea03f3fc4d | |||
| 740fc93c13 | |||
| e94bd3fcb9 | |||
| dba469750b | |||
| c7fe6076cb | |||
| 356f578014 | |||
| b151ed4c55 | |||
| 9455e3b52b | |||
| 60e2656541 | |||
| fcb1a8a6a7 | |||
| 9e5829151d | |||
| 1f0a713f9b | |||
| ff49dd4512 | |||
| 3cf83b5bf7 | |||
| c51ce55d32 | |||
| ee9268957d | |||
| 5d1858d5da | |||
| d59abea1f5 | |||
| e6b79334d8 | |||
| 88d2a44f08 | |||
| 78e47d3cd5 | |||
| 6b6af347da | |||
| dccee96cf1 | |||
| 92b24de7cd | |||
| 1225a4887c | |||
| 2a82857570 | |||
| 831bec3630 | |||
| 55cbf2478a | |||
| 20a850b9e9 | |||
| 11c649a7af | |||
| c1e5a2077b | |||
| dc96f626dd | |||
| 46f48023f4 | |||
| e3d83f6dc2 | |||
| fc484e569f | |||
| a92f3e2480 | |||
| 064c447528 | |||
| 64c1bcd9e6 | |||
| 0cca4a2ebe | |||
| c4c26a76f1 | |||
| 1a638431d7 | |||
| 5d5fa21630 | |||
| e78ace4664 | |||
| 37596c412c | |||
| d200021243 | |||
| 1a999e202f | |||
| fd748b29e9 | |||
| 775d1e3cf1 | |||
| c6a1df9a79 | |||
| 77861a4c6d | |||
| bf1e1c3139 | |||
| a564a1d808 | |||
| 22ac935f9b | |||
| 02e2bcb417 | |||
| 3445259cde | |||
| c20c32c17d | |||
| 3fec766890 | |||
| f208a24213 | |||
| 9e9dfb3f4d | |||
| 83eecf09ed | |||
| 1aebe8d0dd | |||
| bb64e482df | |||
| 1841a72ca7 | |||
| 997d4aa1cf | |||
| d517e86333 | |||
| 8bcfc712fb | |||
| c0cf2fd78e | |||
| 0a7de0e9b6 | |||
| 1e2a127dac | |||
| 5b8cd215e4 | |||
| 7583edf3fe | |||
| 2f219a1a81 | |||
| 9127c38297 | |||
| 0c379f8b9f | |||
| d2b617bdf4 | |||
| 6d6f6d9356 | |||
| 8ffb20ebe3 | |||
| eed7b9da0c | |||
| 802381b2bc | |||
| f265c861d2 | |||
| 1dc7b4b5e4 | |||
| c48aa2b255 | |||
| 66859802f9 | |||
| 433c8e987b | |||
| aa477ca48c | |||
| 65b502afa4 | |||
| 06c0b44589 | |||
| d651f2cbb7 | |||
| 8b5be8ea4b | |||
| f4e82c560d | |||
| c23b3e93a6 | |||
| de447d2d0b | |||
| 95b1272018 | |||
| 11d111da7c | |||
| 638dec0f04 | |||
| a0ab6e406a | |||
| 23242c0f52 | |||
| 48bf70e825 | |||
| ada0b96872 | |||
| 0e4917bba9 | |||
| 8169d31e86 | |||
| 75b83aa163 | |||
| d2022de970 | |||
| 8db1cdacb4 | |||
| 527d171a6a | |||
| 20620cfa7e | |||
| 4d03ca078d | |||
| 775e2cca47 | |||
| 7cb2486d3e | |||
| 02a3ecc9fe | |||
| 54435398af | |||
| ffc42883de | |||
| 0cf0371a43 | |||
| f5156bcea7 | |||
| efdf3b2c9d | |||
| c3d3163392 | |||
| c91d5ca483 | |||
| 5f0982970d | |||
| ee05da70f4 | |||
| 04c283c48d | |||
| 836945c95c | |||
| bd4c180c07 | |||
| e1f5290365 | |||
| eefffcfb1b | |||
| 9e088a5e9d | |||
| 317c02bf06 | |||
| 22724c269c | |||
| 2a48782b6b | |||
| e7c3039fde | |||
| 2afba02b59 | |||
| 94928c2930 | |||
| 2c25191291 | |||
| ba2f3f2172 | |||
| aa5cba9347 | |||
| 5f40452f57 | |||
| 2dd9b1723b | |||
| ee54839f28 | |||
| c2f054a25e | |||
| f095d5c99c | |||
| ab93f9809a | |||
| bbb9a62357 | |||
| 82ffed699f | |||
| 4751ea8396 | |||
| c15d8fbe58 | |||
| b379468b47 | |||
| 0deb3eae21 | |||
| 0c1042ec5c | |||
| 05d0de5120 | |||
| 2fa217d5d9 | |||
| a65b5a5d82 | |||
| 7bb42e95d8 | |||
| db536502a1 | |||
| 47c8f1a2e6 | |||
| 30a0f11515 | |||
| 9bf5123a00 | |||
| f337b53ae3 | |||
| aea6050d71 | |||
| 13d5e0761e | |||
| ce28d0284c | |||
| 1a0bb9c3e4 | |||
| d0c71b4b67 | |||
| b3f062956d | |||
| 1a853a780c | |||
| 5c47ddeb2d | |||
| b51deb5d01 | |||
| cbf5ea69be | |||
| e139ffefe6 | |||
| dc0a8deb40 | |||
| 97e93cd10a | |||
| 03c934cf21 | |||
| 92d0d70258 | |||
| d44298993c | |||
| 12300d4115 | |||
| b4f08f61a6 | |||
| 861a25be41 | |||
| 3e175109a6 | |||
| fb2210f2fd | |||
| e928918201 | |||
| df607e5772 | |||
| a7cc470645 | |||
| 4e6421b928 | |||
| df48e8fccd | |||
| 58111bf204 | |||
| 8c02e75fed | |||
| 6f3f1cb4b5 | |||
| dd27997deb | |||
| a1f70d1d4d | |||
| 7da0bac643 | |||
| b3ab2a451c | |||
| 850f836ebd | |||
| d9fa9d03da | |||
| 76c20dc3d7 | |||
| 4568e222d1 | |||
| 344025226a | |||
| f546fcffce | |||
| 068c2d4d00 | |||
| ccf5a902e5 | |||
| 8c72cf9057 | |||
| 1ce14aa231 | |||
| 643485b879 | |||
| 5b3d9f26be | |||
| 70674fbce7 | |||
| f48c0799c0 | |||
| 3bc646187f | |||
| b692ebde6f | |||
| d2a665624a | |||
| 10c8b8ceff | |||
| 92edfc7312 | |||
| eeaeb80f0f | |||
| 6204572ddc | |||
| 14f2f45f20 | |||
| 8ac6c9d7a7 | |||
| 237a47b8ed | |||
| 96bdf606e2 | |||
| 5cc4dcf10b | |||
| a0a3c39606 | |||
| b4cc35b109 | |||
| 72eeb7eb35 | |||
| 44f97411a8 | |||
| ce2296c95d | |||
| 3fe0500746 | |||
| a8daaa787a | |||
| ae9aef9899 | |||
| ad9be91f45 | |||
| 216512788c | |||
| 2483a9c901 | |||
| 9147ed90b7 | |||
| a4ce8c8c52 | |||
| dc83d36193 | |||
| 7dbc466a58 | |||
| 5bb36bc87c | |||
| 7301cd259b | |||
| 5ba5a9dfc4 | |||
| 66e7c60767 | |||
| 22dd7ef093 | |||
| e03bb4f280 | |||
| 4d15283473 | |||
| b1dfbffc4f | |||
| a8de8dcc48 | |||
| e7cdbbcacb | |||
| 060ba2a5be | |||
| 72a21e1ef4 | |||
| cbfb498b0f | |||
| 0ec11c532e | |||
| 65201749f7 | |||
| 8c569183be | |||
| c3665f04d6 | |||
| 2406e0ad49 | |||
| a9490b0838 | |||
| 77e1c69f6b | |||
| f2d8139bef | |||
| 92594dba1d | |||
| 9a28ea7672 | |||
| b87c6c24d8 | |||
| 0c166ff36a | |||
| b7c9530ac0 | |||
| e20f102cb9 | |||
| 7856cc5bb3 | |||
| 663fdf6e2f | |||
| 1867aed769 | |||
| 72aa6150b5 | |||
| 247ae71777 | |||
| bbf5d6712c | |||
| d415f6a9f2 | |||
| 1912881d05 | |||
| a85d9e5442 | |||
| fe2f5b2d8f | |||
| cae64feaf9 | |||
| 6338757a9b | |||
| 60eb08c834 | |||
| cbee0bd6c8 | |||
| db9da54391 | |||
| 5d7f9ced17 | |||
| e3825fbf96 | |||
| 71db2ae1c9 | |||
| b7f12e4291 | |||
| 20f18fc391 | |||
| b6185cc2cc | |||
| 265804a834 | |||
| 9e09de5f1e | |||
| db5f85272f | |||
| cf2f3d0dca | |||
| 0d46b3fa19 | |||
| 768046e889 | |||
| ac940ab25d | |||
| 3cee69d23a | |||
| deaa164f25 | |||
| 68bb7acf95 | |||
| 0eefe3200c | |||
| df65bae58f | |||
| b0443dc812 | |||
| 63545ee732 | |||
| 253d099738 | |||
| 2ea27f2597 | |||
| 2c4b68d719 | |||
| a70f9c0673 | |||
| 540a35cb0e | |||
| 1d9a2ff6fc | |||
| 01a5d71b4a | |||
| 4f11fa53cd | |||
| 8f6540118b | |||
| 089618b8a6 | |||
| 6f87037c78 | |||
| d9b36c0616 | |||
| df2bc9767c | |||
| 508810d5c7 | |||
| dc6770ecaa | |||
| 25b8702a42 | |||
| 5a5aa510c5 | |||
| 5d7777095e | |||
| 95ad5b6fbe | |||
| 9e3227ba0b | |||
| d725c87cae | |||
| 6c3bf03bc3 | |||
| 20c04f32be | |||
| 29bafc6215 | |||
| d3279ef923 | |||
| 291e210e63 | |||
| 535b1aaba9 | |||
| 48cafadbdd | |||
| 39d442c2b3 | |||
| 2bf590b6c4 | |||
| 2e80832154 | |||
| f64e7c1a61 | |||
| 3edf593a18 | |||
| 7ffe41ae9b | |||
| 10a7c327f0 | |||
| a32a2cabd8 | |||
| 7d77870daf | |||
| 45d7233485 | |||
| d03afb5d47 | |||
| d5da52d0fb | |||
| 25714acd38 | |||
| afe05779cd | |||
| 8da007f4eb | |||
| 58b2630968 | |||
| 05e03b3ea4 | |||
| c2781e834f | |||
| 557348831d | |||
| cbf03250f9 | |||
| 7a3bc7086e | |||
| 8047c66869 | |||
| 9d17e2ce9a | |||
| 1f855a7fd7 | |||
| 6310b8f4aa | |||
| 839146b8c7 | |||
| 817a6300ea | |||
| 3a5effaa52 | |||
| 6e8ce9d23d | |||
| cae120cfd4 | |||
| 734e0f7128 | |||
| 4c76439f4e | |||
| 2488d4db53 | |||
| 565987faff | |||
| e14402c6a0 | |||
| ccfc40f6fc | |||
| d69a331b87 | |||
| 01fd66c35a | |||
| 73b33fe697 | |||
| 9e730a2b85 | |||
| 6395b0e945 | |||
| 79d16b98f1 | |||
| eb4fa8d85d | |||
| 7e2d5dfa5d | |||
| f785ba8932 | |||
| 63cf4a2d67 | |||
| 9e270bb53f | |||
| 3bafcb6b4e | |||
| b770a40150 | |||
| 3b50b58aac | |||
| 61ad27845b | |||
| 47bb8563ca | |||
| c2c0df0e88 | |||
| 22f0f8cd60 | |||
| 5af10f1c6b | |||
| 67b322025d | |||
| c58a438ad2 | |||
| c3f5a6f9e2 | |||
| 90422448aa | |||
| 66f7019bf3 | |||
| e0e5e29ba1 | |||
| 5f9010e4b9 | |||
| dd40f272cb | |||
| 9abb018fe8 | |||
| d38a22901c | |||
| 0b8eace5bb | |||
| 16b69ef3cc | |||
| acd556d6f1 | |||
| 2a5db95ef2 | |||
| 3c2c71a7da | |||
| 838ef0cdc7 | |||
| 68229fdd72 | |||
| 60d9d2c1b3 | |||
| 5b71c17e99 | |||
| 4497b522f7 | |||
| f0a209742f | |||
| 2ccd568ea2 | |||
| b8ceeab46e | |||
| e504a6b97a | |||
| 4bc8e5031a | |||
| 8a44a798aa | |||
| 6423a7f89d | |||
| 878cdb31fd | |||
| e7981c2e59 | |||
| 3d4a166b2c | |||
| 29a4bb42f4 | |||
| b5d9773704 | |||
| e650089e8c | |||
| ea9b0cb827 | |||
| aaa1eb95ed | |||
| 5a48886bcc | |||
| 43f51d44f2 | |||
| ea7eecccb1 | |||
| 72b7e6b06d | |||
| 0c54f0e36f | |||
| 80560c8eba | |||
| 03c2f7cdcd | |||
| 8b44187299 | |||
| 32eb094f09 | |||
| 811db632d2 | |||
| 1543c50d98 | |||
| 7f7b609b7a | |||
| 4bb8ad5b4c | |||
| 5bede9c89c | |||
| 8af4853964 | |||
| b67e68c83e | |||
| 8eca3e102c | |||
| 09a6e26055 | |||
| a070680c46 | |||
| e655f599bf | |||
| 1791737863 | |||
| ea4f2783a9 | |||
| fc532e30d3 | |||
| c4a79c0e37 | |||
| bba95401bb | |||
| 5c887e1d9d | |||
| b9024945be | |||
| 1479c6ebc5 | |||
| 36c6742532 | |||
| cd9a1402cb | |||
| 2e0745e1b2 | |||
| cd69d46d1b | |||
| f73acb88f9 | |||
| 06622dba80 | |||
| d488361d3f | |||
| 1eee95b31a | |||
| a806229848 | |||
| 0a49932835 | |||
| 6b8cff6dba | |||
| 4d5b0b9583 | |||
| 859f216462 | |||
| 5e4d1cbdab | |||
| 7ce4d61b31 | |||
| c17c59aea6 | |||
| 20952b5c26 | |||
| 7b53161041 | |||
| 5356845e74 | |||
| 298d75abf6 | |||
| 5e57a6d61a | |||
| 97318bfd07 | |||
| 0dc7d74663 | |||
| 89be51441f | |||
| 7dc5b9f7ee | |||
| 5fef8400b9 | |||
| 21ab4f0aa4 | |||
| a15586f1a2 | |||
| 0cdb13cc57 | |||
| d2750b87e9 | |||
| 5c6c7a4459 | |||
| 7e33e534a0 | |||
| 84fd20c05f | |||
| 3eea1840ff | |||
| 0c2c75417f | |||
| ab090747eb | |||
| e2765c34d7 | |||
| e512184d6d | |||
| 898fe7c443 | |||
| da863a44e0 | |||
| ad3b8e6da2 | |||
| 378c0eca3d | |||
| 7ca73b4fca | |||
| f8bfb5dc08 | |||
| 8be8a74239 | |||
| b7d2971b46 | |||
| 5fc9126bd5 | |||
| fb2273e47c | |||
| de5d569197 | |||
| 962131c610 | |||
| 379745f822 | |||
| 31a3d05e0c | |||
| bab5202d5c | |||
| 308d9beac4 | |||
| 1c649f82bf | |||
| 892e5116b4 | |||
| 50723e890d | |||
| a0f54be69b | |||
| 7289e89ceb | |||
| 68a26f7bb6 | |||
| 20304d5b5c | |||
| d026791864 | |||
| c196270242 | |||
| 8d872c8b5a | |||
| 67fe2eebd4 | |||
| 793fa74096 | |||
| 5c03988e3c | |||
| 00adb257e8 | |||
| 49086ea93c | |||
| 0db5652961 | |||
| b895c845a8 | |||
| a2e45c3ef7 | |||
| 8a726e9c89 | |||
| 760e346ab9 | |||
| 7edbdb2333 | |||
| 75478d5ff1 | |||
| c94a13ef54 | |||
| bb2ed5a116 | |||
| a97f5853ad | |||
| 3c40f0ccf0 | |||
| ecbd374dc7 | |||
| d318946791 | |||
| 262b7e250c | |||
| 28a67f4c74 | |||
| 0bfcf96773 | |||
| 6a20750bfd | |||
| c6937325fa | |||
| 3ac8a5cd86 | |||
| d17ced4c45 | |||
| 31ad78d28a | |||
| f31d756185 | |||
| 5a9337c2e2 | |||
| 49b124e7bf | |||
| 031bee20d9 | |||
| 124f6da70c | |||
| 5e3f56a1b0 | |||
| f2be750e2e | |||
| 82b1cdb957 | |||
| bc1f99f6af | |||
| c031eb5829 | |||
| 426fe24894 | |||
| 87e49b8c5f | |||
| d372aa469f | |||
| 7c80ea515f | |||
| 53e92ed3dc | |||
| bdcac383fd | |||
| eb64716012 | |||
| 8062abade9 | |||
| 4acf26b73d | |||
| 00947efe53 | |||
| f35b53a071 | |||
| 2211f99f36 | |||
| 1a6378f24a | |||
| 3702c308b3 | |||
| d5c08a5f51 | |||
| d83286a5ff | |||
| 8d9c9bbfa6 | |||
| a15d2e6587 | |||
| 9f81317ecd | |||
| 4e3f1a926f | |||
| e6e31449f6 | |||
| e573ddbb25 | |||
| d510fd3b71 | |||
| 5a316915a5 | |||
| 3d6cba7b43 | |||
| 9c7716c4a4 | |||
| b1dd85a5b2 | |||
| 02091c7969 | |||
| d4b43f58c5 | |||
| 66bd71e8c9 | |||
| 56b9f971e4 | |||
| 6d444ebe99 | |||
| 237eafed35 | |||
| fbc5069fb8 | |||
| d23c44589e | |||
| 42cc500b05 | |||
| 81760192dc | |||
| 2cb077423d | |||
| de8aaaa5e5 | |||
| b9ebd4e1d6 | |||
| 8fdf1e841c | |||
| 9df92d0262 | |||
| a07d5aa440 | |||
| 54bd222605 | |||
| 6487258136 | |||
| d1935a4439 | |||
| 026c30642e | |||
| 036d036a61 | |||
| 2092d44627 | |||
| c6e7e64ba3 | |||
| a8f5ad6435 | |||
| afa0c3a1b0 | |||
| b3132d57b2 | |||
| 0a2a6b558f | |||
| adb9926928 | |||
| 3ce25007b5 | |||
| 5690ada2a7 | |||
| 76481186e9 | |||
| 8d2d2341c8 | |||
| 4e20d282f7 | |||
| edc3ce1ba4 | |||
| b9249ff09a | |||
| c3b2ffa97d | |||
| 4e3b8ee3c2 | |||
| a749ed4837 | |||
| 67ba6be6e2 | |||
| 7a47e6617d | |||
| 4a4c6e7df2 | |||
| 5661528862 | |||
| 696e9d6b64 | |||
| c0aa465827 | |||
| a6120ae27a | |||
| ba8a165aa5 | |||
| 833d7072ed | |||
| 9829137001 | |||
| c686214f56 | |||
| 2252d7ea6a | |||
| e7fbfca2d7 | |||
| 9ca959a20a | |||
| bd8e26ecab | |||
| 451b34dceb | |||
| 02761db660 | |||
| 42b7e9fa62 | |||
| edf6c25e17 | |||
| e91aac65cc | |||
| 01d5a18af8 | |||
| 70c1142f8d | |||
| 8b6b162073 | |||
| 5199fbe0cb | |||
| 924de62dff | |||
| 4cba7d8684 | |||
| f3f9ab1360 | |||
| 682d1d85ce | |||
| a1cc9a2049 | |||
| a7f7b3e572 | |||
| 7c32a7c2c8 | |||
| e842579f25 | |||
| bdd9134a0e | |||
| a01552e88c | |||
| 824957ae85 | |||
| af335d5565 | |||
| 2f9eb51868 | |||
| aebbc17643 | |||
| 84e78e1e20 | |||
| 89bb747ee3 | |||
| 62e37dbd09 | |||
| edef9cb936 | |||
| 3ae02c3050 | |||
| a4016616a1 | |||
| b4855611c4 | |||
| 1b44f6d220 | |||
| b0f0af087b | |||
| 1344f7255d | |||
| 39fe3b0fd6 | |||
| 0ba676b5e7 | |||
| 4d6897c138 | |||
| c7c6ba09e9 | |||
| c06baa67f1 | |||
| cdb7946c00 | |||
| bdb5da8df0 | |||
| e961c8d3aa | |||
| 3eb1a9eef8 | |||
| 67aead8fcc | |||
| fd764d0576 | |||
| dad55d7922 | |||
| fb32772512 | |||
| 918ce65acd | |||
| 9f03b9ee71 | |||
| 2235de1a2d | |||
| 8804c89f04 | |||
| 2e8805015c | |||
| f435ca2961 | |||
| 71c3761b20 | |||
| e4c441043a | |||
| 8a655a5d6e | |||
| 777c21ce87 | |||
| e22ff09691 | |||
| d0f685e87c | |||
| 8f71c417a9 | |||
| b62977c494 | |||
| 8d11136c1c | |||
| 4a7ea43095 | |||
| 8fe4bd2751 | |||
| 38bb819a24 | |||
| dbe75ad18d | |||
| 760441b45a | |||
| 56645b601b | |||
| 885e4bc99f | |||
| b04e5510fd | |||
| 806000725b | |||
| 71270641d3 | |||
| bf4f2bec91 | |||
| dafad3a7a3 | |||
| 182a1cc3fb | |||
| 4b7664aaa6 | |||
| 2050aef1e5 | |||
| 390af30bf6 | |||
| 698f48b1fd | |||
| 2e5cc61ac6 | |||
| 8d97fb7633 | |||
| 8a41c393bb | |||
| 6ae38359d7 | |||
| 7ddd1e3497 | |||
| 20a0993aa8 | |||
| 57d58056de | |||
| 06c6fa4d01 | |||
| 41f884e129 | |||
| 77a74c8839 | |||
| c198788017 | |||
| 4cbfa21b52 | |||
| f3754de394 | |||
| d47ad013cd | |||
| 8c4372d0d3 | |||
| 1c7b9145c8 | |||
| c477f53ee6 | |||
| f99f03dc33 | |||
| 2ddd786819 | |||
| 6e604f98e3 | |||
| 729404d05f | |||
| de50dfdb7c | |||
| 7bda522f0a | |||
| 6c39fb0649 | |||
| a7342ac77e | |||
| 5d45b8bbdd | |||
| aa0ff38ed7 | |||
| d55aa3b569 | |||
| d86a99fb32 | |||
| c687152724 | |||
| 65ec539875 | |||
| 6dba0792d2 | |||
| df78cecb31 | |||
| 3d8687f69d | |||
| 92196897a9 | |||
| 4206edfb13 | |||
| c08e63ab80 | |||
| 03646b4f87 | |||
| d9fa860b0c | |||
| 93d8494ddc | |||
| bd982958fa | |||
| e280b62f5c | |||
| 2bb050de40 | |||
| f3ed3bf0bf | |||
| 79457536f2 | |||
| 048f930da1 | |||
| 6aa8108fce | |||
| c234f75d7e | |||
| 064b634f77 | |||
| 8d83184cd1 | |||
| 7a5112bee5 | |||
| 0c549c6bda | |||
| c48e704502 | |||
| bec66895d9 | |||
| c9f1e8a8bb | |||
| ac209e7ee2 | |||
| 525256e15c | |||
| 3b8c965f4b | |||
| 8f8da8e6ea | |||
| ac9b81abea | |||
| 1c39c55423 | |||
| ca11273b37 | |||
| b532a60c3d | |||
| 941662e9f2 | |||
| 4d1e4c3ebe | |||
| f66fd9bcae | |||
| f5c5ecd1b9 | |||
| f9b7855d19 | |||
| 418a8af99a | |||
| ce3b4661de | |||
| 4b811f38b0 | |||
| bba2823065 | |||
| 5547e9658d | |||
| e14cbb19f5 | |||
| 0613a001c5 | |||
| 2970ba69f8 | |||
| 2c6b811d4d | |||
| d5a3caf961 | |||
| 7e64778546 | |||
| 1afd0d7c28 | |||
| 3027a3c3e8 | |||
| 3d7df100ff | |||
| 4de5030196 | |||
| e3bfe368db | |||
| e45fe0aaa0 | |||
| 807d758bfa | |||
| 7c5164b9a5 | |||
| 1e15fb8e43 | |||
| ae996b4b9a | |||
| 3259a7eec9 | |||
| 39a5aa1d63 | |||
| dbe378ad82 | |||
| a316c11974 | |||
| 2fd05c2464 | |||
| 8adabb946e | |||
| 3f251b9c0e | |||
| aadd60c3ad | |||
| 99cc994865 | |||
| da0355ca88 | |||
| aaa7c0934a | |||
| 03c70f4dfa | |||
| 0704609fa5 | |||
| d26569b26f | |||
| 007e93e526 | |||
| 8feec0284d | |||
| eaa79fb3bd | |||
| 3af5102e93 | |||
| d936460d83 | |||
| f51649c59f | |||
| be1e33b555 | |||
| 059645dec7 | |||
| 6439becd7d | |||
| 917fbc1ea2 | |||
| c97fee90b7 | |||
| 35d04946b4 | |||
| d0d71d626e | |||
| 5a1b39c67e | |||
| a8cbd37697 | |||
| b2bac94009 | |||
| d88b7e2a17 | |||
| 68bf35d83d | |||
| a78e6587ac | |||
| 21f715a321 | |||
| 18a5dfd81f | |||
| 2a7b5e2efb | |||
| 0d63b0361f | |||
| 4e301ddd24 | |||
| bc182276ac | |||
| 4980523d10 | |||
| 85baf58b55 | |||
| d7a4d02564 | |||
| 0e6f4c45db | |||
| 932cadce3c | |||
| 3926ea9c69 | |||
| dd1495c881 | |||
| 8c27e6aade | |||
| ba2774eeb5 | |||
| 8e854a8d64 | |||
| 86f5ed198f | |||
| cc57520c71 | |||
| 8d9f8960b2 | |||
| f66573620b | |||
| 3544a0e7f8 | |||
| 9c9db90886 | |||
| c4bc4d22e9 | |||
| b107c70a0c | |||
| 084069441f | |||
| 8b01433e61 | |||
| b72902b8f4 | |||
| 354e455ae7 | |||
| 8aaed47e39 | |||
| c7598aaf12 | |||
| cbe2d16d9b | |||
| 953eb97513 | |||
| b340b3b699 | |||
| f9f2579904 | |||
| 3a90653edd | |||
| a8ae18f43c | |||
| c235dd934a | |||
| 3e7c2cb0c2 | |||
| 1c9398b5b9 | |||
| 6a9c818e67 | |||
| 753baf85b6 | |||
| 7685c2a6b7 | |||
| cf1203566e | |||
| 052e6a475b | |||
| 8890acef3a | |||
| 72570ee21b | |||
| 100c94ad83 | |||
| 2ea3bf20a7 | |||
| b1cb7c7259 | |||
| 7510dfc5c5 | |||
| b18bbba23f | |||
| 4e28cea2a3 | |||
| a9bafc5efd | |||
| a04ff3343b | |||
| aa09fb28d2 | |||
| e6900c18b9 | |||
| 221a17a5af | |||
| fc638c608b | |||
| 71d9d96d81 | |||
| 5a8b999509 | |||
| 720d7e9d8d | |||
| c69be5934d | |||
| dae186fb03 | |||
| 076ad78355 | |||
| 421aa3a95c | |||
| 153d186a1c | |||
| 2238835868 | |||
| e0be4542ab | |||
| fab841bc7a | |||
| 789a28a966 | |||
| 7cde652ed1 | |||
| 5359116e72 | |||
| 17edfd215d | |||
| e292b46cca | |||
| d091b20ebe | |||
| 50a53562a1 | |||
| 55a479590b | |||
| 8874bb64fb | |||
| 38afba3075 | |||
| ba48e30128 | |||
| 77397b6877 | |||
| f50fa0554a | |||
| d0dd9f629d | |||
| c82637e760 | |||
| 152cfb3f07 | |||
| 7f579181fd | |||
| 3e0f39b6f1 | |||
| 244d3b1a5b | |||
| 7c24302f7c | |||
| 6cafc3a1e8 | |||
| 1ab0d31baa | |||
| b2fadc5a90 | |||
| 38f3d85909 | |||
| 3694100265 | |||
| af44f271ab | |||
| 9984f6aef9 | |||
| 51a1debc39 | |||
| b8a68f62a0 | |||
| 5ded188f51 | |||
| 12c5dda1fa | |||
| 25146049bf | |||
| 5598ee0c78 | |||
| 6e4b0cbcbf | |||
| 572cf29974 | |||
| 5601d19002 | |||
| e81dd5df76 | |||
| e7919d5a47 | |||
| 6f634fbc21 | |||
| 7478ece1ff | |||
| cd72b6f477 | |||
| fab96de4c7 | |||
| 0ffa17cf67 | |||
| 777549a15f | |||
| c07ded004d | |||
| da3e96a9d8 | |||
| d6e8a03ddf | |||
| b13cbd1e54 | |||
| 6b2e5c154b | |||
| 137a4d1e0d | |||
| 1725550acc | |||
| bd91e173b0 | |||
| 47a11b3e64 | |||
| b5e57519ff | |||
| 20845bbcd4 | |||
| 739c10ade6 | |||
| 14ea2d72a7 | |||
| 4a9ea97ea1 | |||
| b017a94353 | |||
| 15b65dd844 | |||
| 079ea8c39d | |||
| 4b949dcd72 | |||
| 2626cf4253 | |||
| b260c8aaec | |||
| 1ece46473b | |||
| 890c3cc8b0 | |||
| 7b45c9f1c5 | |||
| 58fb2f5ea6 | |||
| a79f3e47ba | |||
| b3b9db9ff6 | |||
| 9aed245241 | |||
| aa03fdb445 | |||
| 7cb8356598 | |||
| ac347755fd | |||
| b16cb15e88 | |||
| 4989c37964 | |||
| 06849c5814 | |||
| 78b67a6f5e | |||
| acf79df4d0 | |||
| bc5a9caf63 | |||
| 7b34b07cdc | |||
| 8df1a1bf17 | |||
| 1143b0f2d2 | |||
| 86883336fd | |||
| 62d77c5811 | |||
| 8397dddbbe | |||
| 47ef94d8c3 | |||
| 8aa4a485ed | |||
| cb4ef9c9ea | |||
| 2f80852a7c | |||
| 190a580642 | |||
| 6ba85f5069 | |||
| 707b5921fb | |||
| 2e25e68444 | |||
| 034260e426 | |||
| b4eda8bbff | |||
| 93a1b7fb52 | |||
| 8ef44c3520 | |||
| 449de57fc7 | |||
| cbe29e233d | |||
| bef56ff124 | |||
| 5a05c0f858 | |||
| c1e13e520b | |||
| cebe92bd8f | |||
| 6f8cfc7914 | |||
| e7e98b83d2 | |||
| 4b72bb9d28 | |||
| 221068874b | |||
| 6028d8b2f1 | |||
| ddaafe9310 | |||
| 139e38731a | |||
| d25056cb35 | |||
| 5c80a7091b | |||
| 5faf190202 | |||
| 169b114ff6 | |||
| bc67326573 | |||
| a32543533d | |||
| 6b6e40ef96 | |||
| 8127b7ecf0 | |||
| 09425ccbe0 | |||
| 61fbc4e3b5 | |||
| 158e4f85da | |||
| 8b1107d2e1 | |||
| 59ffa9084f | |||
| 19df673c50 | |||
| 5f20894413 | |||
| 7349874804 | |||
| fda5dc7e89 | |||
| d60b45a667 | |||
| ab2e69a76e | |||
| 6a836338a5 | |||
| 5a02365605 | |||
| 26b38c4f64 | |||
| 9b7edf2960 | |||
| 7050f64fae | |||
| 4623a989d8 | |||
| 87b942bd6d | |||
| 87ee5cc627 | |||
| bff8fe8b70 | |||
| 1495882dc7 | |||
| 2e50d84f2a | |||
| d32716f4c5 | |||
| 876aa4eda0 | |||
| 3673aee8e9 | |||
| a758191ee0 | |||
| 99410249c7 | |||
| a705f2ad30 | |||
| 33223dedc1 | |||
| bd8e8ef346 | |||
| c75e7bf656 | |||
| cb4117376a | |||
| 0d37920aad | |||
| 0da6e76200 | |||
| 5f5934a6ee | |||
| 85b7a2f4f5 | |||
| 3dcfd30a04 | |||
| b5a0f65783 | |||
| 3862e6f3a4 | |||
| 1d4e2ec50b | |||
| 8b85485510 | |||
| 722ce3ac8b | |||
| 1e132f2808 | |||
| d007e0a172 | |||
| 3ddd722cc1 | |||
| 82d8189966 | |||
| 2d533eb004 | |||
| f9c899701f | |||
| e9f62fbb09 | |||
| 5b2f09318a | |||
| 8c260c43a8 | |||
| eee793302c | |||
| 0d1fdf6e60 | |||
| 64398d8f30 | |||
| cab736b573 | |||
| 93071dd81e | |||
| e8fcb8f91a | |||
| 33cacfe884 | |||
| f624f7f05a | |||
| 624195d870 | |||
| ab2ef66263 | |||
| 4ea0372212 | |||
| ff31912e8a | |||
| dcefed2e4c | |||
| 55bbc4f585 | |||
| 0f2bb99b39 | |||
| 85342eeed3 | |||
| 374a6a668a | |||
| e3be3195ee | |||
| 503279f3c2 | |||
| f8bb54024c | |||
| 6e53fc606a | |||
| ab810c48af | |||
| 13bb9183af | |||
| 2c5b6ea690 | |||
| a8efa2e266 | |||
| e73eb2fd86 | |||
| d38fa26e13 | |||
| 716f4493e8 | |||
| 3220974a4a | |||
| 6732272047 | |||
| 547f038139 | |||
| 3b0ee60eaa | |||
| a869281de7 | |||
| a4ed77c7bb | |||
| 81718e64d3 | |||
| dee0daf8aa | |||
| 8e599fb22a | |||
| acb5589af1 | |||
| 6db2771cd6 | |||
| 06d4e0a19a | |||
| 3b18c6c14f | |||
| 300359acf2 | |||
| 5456d0200a | |||
| 9890f66443 | |||
| aba863bc84 | |||
| ade416f5c8 | |||
| 7097267f7c | |||
| b0d8d1a86d | |||
| 2c8296ba85 | |||
| 4dd17de146 | |||
| 3a281b0b57 | |||
| 04ed625f1a | |||
| 1cddfb1b2d | |||
| 796b64d83e | |||
| 240a3687d7 | |||
| 9ed4764ab2 | |||
| f253a13297 | |||
| 744cd57dd5 | |||
| e2a5647363 | |||
| a1f324c105 | |||
| 767e0f8ac7 | |||
| 0c0ad02234 | |||
| c09973ec56 | |||
| 03a72e1917 | |||
| f9e0eaaf83 | |||
| 985f75f7da | |||
| 171cbd6c53 | |||
| 9875bc5c5b | |||
| 882509f891 | |||
| 3396502334 | |||
| b7fb99c3d4 | |||
| c82307a710 | |||
| 309a99d183 | |||
| 09a6ef0194 | |||
| 43afcb4239 | |||
| 7a78f33ac3 | |||
| d5fb538630 | |||
| a22cdf5d5b | |||
| fe0636bbbf | |||
| 13859cfbd7 | |||
| 0adadc59ac | |||
| d65ba19c6c | |||
| 5cedbd2fa0 | |||
| 735fb09762 | |||
| 79d61419b0 | |||
| 248b93e5c6 | |||
| d8eff1adb5 | |||
| c911620254 | |||
| c68a32b889 | |||
| 788819a900 | |||
| 27c94af980 | |||
| 81122665a0 | |||
| 1856e687eb | |||
| 6055793d46 | |||
| 99b670ff10 | |||
| 7a09218cc0 | |||
| a34d0523b5 | |||
| f06e900bab | |||
| 7da15a2d44 | |||
| e999cc53d0 | |||
| b7d4bd00a5 | |||
| 8c2aa849d7 | |||
| 01a759fff8 | |||
| cb0008b59e | |||
| 9cd825aff1 | |||
| 8ad52d2979 | |||
| efd6143498 | |||
| 157fae5f83 | |||
| 6d63301b63 | |||
| 9801c8c6b3 | |||
| e04f4c0bd0 | |||
| b501578584 | |||
| 308f429c91 | |||
| 1d45172475 | |||
| 085a4f30db | |||
| 7a600dc2b6 | |||
| c0c2891d8d | |||
| 06b269a2ba | |||
| f3a4db0d87 | |||
| bcd99d18c4 | |||
| c05c400c6f | |||
| 0f081d8d7b | |||
| 833dc5e3ae | |||
| 0be3df435b | |||
| f4446af57e | |||
| 253aa664a8 | |||
| 0df037a295 | |||
| ed49d743f9 | |||
| 203cc392c0 | |||
| 52ba5a7f24 | |||
| 8aa0576bbc | |||
| 5ce9cc79c8 | |||
| 1a596dfdea | |||
| aeecb3ff59 | |||
| 85c8d2d558 | |||
| 2cf4e7ac59 | |||
| e7412a91f9 | |||
| 9888d03982 | |||
| 765cc39553 | |||
| 6e58c2f984 | |||
| 295542ff18 | |||
| 9d72d9c647 | |||
| 853897ec3e | |||
| 9cf8ad7399 | |||
| fdf974c5e3 | |||
| 2920dbfe8d | |||
| 77d05f7697 | |||
| 3ffeaeffb6 | |||
| db2755675c | |||
| 7ca090f73c | |||
| bb251ad29e | |||
| 75d770e019 | |||
| 49bf116c18 | |||
| b7d227fe0f | |||
| 83f59935f2 | |||
| 37b794fa14 | |||
| 1f5c45df91 | |||
| 62e3020234 | |||
| 895d457500 | |||
| 586269efd3 | |||
| 576718fc03 | |||
| 648dd4147a | |||
| c4df743c3e | |||
| b98fead37e | |||
| 6522094164 | |||
| fcd3dfe75c | |||
| ec9a798590 | |||
| 5825443d4d | |||
| 9768b3fadd | |||
| 77a72d6663 | |||
| 08d647c024 | |||
| a77ef040be | |||
| 13e581b953 | |||
| 1cc18617c5 | |||
| 2642f65614 | |||
| 4abb2aacf9 | |||
| 904daaf2b3 | |||
| 3044f2b1fb | |||
| 826accb2d1 | |||
| d5cb35ed95 | |||
| 24c7e4be8c | |||
| abbd7283b2 | |||
| 2980aa08d7 | |||
| e2344abbc4 | |||
| 80097c3500 | |||
| 714f36caee | |||
| fb1860d78b | |||
| ce7acd278e | |||
| ae8473183d | |||
| 69fb328b50 | |||
| b8d9899796 | |||
| e58fa1964d | |||
| 1627dee77e | |||
| bbac0c033f | |||
| 6437e1dbad | |||
| 48a9e998ff | |||
| 6b6ca461f0 | |||
| 7960952a30 | |||
| 5ec64efb75 | |||
| 2440b2eae4 | |||
| 54db2857c9 | |||
| 5b8f0b7361 | |||
| 053ebe3963 | |||
| 661b0367f5 | |||
| 01da0697a0 | |||
| a3d3b670ae | |||
| 5c64a332f8 | |||
| 6fcd9b645a | |||
| 78da16654a | |||
| da20d4882b | |||
| 1f31c38d24 | |||
| 5f2fd9733b | |||
| 8a225b4e09 | |||
| af05b41937 | |||
| d618da457e | |||
| d16bdad782 | |||
| f6d33e73a0 | |||
| 7b48e445f5 | |||
| 2390f904bd | |||
| 3bee3631a3 | |||
| 9da0b2d3c1 | |||
| 7a092e4585 | |||
| 196fb6b4f6 | |||
| 9507002961 | |||
| 943ed38c2f | |||
| 496619b492 | |||
| 4772b42d64 | |||
| 5bc10953cc | |||
| 18deca202d | |||
| 84bc4b018d | |||
| 1a0598a47a | |||
| 973d117887 | |||
| c284c8f336 | |||
| df69cbc84c | |||
| 646453887f | |||
| 189d617005 | |||
| 554cd8bfe7 | |||
| 79505dea20 | |||
| 5358a46b7e | |||
| aff1599ce7 | |||
| bc7df1c8a1 | |||
| f1df1d25a8 | |||
| 47d9b472ed | |||
| 89ab8c34d8 | |||
| 600498f9c1 | |||
| 845fbcd2ac | |||
| 3cc9f19b8f | |||
| e68c642005 | |||
| 81ae950577 | |||
| 62b4496cd6 | |||
| 29b7292d15 | |||
| 791058a2d2 | |||
| b6c108faef | |||
| 72d592866a | |||
| 4052993246 | |||
| a24f6e7789 | |||
| 0d0fd49924 | |||
| 139dcb409e | |||
| 707e6e7d13 | |||
| 36abb29ddd | |||
| a700fe761e | |||
| 7577164471 | |||
| 1bce743ea3 | |||
| f85ab0364a | |||
| eb3a0d52fd | |||
| b8cd295a12 | |||
| d3ff49ee0c | |||
| d4833f1e6e | |||
| 548483ed2f | |||
| f6f39b97c8 | |||
| 21ea5e0df9 | |||
| 3cbab6a5c7 | |||
| f19f39ba16 | |||
| b9c0fd9a1c | |||
| ce520e6944 | |||
| 0ad62a95e2 | |||
| 8f62a69e06 | |||
| 34bbb98f7f | |||
| 26cd6bb955 | |||
| 97534c633d | |||
| 0a9a2963c2 | |||
| 05afc39a35 | |||
| 84fdc1f55f | |||
| 3b03c3c2bb | |||
| 980f62686d | |||
| 202f2532a6 | |||
| 78d193a2fd | |||
| 0c109b0f27 | |||
| e33c0ab86c | |||
| 3a0189069d | |||
| 2688bd9edd | |||
| 889f7bd2d7 | |||
| 0561c2d640 | |||
| b76f1ad004 | |||
| cde6153f64 | |||
| 12bdaa510b | |||
| 0e6a4acf80 | |||
| e7785f7094 | |||
| 2dcf39eff8 | |||
| 1125c5c133 | |||
| faf7cedfe2 | |||
| 52a6127625 | |||
| b552f6f9fa | |||
| 9b558fcce2 | |||
| c8eae6df6c | |||
| 5f50bd7095 | |||
| c8617218dc | |||
| a8ceae993e | |||
| a72a8854c9 | |||
| dc658db9ba | |||
| 8d8ecfe9e1 | |||
| 4b77e63857 | |||
| 19aa800324 | |||
| 85adb6b0e3 | |||
| bd2523821d | |||
| c1838a3c84 | |||
| d836f8f5d0 | |||
| 37491c134e | |||
| aa6efb7e5c | |||
| e4d990c06d | |||
| 01288afac0 | |||
| 579e3ca3ab | |||
| f61bc3ce7c | |||
| cc6004e981 | |||
| 35eb037d05 | |||
| 1eb0e4419d | |||
| 7b5ca875dc | |||
| 2d22a6c383 | |||
| f4884f1c18 | |||
| 27cc3bd185 | |||
| 9b894c2ea7 | |||
| a341808873 | |||
| 8927513f8e | |||
| 84436dfa94 | |||
| 2b73f633e0 | |||
| 3d7a452141 | |||
| 38a8557311 | |||
| 79672923c5 | |||
| 3842182a83 | |||
| 8b0d359e0b | |||
| db2903edfd | |||
| 18d22a72bd | |||
| 402cfc1632 | |||
| 9dec7e4971 | |||
| 931c224247 | |||
| f6ee6d4027 | |||
| 332d41fb25 | |||
| 8303af25fb | |||
| ee02bdb19a | |||
| e674132d5a | |||
| c9eb8bc7be | |||
| 2076a2c6d0 | |||
| 32c0f09b16 | |||
| 1264cabb3f | |||
| fb722d0581 | |||
| cb00ab9610 | |||
| 4102a1c8fd | |||
| af6d7a1ae2 | |||
| 36cae6311a | |||
| 327bb31daa | |||
| 8c2effe337 | |||
| da59adddf4 | |||
| 6f3c806a21 | |||
| 3d119bcd98 | |||
| 6264c21e23 | |||
| d5d6aa0bd5 | |||
| 7ad49fa65a | |||
| 5b8dfb48c3 | |||
| 4d557be99a | |||
| a7e022c6f4 | |||
| fc3f5dad4f | |||
| fa42669580 | |||
| 0c73de726a | |||
| ea87d21977 | |||
| a9e9e8cf44 | |||
| 9905cd307f | |||
| 92ea32b52c | |||
| 4c56f7583a | |||
| fc3050ef3d | |||
| 29c63e11bd | |||
| 64cbe21f6e | |||
| a56bb97d45 | |||
| 6edc6a1c6d | |||
| 01c656ffb2 | |||
| 078c6d0c21 | |||
| 580a8c0f3e | |||
| f0258349bf | |||
| d9080eeb80 | |||
| b504744876 | |||
| 638e8b5b47 | |||
| 9b9c40f310 | |||
| cc3a1db879 | |||
| a16312803e | |||
| 206f9fa5ad | |||
| f20e97574a | |||
| 51764f0ce0 | |||
| e698b9d608 | |||
| e2a7cc6b45 | |||
| 6eaf307be9 | |||
| 9743af5db0 | |||
| 07d02ad75e | |||
| 91f51a27af | |||
| a60318260a | |||
| c3e7e336b5 | |||
| 0b1037b497 | |||
| 7da48b7dc5 | |||
| 73bcfc6151 | |||
| dfe1a16aa0 | |||
| 4f0e685feb | |||
| fca052b308 | |||
| c449f42444 | |||
| 5ec956943c | |||
| 1ad696be6d | |||
| 92b3b762b2 | |||
| 0b29a57079 | |||
| 0dee015181 | |||
| 2f1294a119 | |||
| e609e55710 | |||
| b752ce8572 | |||
| de59c68328 | |||
| f92e78e8be | |||
| 9abc611f1e | |||
| 8e42f61a52 | |||
| 48fd3f977d | |||
| 451636e0b3 | |||
| 1fc810470b | |||
| 1c96efdafa | |||
| 8fb0711973 | |||
| aabb4f2c13 | |||
| eb1c5d976f | |||
| fd89533903 | |||
| d5ec60f0f6 | |||
| 18b896ec0b | |||
| af93e1edec | |||
| a8a5b4ad16 | |||
| 0d40883929 | |||
| 3b6645156d | |||
| 7596346fcd | |||
| 877ff60077 | |||
| 928da6e679 | |||
| c1a9ccef3c | |||
| 5f41c85281 | |||
| 18ef38b90b | |||
| 7b155e6b31 | |||
| ba4d7b2199 | |||
| 869387af34 | |||
| 5b16a80730 | |||
| adf1190584 | |||
| 1c16cf5926 | |||
| a833cf7b0b | |||
| 62a35e7ced | |||
| 7b005760c1 | |||
| b07631f0b5 | |||
| 595d8a8f53 | |||
| 35321b00cd | |||
| 8928f19818 | |||
| 76cc8fad47 | |||
| cb851d8519 | |||
| af0aff3aee | |||
| 6d4099c79c | |||
| d9672e179c | |||
| 1e291343fe | |||
| a5d0bf68fd | |||
| b8e2b524e1 | |||
| 6abd062477 | |||
| fbcc2644bf | |||
| 34b05c8c17 | |||
| e3dce02716 | |||
| ed8a70b5c8 | |||
| 35944b0776 | |||
| 2f80ee5b39 | |||
| 280eb71ae4 | |||
| 9462b1b175 | |||
| 874204838d | |||
| 0e4a936176 | |||
| 5089708e2d | |||
| e17367aa13 | |||
| 26be0978ee | |||
| de1aea9dd2 | |||
| 4c143be906 | |||
| b83cea1073 | |||
| 2418b67089 | |||
| 7e550cf916 | |||
| dce72fcb08 | |||
| adede7bb2e | |||
| 377799ace3 | |||
| 02a822c630 | |||
| 8101bca753 | |||
| 40e177ded0 | |||
| 13f732d733 | |||
| fbca4cbf8c | |||
| 45c8cd1536 | |||
| da293bbc2f | |||
| 7991568d6d | |||
| 5fc1c8cbb1 | |||
| 596981aca2 | |||
| 6d55197218 | |||
| 85cb813a75 | |||
| 5f99319985 | |||
| f34c76eb90 | |||
| adb08aff75 | |||
| 93f8bf561b | |||
| 52e391aa83 | |||
| 751e9fc0c5 | |||
| 77b0b9dc6b | |||
| 5729552206 | |||
| 929f53ac13 | |||
| c6b983ea6c | |||
| 419bee76e2 | |||
| 2f3180cc07 | |||
| b5eb917e10 | |||
| 9fed8d6335 | |||
| becbdba56e | |||
| 85b9373760 | |||
| c069541cee | |||
| 4c0f20694d | |||
| a99175d46c | |||
| 4bab9b9f5b | |||
| a5ea603116 | |||
| 8be6d9bd77 | |||
| 9a9043aa67 | |||
| 7ed58386e5 | |||
| 51660449a8 | |||
| af1a8d13f1 | |||
| 8e13e6c181 | |||
| de915ba840 | |||
| 834922aa35 | |||
| 2d4e67c268 | |||
| 48a036a2bb | |||
| 140fb72aeb | |||
| 2d4c3790a6 | |||
| 74860fe2ee | |||
| aab69705b6 | |||
| d6c88621f6 | |||
| bd275601aa | |||
| 72c04e7b43 | |||
| f281d6bfce | |||
| 62fc223d7b | |||
| e274a542c1 | |||
| cd3b453bbb | |||
| 84bc6c95be | |||
| 3862447fa1 | |||
| f85224258b | |||
| 11d5edcc5e | |||
| 4df519e67a | |||
| e9afcaa9e6 | |||
| 672403ef92 | |||
| 4fbdd67255 | |||
| d6dd93b9d0 | |||
| 80f223e706 | |||
| 0f0d709975 | |||
| 8db5e100b8 | |||
| ebc984d371 | |||
| 80c73e5871 | |||
| 5de4d29dd8 | |||
| fad95a0b22 | |||
| ebd3867c5f | |||
| 0781265baa | |||
| 9b8798d534 | |||
| 190724360c | |||
| 93acb7fbc1 | |||
| 90cc235d23 | |||
| 515698fd95 | |||
| 2596d0a4bc | |||
| ef8f9f7816 | |||
| 276ecf262f | |||
| 5c8d083038 | |||
| a2c399b4b7 | |||
| 4ecec2e362 | |||
| e072cb4123 | |||
| e44cdd4191 | |||
| 43d60b20ca | |||
| 6a61c0e722 | |||
| 9de9428825 | |||
| 13cb31d2db | |||
| 211c687609 | |||
| 3151df31f8 | |||
| db30396c26 | |||
| efed67f6e4 | |||
| 3c0d0a7d60 | |||
| 0f0254675e | |||
| 068cf1a2fd | |||
| 5f3d2904aa | |||
| e81d3a43b8 | |||
| 7006687292 | |||
| d044c65d2c | |||
| d3ae88f5fe | |||
| a598104778 | |||
| c7099f1a7b | |||
| 3955a27594 | |||
| 597ecd8c0b | |||
| 006505bf22 | |||
| 3b0102c5a8 | |||
| 5d8c49c537 | |||
| b276b6eda9 | |||
| dc502c95b2 | |||
| 1b29e4eae5 | |||
| cb5cf573e5 | |||
| 0f5dd3a722 | |||
| ea4a77dbcc | |||
| 02a6de68b8 | |||
| 1dbb7373c6 | |||
| 4bbd2aa56a | |||
| 169fca23a9 | |||
| f4058b7981 | |||
| baffc7a775 | |||
| 0140d20793 | |||
| 8ced7206f0 | |||
| fa4274f2e3 | |||
| 64d0d211b1 | |||
| aaaa6aa731 | |||
| 47d61bb83a | |||
| d5850afcc2 | |||
| 0c48b0799e | |||
| 1b96dbae3d | |||
| 244e183a2b | |||
| 5cb00a0532 | |||
| 09ce46f46a | |||
| 881a23ec7f | |||
| d53da82ddf | |||
| 177d95128f | |||
| 867a162fcf | |||
| fe0291ef55 | |||
| 1a21ab513d | |||
| 1a275e9501 | |||
| 96a8c33767 | |||
| 084284d1ee | |||
| 13b087e44b | |||
| 22b318f05e | |||
| a575e40859 | |||
| ef044e4937 | |||
| 1e1f8e7ca0 | |||
| 814395b58e | |||
| 5ac5c3c595 | |||
| 64a8daab76 | |||
| 3fb6017976 | |||
| 9379e84ba2 | |||
| 8eaa468b1c | |||
| a1c3e64bf3 | |||
| e90e1bd0c5 | |||
| 30cec00f0e | |||
| 2a0c1a13ad | |||
| 072aa0883b | |||
| 2e22c585d0 | |||
| 3240b19649 | |||
| 2f4b47e456 | |||
| f735c9128c | |||
| 56e8cb0f44 | |||
| d5253f130c | |||
| 261c6f3c7e | |||
| 2ad59e6592 | |||
| f5cf977788 | |||
| d392707ecf | |||
| cbc57fbc0b | |||
| b32a2ded77 | |||
| e7ee9ae747 | |||
| 97acfb6845 | |||
| 709197a957 | |||
| 7d003cdc3b | |||
| c0266a5b84 | |||
| 5b61c71cdd | |||
| 3423b42a8a | |||
| 942124ac67 | |||
| 58d4534176 | |||
| 93517582d1 | |||
| 75c60c2b60 | |||
| 1fbd9cfd50 | |||
| 2e6843fd78 | |||
| c073de4acd | |||
| dcd85c85d0 | |||
| 6e5bfd162a | |||
| b579fa7804 | |||
| f356313e67 | |||
| 4055debc6f | |||
| fcc907c507 | |||
| 8a90a51182 | |||
| 4c42b3090a | |||
| 626d519c81 | |||
| dae3672a9a | |||
| 640bf5515f | |||
| 476fd09397 | |||
| bfbf12914f | |||
| 91eae536ae | |||
| 404becadba | |||
| d71d33d899 | |||
| 65e72da01e | |||
| 8556bebb1f | |||
| dc5c353b8d | |||
| 9f7f877cf2 | |||
| 9a827b783a | |||
| d2641f045e | |||
| e4ef6dc604 | |||
| c8cc9bb188 | |||
| a21dd3d0c0 | |||
| b16d6658f8 | |||
| 01aab808c3 | |||
| eb1ae54739 | |||
| 5483d02a6f | |||
| 9d434eb1e9 | |||
| 43269befd6 | |||
| d8d2b06c6c | |||
| 1f9a2f6554 | |||
| 940162a8b5 | |||
| 3c2b39453a | |||
| 459cd92017 | |||
| a5aa0a773d | |||
| d1b569fbbe | |||
| 6d609f628b | |||
| 8d5eaf0f8d | |||
| de93b439ca | |||
| d11d9ef03c | |||
| f1fc8e1d82 | |||
| 9a44c37cab | |||
| 25a9e5efdf | |||
| 9352193986 | |||
| 61436ca278 | |||
| 17b6fcc48a | |||
| 9f9c5cf27a | |||
| 8fd38fbb40 | |||
| ac2c9fff38 | |||
| 8dc4877379 | |||
| d22a3a3953 | |||
| 182538d2a7 | |||
| 997c0bc297 | |||
| f9099cd680 | |||
| e8b47c33b6 | |||
| 6618fdd86b | |||
| 0b5ef5e257 | |||
| 4f36e6119c | |||
| 24b58d9615 | |||
| 4621c21907 | |||
| a53f6005b3 | |||
| 8bad1b2dfc | |||
| 856ec02083 | |||
| 45c63bdac7 | |||
| a5202b8eb8 | |||
| 766e47a757 | |||
| 0026ef7db7 | |||
| 368c7927ff | |||
| 1dd1ec3a0d | |||
| 6ed5c83b05 | |||
| 3efd1e56c4 | |||
| 1e18c9e309 | |||
| c79048027c | |||
| b2c981fca1 | |||
| 88af4d608d | |||
| 2008b35e8e | |||
| a082714ad5 | |||
| 2f28fde4e6 | |||
| e3004b9db7 | |||
| b192f4f80d | |||
| 809331b9fd | |||
| 3828c8bf89 | |||
| 4731750684 | |||
| 54f2308944 | |||
| afdd44323e | |||
| 9b88d5814c | |||
| 02a924e97d | |||
| e167439ed0 | |||
| 9f26d5a401 | |||
| d7f72470ec | |||
| abc45b1a2f | |||
| 5bc530deb2 | |||
| 6a206b0c5e | |||
| 2485639e11 | |||
| d056c14b91 | |||
| 834a8dd0a8 | |||
| ea5e4d48d3 | |||
| 2b08a8958a | |||
| 759b09c8d6 | |||
| 0266afe9ab | |||
| 109c5e0703 | |||
| 40a79c2cc4 | |||
| debc425f99 | |||
| 602a1cc8a3 | |||
| d080eae809 | |||
| 631b5033fe | |||
| af8ea6934b | |||
| 19740ae6c2 | |||
| 7b78b71487 | |||
| 86a43a79c8 | |||
| 6035a1bde4 | |||
| a32e952323 | |||
| d55b1c67df | |||
| 103f7bc18b | |||
| e857c223d4 | |||
| ea07997522 | |||
| d492c73f94 | |||
| 3b836d29a2 | |||
| 9248916527 | |||
| 2006ebb244 | |||
| 58c852cdba | |||
| 9e77a8e304 | |||
| e9817f1e0d | |||
| 123dde7b8f | |||
| c1b84eabdb | |||
| c7ececde77 | |||
| 6f305d636e | |||
| d25990895c | |||
| d406ced759 | |||
| b858b56120 | |||
| c94fe81dbf | |||
| a67bbebb84 | |||
| cf577c81e1 | |||
| ad236be02c | |||
| 3412e379d6 | |||
| 95f240ab07 | |||
| 0c8ae3f45b | |||
| fe87944049 | |||
| 2cbe290916 | |||
| a85321a1a9 | |||
| c55071d157 | |||
| 86eac774e7 | |||
| dac6df4282 | |||
| d7918b1714 | |||
| c4de84a23a | |||
| c147c29756 | |||
| 5a4a50bc9d | |||
| 55ea4009c9 | |||
| 536fd7dfe4 | |||
| a1f6568b84 | |||
| 6a9112f03c | |||
| 89b4305ccb | |||
| 8643e6a055 | |||
| e2756e85b7 | |||
| 0f7bc36e86 | |||
| 5e20032976 | |||
| c7dbac05a9 | |||
| a0a5adb807 | |||
| ac6a43f6e5 | |||
| 91f57da735 | |||
| 488ac604f9 | |||
| 70ab3e456f | |||
| d0017d2ab8 | |||
| 9633abc09e | |||
| 8f608acc71 | |||
| dbce582bdf | |||
| 62f03bcf11 | |||
| 530eb9ef66 | |||
| 12509eb93a | |||
| 621623bdb6 | |||
| 497a94e3a5 | |||
| a2f5ce797d | |||
| e17082d27e | |||
| 2eefb8e225 | |||
| 5d9b1a1810 | |||
| f274e76253 | |||
| 3bfef7f67b | |||
| 5d6651e00e | |||
| f0ed0b7c41 | |||
| 0d4bf7b6b3 | |||
| a5c7c656e6 | |||
| fb3a937c81 | |||
| e50820abd0 | |||
| 083084136c | |||
| 0188b81220 | |||
| c7468dbfb5 | |||
| d92ba7125e | |||
| 050d5dd063 | |||
| a860c57bd1 | |||
| 1b0b189c16 | |||
| 7d2b3d6663 | |||
| 2899d68973 | |||
| 0cc8238b1a | |||
| f277751d86 | |||
| 74d63a9144 | |||
| 07f7b4e7fb | |||
| 92fda093f7 | |||
| 714751d2d8 | |||
| 2c949192b2 | |||
| c0e3c6a0eb | |||
| 764484f735 | |||
| 208bd4fcb2 | |||
| 6b17825fa2 | |||
| d20e0bd2c2 | |||
| ba53a5fa93 | |||
| 4d40da5661 | |||
| 4ab157e2a1 | |||
| dbf64d2a2b | |||
| 03d4ee3482 | |||
| 959a061380 | |||
| f5432dfb9e | |||
| 6e2f2fb9d2 | |||
| fb494a911d | |||
| bc9dec659c | |||
| b68cc3f61e | |||
| 0db80add2c | |||
| 2a67632497 | |||
| 5260b28c15 | |||
| 4d365cba22 | |||
| 8174a8efc3 | |||
| a5d8df35b6 | |||
| 0ad429ffaa | |||
| 3108572387 | |||
| 98a406ff9e | |||
| 9257550e56 | |||
| ef19ed0a26 | |||
| 80daa8560d | |||
| 797cc16a91 | |||
| 771e0464d7 | |||
| 715e9c0015 | |||
| d13a0c4fb3 | |||
| 2bb0517264 | |||
| ac174673ef | |||
| dacab5ece7 | |||
| 69a5ef6f18 | |||
| 47be8eef62 | |||
| fe7760e779 | |||
| 18dddaf0a1 | |||
| b32066e6f8 | |||
| eca378c09e | |||
| 2c3e4173f4 | |||
| 488a65055b | |||
| cb94f0c2c6 | |||
| 8dc4cf8d63 | |||
| 82ec5e0d5e | |||
| 91cebd2902 | |||
| cecee18d8e | |||
| 2b1ea2eb6f | |||
| bc67b380e5 | |||
| b7b784f442 | |||
| 6889effbb6 | |||
| ae7865ecb8 | |||
| 83c9d4887b | |||
| 75da4dab70 | |||
| 07fccf9b52 | |||
| 6cfafd60ef | |||
| b24bd740c2 | |||
| 6c81ee7b3a | |||
| cd00194819 | |||
| 0eda52e3b2 | |||
| 56de3b5658 | |||
| b8f31fc36f | |||
| 7354110d2f | |||
| c08335b5a8 | |||
| f4d9a3c65c | |||
| 174b73a5cb | |||
| 5df5123682 | |||
| 1aef828fcd | |||
| 6401183eff | |||
| 82757a2f0c | |||
| 736386bc31 | |||
| 922bed81fa | |||
| 708e8c5b14 | |||
| 1e02082472 | |||
| 9599bcb70f | |||
| dad8460574 | |||
| 021d12963f | |||
| e5599650ac | |||
| 22a1eff98e | |||
| fc00566469 | |||
| 2e05eb91ca | |||
| 7587860c12 | |||
| fabb5dd003 | |||
| 314da8b50f | |||
| 031e035a50 | |||
| 02374575bc | |||
| adef9e1014 | |||
| 5bb3f15332 | |||
| 089e0d5d6c | |||
| c8fbfcbc24 | |||
| a922961621 | |||
| 513bc2ae8b | |||
| 8a1c61ac22 | |||
| 3e1910a28b | |||
| b5e5341436 | |||
| 223ef16583 | |||
| 114312e1e5 | |||
| 1a49159b64 | |||
| d0ee9badb2 | |||
| b9116c30ed | |||
| d7e6436d8d | |||
| c039172880 | |||
| bd5da47370 | |||
| e9aabe0a5e | |||
| f3f09dbb9d | |||
| 3cc8a98f67 | |||
| 31e923c080 | |||
| 39b3b4a0c2 | |||
| 8470daa20f | |||
| e852137baf | |||
| 753c46d9fd | |||
| e06ca730a2 | |||
| f84e84b17b | |||
| 4f927b272b | |||
| 662e1a93a9 | |||
| e25a043457 | |||
| b32f923513 | |||
| ad8898266e | |||
| 51e87bdda5 | |||
| f88677b0f6 | |||
| fc71ec0250 | |||
| ca6089c220 | |||
| 7cc051fd90 | |||
| 5b01fda526 | |||
| 585f6b8a4d | |||
| 81aeba0874 | |||
| d9133e2793 | |||
| 9ef740ae1f | |||
| e54fe71e93 | |||
| 9df878b8e3 | |||
| 1a59c267c1 | |||
| f8a07d983b | |||
| 1f1847f246 | |||
| a32dfd6b37 | |||
| b1cce92e04 | |||
| fdf32439c9 | |||
| fc2208f9e5 | |||
| 1a4eb366bb | |||
| b89c64a2c2 | |||
| 68e8f6e753 | |||
| f15cc4cb3c | |||
| 903273e3ef | |||
| 1c9b744d31 | |||
| 7c0fb29886 | |||
| 2505a7510c | |||
| 0a66db40a2 | |||
| 6c68893979 | |||
| c512eab0b6 | |||
| 3cedd4bd0f | |||
| 0759c5e4c6 | |||
| ad6cf4be79 | |||
| 23c3899fb2 | |||
| 1a6515a660 | |||
| 58815a7650 | |||
| c15ec9fefc | |||
| 0e18d59680 | |||
| 2d88efa5b4 | |||
| b3da7572f3 | |||
| 099ec4e85d | |||
| ff88a15c61 | |||
| 839791b0fa | |||
| 159a533731 | |||
| fb5835baa4 | |||
| a3f05cd597 | |||
| f3af1672f6 | |||
| c984c9849b | |||
| e28d264125 | |||
| 7166ab9502 | |||
| ab242c2ecb | |||
| 6f829dd4c7 | |||
| 3e0602cdf0 | |||
| 67cdebfb67 | |||
| 0f87973742 | |||
| 92317f7730 | |||
| ce936c2553 | |||
| b995f16c34 | |||
| 49c7adcc40 | |||
| 88eee6fe48 | |||
| cbe425d150 | |||
| 1c7d6b7bf8 | |||
| 8323608558 | |||
| 3f8a5ec125 | |||
| 464b1695a9 | |||
| d85602612b | |||
| 59440d251b | |||
| d774f09427 | |||
| 45be650db9 | |||
| d54847803f | |||
| ce3b66eda7 | |||
| 5b6bcc7d12 | |||
| 24d4c2ae2c | |||
| 98e451d57d | |||
| 8c491c45be | |||
| 6f271c5638 | |||
| f9c083ebc6 | |||
| e79360915d | |||
| 2fbd8fdc08 | |||
| 5a9d5ec9a1 | |||
| 9ace798ee5 | |||
| 63e0dc0cb0 | |||
| 974aae3ec6 | |||
| 3268975849 | |||
| b6adb4cff5 | |||
| 78191bb750 | |||
| 2ab66671e5 | |||
| fdcfc630b3 | |||
| 3a717a8876 | |||
| 2dfb381b96 | |||
| d8a7e3331b | |||
| 2995eb1cac | |||
| bedb097955 | |||
| e6cebe41dc | |||
| 5aa123d42b | |||
| 9adb7d18c0 | |||
| 73da57a4f7 | |||
| 261d3c5532 | |||
| 91e55502f6 | |||
| 26846a02b5 | |||
| f2ed289c70 | |||
| 758b732142 | |||
| 50b80f3267 | |||
| 8b9109396a | |||
| d443a99773 | |||
| 9a3b706d76 | |||
| 2b4697938a | |||
| 7b1134d4a7 | |||
| 6c5aa5529e | |||
| e3db167d8f | |||
| 6365aa645a | |||
| cd172d0510 | |||
| f86806b88b | |||
| 211003c203 | |||
| 0ad281fac1 | |||
| 9c413712a7 | |||
| d4022de2e1 | |||
| bfdc30cfbc | |||
| 90e4e95a40 | |||
| 300833edc8 | |||
| 8d80152734 | |||
| f2f884c4ea | |||
| f46e66ecf6 | |||
| 4c13196c40 | |||
| 4aa3b481aa | |||
| 11489b7ec4 | |||
| 2b6a182b17 | |||
| 71e7da1a41 | |||
| 635c24ec19 | |||
| 4d68271c39 | |||
| f7154f4ab9 | |||
| d222385b05 | |||
| 856e9b2bb3 | |||
| 96982e1dae | |||
| 0d223024c5 | |||
| 2c9c14cc88 | |||
| 0f2d578756 | |||
| 1e035daed3 | |||
| 7935d73140 | |||
| 5df0f3485d | |||
| 20a809a7fb | |||
| 713bb699d1 | |||
| ba298ffb32 | |||
| aa64792ed5 | |||
| 74e1298a89 | |||
| 230123f1be | |||
| a326b3f402 | |||
| 179df2fbcc | |||
| e1feb93488 | |||
| 6a6cc06010 | |||
| 24c314beff | |||
| fbce03a5bc | |||
| 33ea9bf4ba | |||
| b206ae7331 | |||
| 9a9dc31cb2 | |||
| 6afcc3e0e8 | |||
| ac4153b58a | |||
| a0bc73ab3b | |||
| ff6a7c6590 | |||
| 1c57cb6f04 | |||
| 383b7e8f66 | |||
| b1d1636c4b | |||
| d006791a10 | |||
| 98de57f9ad | |||
| c0810e6f24 | |||
| 6e8527ff19 | |||
| 29773c2521 | |||
| 9a40625f8c | |||
| 7e6e33bcd6 | |||
| 036e94dd8e | |||
| a6ce7a635d | |||
| 4ba4c20f85 | |||
| 753b6b7db4 | |||
| 923014402d | |||
| bec302465b | |||
| bfaee826df | |||
| b8e73f9a58 | |||
| 12f259249c | |||
| 8c841fec9e | |||
| 5264e63bce | |||
| a0610675ee | |||
| e68f9a103f | |||
| 5aa129299d | |||
| 0a7abc9018 | |||
| 6c027912d5 | |||
| 7a6d383f47 | |||
| eef3d575e2 | |||
| 98e489503f | |||
| 7c9c159db9 | |||
| 0978d7dd5c | |||
| b843a8da0f | |||
| 4a22a619d9 | |||
| 362d34c36d | |||
| 16054f6d9c | |||
| 9c5db730f6 | |||
| b93a4ddd99 | |||
| 571c0bcebf | |||
| 735f653db3 | |||
| f3d1704229 | |||
| 91292b275f | |||
| 256b8d14d9 | |||
| f03f0c1ea9 | |||
| 875245e9fd | |||
| 0c90843fc5 | |||
| e7dd79028e | |||
| 1a281344ea | |||
| e826051bf5 | |||
| 68bb614c33 | |||
| da996d582c | |||
| 391d1077ca | |||
| 5b039b22d4 | |||
| e62ae1106b | |||
| 180329f055 | |||
| 87185210ef | |||
| d1454f3cae | |||
| 470706929f | |||
| c43b6cca68 | |||
| 4880230261 | |||
| 935f22ca5a | |||
| 548cc0f746 | |||
| d2e5a925b4 | |||
| 84ca4ab691 | |||
| 0b214f3e1b | |||
| 039cdc3d9a | |||
| 2284977fa5 | |||
| ce8ee6ebb3 | |||
| b570556ab0 | |||
| 23e7157015 | |||
| 2994944061 | |||
| 959416f191 | |||
| f09f91e666 | |||
| eaa51b0e52 | |||
| a97d7d860d | |||
| 63376552db | |||
| 7c5dda6ab0 | |||
| e87d47a7bb | |||
| b75df908ca | |||
| f42c7be03f | |||
| 9b246f034a | |||
| 2bfb720ca4 | |||
| a168633565 | |||
| 91a2c3a5b2 | |||
| b765395187 | |||
| a68ea48783 | |||
| aebbcb7971 | |||
| d5eae90808 | |||
| 2469f5e1a1 | |||
| 5ea4fad854 | |||
| 3174f98812 | |||
| c2183de96f | |||
| 3b8c720dc8 | |||
| eda533704e | |||
| 8eb03db558 | |||
| 6e0cfab1ee | |||
| da773d87fc | |||
| ab8d0b7750 | |||
| b3752ebea0 | |||
| fa57f23218 | |||
| ef673c0a29 | |||
| 3b518d3971 | |||
| 8e0e2f6d61 | |||
| 6981cfe14d | |||
| 3e0c7e7606 | |||
| 193c89499e | |||
| 2a629249d5 | |||
| ec3f5a0ab9 | |||
| cd1fe24cfc | |||
| 0f139eeed7 | |||
| c29d940b67 | |||
| 51c51ed1a8 | |||
| 16054bf755 | |||
| 3274297090 | |||
| c2e2e3b433 | |||
| 4920dfb64f | |||
| c04ac3f512 | |||
| 31d40c17de | |||
| accbd1cdd0 | |||
| 3e1be9b4c0 | |||
| 55aa43876a | |||
| c56da60fbc | |||
| f9dc4fc2e4 | |||
| 42bb5fec77 | |||
| bf76e3896a | |||
| a8dadd7e44 | |||
| e96c3bc0d0 | |||
| 6aeca58736 | |||
| cc5866e199 | |||
| 8831171a47 | |||
| 2bcbb3a9f9 | |||
| 451528bd15 | |||
| 8cf536473b | |||
| 5d401af00f | |||
| 0deb81cf53 | |||
| 05b440f343 | |||
| cf9f623699 | |||
| 19c43a01fe | |||
| 97d6b1d67a | |||
| 779bac00a8 | |||
| 1350968d20 | |||
| b114dd1159 | |||
| 36052ead75 | |||
| b2200d1d2f | |||
| 014aacc80a | |||
| e119aa6bfe | |||
| 68f4852f03 | |||
| 1ad7e82dfd | |||
| bf163a0189 | |||
| ef95e1476b | |||
| 15a9340019 | |||
| 938d922607 | |||
| 9cddcf2e52 | |||
| 1567e75b22 | |||
| 29153f0aa7 | |||
| 7a5c428358 | |||
| 4468f98ccd | |||
| df0952944a | |||
| 96df7dd767 | |||
| 7300986418 | |||
| 2efdd4bada | |||
| 353ade3f86 | |||
| edb68cf37a | |||
| 6df81cd640 | |||
| dc2e555ed7 | |||
| 3ecdd2fa6f | |||
| ad30e75751 | |||
| f6938b326f | |||
| b2c80432d2 | |||
| 0515653fe9 | |||
| b14156a463 | |||
| 979b3cde85 | |||
| dc3ad8d708 | |||
| 799d2607e3 | |||
| e9ccbb4126 | |||
| b5811749e1 | |||
| 57310a6eb7 | |||
| 41f9b89268 | |||
| 34e43eaf6e | |||
| 549f30b812 | |||
| 31f3273c09 | |||
| d9bd328eca | |||
| b0b7130c17 | |||
| e6b5431f83 | |||
| 27a131ebb1 | |||
| 410cb3909e | |||
| a36e3143b9 | |||
| 3036a22d57 | |||
| 31a632aaf0 | |||
| 9f2453472b | |||
| a9244d62a2 | |||
| 7f603185b6 | |||
| 58ffc3d708 | |||
| f4d8174d47 | |||
| 282787ba87 | |||
| 1ae9f719b8 | |||
| 9c7a108bd4 | |||
| 3db92f734b | |||
| b16b674ba4 | |||
| 0c4e6ff26d | |||
| cbd158445f | |||
| 1fb5be9c42 | |||
| 41e18bf2f9 | |||
| e957201f53 | |||
| e820b0daa6 | |||
| 65d18319d9 | |||
| 8ee654c73d | |||
| ae5cfc8307 | |||
| 1c1bb432bf | |||
| 5355b27a99 | |||
| 6931e24d65 | |||
| 5f0ddf13a8 | |||
| 90ee2e7f67 | |||
| f88c7701c5 | |||
| 6b26fb00cd | |||
| 29ddb2d682 | |||
| 8d500648a1 | |||
| 1f99f2de9b | |||
| ecccbf9137 | |||
| 8fe3aabe75 | |||
| 47465a2ac6 | |||
| e7211871fc | |||
| ceedd4815c | |||
| d8b628bb0c | |||
| bc8b146bc7 | |||
| 4542147801 | |||
| feb4fb3c82 | |||
| 070b89e096 | |||
| 47886ef78c | |||
| b6cd2e4e90 | |||
| 5ba3f770a6 | |||
| b0854871ae | |||
| e870a08288 | |||
| 0e7a506f06 | |||
| 7b196bc4f7 | |||
| e5f4c64546 | |||
| 37c8cd4172 | |||
| 7299af57b8 | |||
| 53b1d1a0c9 | |||
| 3ea86553b2 | |||
| be9c05333e | |||
| 23012ce741 | |||
| af53afa3dd | |||
| ec7b598a77 | |||
| 052956afa3 | |||
| d0ed004d84 | |||
| e99b810649 | |||
| 177f417f99 | |||
| 739ac633f6 | |||
| 2fe43d3f72 | |||
| 9078fa0197 | |||
| 24b0bd05d8 | |||
| 453ca8c3e3 | |||
| 9bfb569acf | |||
| 3f86340db1 | |||
| 52087105ec | |||
| 555c48831a | |||
| 75a877f17d | |||
| a40f16c1ac | |||
| 979dc27874 | |||
| 1acbcd00a6 | |||
| 73ec92fe94 | |||
| 76d05b743e | |||
| baa96a0fb1 | |||
| a84163f181 | |||
| 2b3c462c83 | |||
| a6f3600742 | |||
| a718458958 | |||
| 4bf82b8b8c | |||
| 0d19e625bd | |||
| e364376ff4 | |||
| c3625a04c4 | |||
| 2058670123 | |||
| b7f9f76c10 | |||
| 5e728fb183 | |||
| c79e8fda8e | |||
| 834ab5fee4 | |||
| faa7cc975c | |||
| 5f51071b78 | |||
| ab1553665e | |||
| 91d60d7e71 | |||
| 11f8aadfa4 | |||
| 5bd75a553c | |||
| cc20d2f538 | |||
| 5d0cda5e9b | |||
| b847e4b8cb | |||
| 516098e822 | |||
| b2457d67df | |||
| 880459018d | |||
| 6c79f8195b | |||
| d644b899a9 | |||
| b2f33f0a51 | |||
| 418a52c353 | |||
| 9fa7a5c933 | |||
| 12d070c472 | |||
| 2c5c018452 | |||
| 81951b1b67 | |||
| 5ed8fe0fdb | |||
| aff2365322 | |||
| c1044f5b82 | |||
| 1e21430b56 | |||
| ea87ff3911 | |||
| 932d60a46e | |||
| 112f84f88f | |||
| 71d9713503 | |||
| ec235fe302 | |||
| 33afd0a679 | |||
| 94f8256982 | |||
| 0eaf1b6251 | |||
| a4c6007695 | |||
| 9fa9d113e4 | |||
| e46e65bc7b | |||
| 0cd86f1fb8 | |||
| 91ba266339 | |||
| 047371261b | |||
| 548eb41ab8 | |||
| 7d0e550e9b | |||
| 25866bd621 | |||
| c5e352e59d | |||
| 37e894da43 | |||
| 431af3c438 | |||
| 9d1f3875ee | |||
| 1d084fcffd | |||
| 9342e4b8ba | |||
| 6ce1eca54d | |||
| 4d6a089a1b | |||
| e02b85a37c | |||
| d79cca9c3f | |||
| e1cdebe95e | |||
| 4c5b9cd6bb | |||
| 1e27f9ebd5 | |||
| d7e7c5057d | |||
| db3edfe0f5 | |||
| 25052ef447 | |||
| fceff21c5e | |||
| 553889dd82 | |||
| e0e25479d2 | |||
| 3614b5d33c | |||
| 4b8ab7d5e2 | |||
| 916633b50a | |||
| 2db91bb088 | |||
| 379ab40946 | |||
| 3b8e7dffb1 | |||
| a5759b18f4 | |||
| 5f16a31a80 | |||
| 541cd9302b | |||
| c4014c788b | |||
| 8afb3ac0f4 | |||
| 6798750645 | |||
| 490e628406 | |||
| 0c652130c5 | |||
| 6971a17a18 | |||
| 5fbd93b0a3 | |||
| c4b53ec7a6 | |||
| b7b2ebbd04 | |||
| 3b2d32af99 | |||
| 8bbdb5a7cf | |||
| 098f84fa88 | |||
| 2b03112c2a | |||
| 895305f175 | |||
| b860196727 | |||
| 39e957cd82 | |||
| aad8994cd9 | |||
| c077ce6d47 | |||
| 63098ca29a | |||
| e549254df9 | |||
| d8fcda9eba | |||
| 23d18cc63c | |||
| bc47514b03 | |||
| 273dc9da6e | |||
| 1b52049baa | |||
| d59424a384 | |||
| 18268c148a | |||
| dfc2d9af85 | |||
| 8f9359cfc5 | |||
| c0ba9aedd8 | |||
| cccc8967a3 | |||
| 768b28f0cd | |||
| 4ad756a8c4 | |||
| 36856cbff0 | |||
| 18822a5c89 | |||
| 2ae4175491 | |||
| 9dd4fb6984 | |||
| bda4ad82fa | |||
| 8b85bd29a7 | |||
| dc49396466 | |||
| 0a377a4065 | |||
| fac2ac4150 | |||
| f62293c46b | |||
| 510703a07b | |||
| 06063d970a | |||
| e205024973 | |||
| 5fa45f6a46 | |||
| 09d3b61234 | |||
| 620dd597fe | |||
| 130340a752 | |||
| d3fc25bc99 | |||
| ff354d5a32 | |||
| 5b28b54efa | |||
| 4088aaaff1 | |||
| b13cbeed61 | |||
| abeb2c96b1 | |||
| 139be845e0 | |||
| 1b39f5826a | |||
| ae93d560d4 | |||
| 69782ec244 | |||
| 684c08a637 | |||
| a665f2db18 | |||
| 8a5e20fed8 | |||
| 8211fb1a25 | |||
| 0b1d9cc012 | |||
| 9737e8b0ae | |||
| 36999fe759 | |||
| 0fad139d9c | |||
| e9cf91e04e | |||
| 8bb829b577 | |||
| 58da921ffe | |||
| 6deca5459f | |||
| 58f35ef0c2 | |||
| e67a414507 | |||
| c327620e1b | |||
| 05d371152d | |||
| 7e3dd42e73 | |||
| 240dcc0164 | |||
| 41e5bac97e | |||
| 824e2c5106 | |||
| 5ec1f31434 | |||
| 4f4a9a8048 | |||
| a456ae4fa7 | |||
| b3b0ab225b | |||
| f4aa5d2bf1 | |||
| 8cc7ab5775 | |||
| 6d4a07db2e | |||
| a0d924c3b0 | |||
| c201bf3ef3 | |||
| 8d45a46ee2 | |||
| 6a5a9b33c2 | |||
| 6d237b1781 | |||
| 46b40bf2f0 | |||
| 546c258c82 | |||
| f6031e9b9c | |||
| b6480f9e32 | |||
| b830aba31c | |||
| c6b0c95aa4 | |||
| 129f58c059 | |||
| c10242b388 | |||
| 5c0a430d84 | |||
| 382afa52e9 | |||
| 8fd5191685 | |||
| ce67d74980 | |||
| 0e95e67d7e | |||
| 26e7a572d4 | |||
| 0d3d27c343 | |||
| 97764cbac8 | |||
| 883d9b60ee | |||
| 24f6a8e1f2 | |||
| fa366f2789 | |||
| 2bbe7d15eb | |||
| c5e3dda387 | |||
| 0184c41c8e | |||
| 0c8b0c1dd9 | |||
| 71e5c74b77 | |||
| 21ab566cff | |||
| 20e475cfb7 | |||
| febf592db6 | |||
| fe94358f0c | |||
| 0cb560b856 | |||
| faa0bb7550 | |||
| 1d7df79465 | |||
| 72f2a4fc86 | |||
| 8434eb4ff4 | |||
| ba4280ee4e | |||
| 34f34cef4d | |||
| 30f21d71c8 | |||
| 592d264b19 | |||
| 9d55dca0e1 | |||
| da4111904c | |||
| a4b9358f14 | |||
| 122c6527d4 | |||
| 844b76e116 | |||
| f262009349 | |||
| bc1a4ceb42 | |||
| a8ba984064 | |||
| fda6dab572 | |||
| 4cdb777840 | |||
| f94d9595a8 | |||
| 5d38bd26a2 | |||
| 9239261c5a | |||
| e3aed706fb | |||
| 89d87c6356 | |||
| a0cfe0b6fd | |||
| 476c311e01 | |||
| bb10b8fffa | |||
| 4a8fa4a838 | |||
| 624b844454 | |||
| 027f1f4045 | |||
| 28d66dc162 | |||
| 3995e732f6 | |||
| 60f553707a | |||
| c37e2ceaab | |||
| abd7922700 | |||
| c47389426e | |||
| 6b5c7bd14b | |||
| cb072c2aa6 | |||
| 533649c791 | |||
| 3105f2e8ae | |||
| 8160bc98fd | |||
| 8a1b615fe9 | |||
| 3f3bb2d830 | |||
| ae4871f6dd | |||
| f46da7b12f | |||
| b3f5bdd58d | |||
| ca8ecd297b | |||
| d954d25a73 | |||
| bda261b495 | |||
| af3142546e | |||
| 05b9a400fd | |||
| 5a0f6969d9 | |||
| 7ea0f3f73b | |||
| a383682147 | |||
| 7dd414bc8f | |||
| 32fca9dadb | |||
| dd75eacebf | |||
| ca42e7e7f1 | |||
| 3430702d51 | |||
| 653a9087c4 | |||
| 05889e7554 | |||
| 7fca0cd201 | |||
| 7321c9095e | |||
| bbb83d9cad | |||
| 275023c844 | |||
| 35c6aee5dd | |||
| 25686e981f | |||
| ad8022666f | |||
| 68f246cda5 | |||
| cc977fce35 | |||
| d4f7e2712e | |||
| 9a89b01741 | |||
| 009938bc06 | |||
| 487e933c25 | |||
| 539f621c0b | |||
| bfe9860c92 | |||
| 5b5645e042 | |||
| b9053d1dfd | |||
| a7084ecd88 | |||
| b3b301332c | |||
| eb43778718 | |||
| d6ed4e6b0b | |||
| e38d696ac9 | |||
| 07c3a48657 | |||
| ebede7a297 | |||
| 59ab5e16cc | |||
| 889399fc04 | |||
| 69eda1420b | |||
| 063920a2a5 | |||
| 1623ee858f | |||
| 1edd13b229 | |||
| d0b6fbb7b4 | |||
| 4fc21a29e3 | |||
| 3e6d03eea1 | |||
| b950485f6c | |||
| 3007c0d57f | |||
| 5a2b30432c | |||
| cfb66db035 | |||
| 1eec18b76d | |||
| 1d2bfe2195 | |||
| f4a13b2e7a | |||
| b29667b9f6 | |||
| dcd21aab1c | |||
| bfbfcd2d8b | |||
| bb72181359 | |||
| 2d0b9ab9f1 | |||
| 291f462955 | |||
| 74d6de9c78 | |||
| 9f99390145 | |||
| 8cdf12bafd | |||
| 2b5442a2a8 | |||
| ebb9f42771 | |||
| c75e2b778f | |||
| 1f6d198bf5 | |||
| 30bbfc37fc | |||
| 5a693ae673 | |||
| 38325f84ac | |||
| 0eebd164ec | |||
| f60b730411 | |||
| 16db1db748 | |||
| 818cf4bc33 | |||
| 789b7ba9aa | |||
| fdf389f62c | |||
| 8ae8433463 | |||
| 71464cd5bf | |||
| 44ca3b9e34 | |||
| 2dd24f02c6 | |||
| a6e6bc810a | |||
| 9d00a82343 | |||
| 1246c53c77 | |||
| 8fc10c873e | |||
| b48aac638f | |||
| e427565fcf | |||
| 0e028b3ffe | |||
| c81e3a7def | |||
| 669c9b4fb7 | |||
| 5f015c3d69 | |||
| faa46a7e4d | |||
| 70d2a225f3 | |||
| 1521a77281 | |||
| 516551714b | |||
| e794122b7f | |||
| 67282d1ebd | |||
| 2c5975cf26 | |||
| dc142281f5 | |||
| 5a445fc5bd | |||
| 7ff2f97ac3 | |||
| d47492188e | |||
| 263d3e7546 | |||
| ce31bf63e9 | |||
| 3c030dd6c3 | |||
| 147c3dfe9d | |||
| c2e820f851 | |||
| f53f5f1870 | |||
| c20ecaa616 | |||
| 2cc270708a | |||
| ddf7d4fc96 | |||
| 1e73b530ed | |||
| 5c4a1275fb | |||
| d55a809493 | |||
| 50ecf71879 | |||
| af7434e35d | |||
| ad7239c5d8 | |||
| f90efceac3 | |||
| c6f70dccca | |||
| eca358e73a | |||
| 4e6ce7e8bb | |||
| a2049200b1 | |||
| b10306aca0 | |||
| aaf430cae8 | |||
| e7ee9e3304 | |||
| a4f65adda9 | |||
| d38b90d1f3 | |||
| a07a4a167c | |||
| a77c29af48 | |||
| 4044f3e787 | |||
| 70de96a9e8 | |||
| 014f34d813 | |||
| 8fdc50b2aa | |||
| 88874fb9b6 | |||
| 11ad4cdeac | |||
| c5f1b39fba | |||
| 6eb8af8fd5 | |||
| 2ec3b393fc | |||
| 7a2977d4c8 | |||
| b987142b3f | |||
| 22656d62d4 | |||
| 7d6693e206 | |||
| c3f2bb4d21 | |||
| e154019d07 | |||
| 1b891eba73 | |||
| 38e5f8e4e9 | |||
| 428ab4c6d7 | |||
| 27ce34bce6 | |||
| 6fb5760a6a | |||
| 2e2fd1580d | |||
| 8ab826d27d | |||
| d1f33baa30 | |||
| 7239941168 | |||
| ca00e8680d | |||
| 57d9e0c600 | |||
| f2811422f0 | |||
| 0f71d2e0e2 | |||
| 388c4baa15 | |||
| 13a8c2facd | |||
| def5a26d98 | |||
| d1ad72b0f2 | |||
| da62656f7e | |||
| da3e2399f7 | |||
| c70af212d1 | |||
| 8becc8bd72 | |||
| 5bc0307242 | |||
| 034b2975d6 | |||
| 3ffde8c52b | |||
| b125a747c8 | |||
| 00e656dbce | |||
| a7f6224237 | |||
| 81f469531b | |||
| a4794d1619 | |||
| d6b7bd1194 | |||
| c0169afbc2 | |||
| 19fcc6a175 | |||
| cada8483fe | |||
| 2464894fd5 | |||
| d700df9a60 | |||
| 273a376a4a | |||
| 41b78d80e4 | |||
| d904462417 | |||
| 6bf9836f57 | |||
| 92c4a2af59 | |||
| bbeced7e7e | |||
| c94295b472 | |||
| 4905429bb0 | |||
| c0d60222aa | |||
| 312c6c9729 | |||
| 137cb6bb45 | |||
| bc3408c25d | |||
| 5cb8e5e49c | |||
| 36b924443d | |||
| 5122935e10 | |||
| b5176600f4 | |||
| e073a3c289 | |||
| 18c2f782c2 | |||
| 6449513cb8 | |||
| f56e39e3c2 | |||
| 90e423b62c | |||
| 8e455b48c3 | |||
| c0d54dc6dd | |||
| 3d7f4ba844 | |||
| ae4a0f8caa | |||
| 61e02f0666 | |||
| ee9460d43e | |||
| 264c640036 | |||
| 8ae0c9bee1 | |||
| 670b2d18b4 | |||
| 4a37f1e6f0 | |||
| 897bdff957 | |||
| f1893517e0 | |||
| 4b510f1ff6 | |||
| 961944b0b2 | |||
| 93d0959766 | |||
| 00a5678784 | |||
| c34373cc00 | |||
| d2992adddb | |||
| 0d826be66e | |||
| 67d4250c71 | |||
| 9c2b7aead1 | |||
| 67ad6cd551 | |||
| a4d1ee4be0 | |||
| 72b725c933 | |||
| 7a308e5aed | |||
| 7dd4bdbf74 | |||
| 5560afcd8f | |||
| e2c90548ed | |||
| dd050ba770 | |||
| d2e67af495 | |||
| b870175031 | |||
| f8fc50b37b | |||
| 730a46e32f | |||
| a06343b1f1 | |||
| 675fcf8dbc | |||
| 7ef23c8434 | |||
| 8ae7d5b755 | |||
| 46ce038238 | |||
| d4b3e7680a | |||
| c64cdc6525 | |||
| 5c4bd03c94 | |||
| 06fe8f3144 | |||
| 9044090afd | |||
| c282ff2dfb | |||
| 1e45429795 | |||
| ba73109b5c | |||
| aee03abc63 | |||
| d56bc38aeb | |||
| 995b917ae6 | |||
| 821e35ebab | |||
| ecf942d267 | |||
| 8061dd2ed4 | |||
| 4962fb8b66 | |||
| 6e949b9cbe | |||
| 9e1d32a8e6 | |||
| 44edd4a92a | |||
| 7b6cea3b1f | |||
| dab490e21c | |||
| bcd32924dc | |||
| df463ae2e7 | |||
| 77cb9e328a | |||
| c1df4a06a6 | |||
| 1b5a61f69d | |||
| c546035f32 | |||
| e4eddcb9a6 | |||
| bc83076daf | |||
| 7f0d1436a2 | |||
| 056d73801b | |||
| 536371a580 | |||
| cede650552 | |||
| 96360498f8 | |||
| 1c489e361d | |||
| abc26bbba2 | |||
| 3e0adb422a | |||
| 7d2fa36d2c | |||
| ea6cab53ad | |||
| 92610fd46a | |||
| bcc8a1fd81 | |||
| edd137c7f4 | |||
| 6ed0889ce9 | |||
| 25fdfa5ba3 | |||
| 28c811163f | |||
| b6cf3d588a | |||
| 2cce587a72 | |||
| 5d54c24c7b | |||
| cd152eec7f | |||
| ef8e0a4b13 | |||
| b15347ea8e | |||
| be1ad61f8b | |||
| a0b44dd833 | |||
| c15b316aba | |||
| 6349d8acfd | |||
| 9625b63577 | |||
| 3a574c7b1f | |||
| f2be845b10 | |||
| 8fd0d3f79b | |||
| bfe0cd04f2 | |||
| 60a01e8e85 | |||
| 01e2e49f20 | |||
| 6c5876364b | |||
| 8f3c62e2a8 | |||
| 04882952e1 | |||
| 36ac372b15 | |||
| 757f9628b6 | |||
| 3d861bf5d3 | |||
| 74a3dce903 | |||
| 123550fa9a | |||
| 4be85c8515 | |||
| f6059a98a2 | |||
| 016e067596 | |||
| a7e2141528 | |||
| 2be59901c9 | |||
| 861c2c3d80 | |||
| 9f092c539b | |||
| e38279719b | |||
| f87845f839 | |||
| 734c32a63f | |||
| f367f24dc9 | |||
| 90bb518922 | |||
| 31cd106b7d | |||
| b7c15471b0 | |||
| 30881d68a5 | |||
| 10cc126e99 | |||
| fff9b72dd0 | |||
| 727d0db354 | |||
| 21285c2f54 | |||
| 9e8f60cde1 | |||
| 496b477ce3 | |||
| e6da09285b | |||
| 68f71ef203 | |||
| 416afad49a | |||
| c4450ff6d6 | |||
| 6595ff525a | |||
| ed4752bdc9 | |||
| 86a59ed08d | |||
| 807a38d117 | |||
| 7b0b7c623c | |||
| e2f7845b94 | |||
| cc7c9d4597 | |||
| 3b8e72c0de | |||
| 95181c2ce2 | |||
| d7e500585e | |||
| c6f1620dbf | |||
| 8990ca32b6 | |||
| 15accb0d71 | |||
| 5e75470dc5 | |||
| 1fd9d73cba | |||
| 71c9ec33eb | |||
| c4f6a5f93c | |||
| 4f9691c3bd | |||
| dbd2f7d69e | |||
| 95ac877c08 | |||
| 5831f19ae0 | |||
| 530bdc5510 | |||
| 0c01d6989a | |||
| 02861d01d6 | |||
| 668d1693fe | |||
| 7a3911c837 | |||
| 5291cbc136 | |||
| c1fc68204c | |||
| cd8fed5c7c | |||
| f2506fa762 | |||
| 382763c89e | |||
| b4cd1ccaa5 | |||
| b5032f457f | |||
| f0bb3cae90 | |||
| e416e82179 | |||
| 552aed19a0 | |||
| 6c4cefcf25 | |||
| ac41ba699c | |||
| cd64118868 | |||
| 735df8078f | |||
| 8304f49273 | |||
| 3130de3a02 | |||
| a284ac7677 | |||
| 7964fd9042 | |||
| ded012a1bc | |||
| df3e3465f9 | |||
| bed93bf928 | |||
| 7697ceffef | |||
| 81dd24a9bd | |||
| 729d7d97c4 | |||
| c7a4b3c0a4 | |||
| 3da044ada9 | |||
| 44bbc93dae | |||
| 54341a0afc | |||
| 599eab3e5b | |||
| 9f9c875234 | |||
| 74c0ed80c5 | |||
| 5ecb7aea5e | |||
| 829eacc4d6 | |||
| f7b3f924b4 | |||
| e247bc0e59 | |||
| 4158416183 | |||
| cf1181f2af | |||
| a2d1335403 | |||
| 520cbb5189 | |||
| e8eeadb094 | |||
| 92a2336dba | |||
| cbc75c8b85 | |||
| 563973163e | |||
| e147a7a0ca | |||
| b494dc7bec | |||
| 9ce4b02610 | |||
| d0ff69d224 | |||
| cde09e0f56 | |||
| 84409395d1 | |||
| e4e6bcfad2 | |||
| 2103215e41 | |||
| d086569f09 | |||
| 28064767ea | |||
| e996e4d4b6 | |||
| 422100f9fc | |||
| c9a7ffd778 | |||
| db009abf79 | |||
| c1cc7c98ef | |||
| a08b00d5c4 | |||
| 16a22ab7b2 | |||
| da32ee2504 | |||
| 54eaa9e695 | |||
| 28c1481a48 | |||
| cac340ad43 | |||
| d6994d9a60 | |||
| 90372ad30d | |||
| 24fc22dbe6 | |||
| 7b7adac774 | |||
| 7f0ff6ae2f | |||
| 1b3e58b326 | |||
| dc47fc60b8 | |||
| 6c588964a7 | |||
| f65b24094a | |||
| 6b807be0e6 | |||
| a794eb8310 | |||
| 8290c8a371 | |||
| 475152a7eb | |||
| 4e75e20ede | |||
| d36823c7ca | |||
| 2a6b387112 | |||
| a83822bff9 | |||
| 8e7538f6e6 | |||
| 9cdb26f7cc | |||
| 9659c913c4 | |||
| c9506cb95e | |||
| 43e6ce3997 | |||
| dfd12edcb3 | |||
| 154a8072f6 | |||
| 904abaf26b | |||
| bea18a27ba | |||
| 2d998eab50 | |||
| a25a67572b | |||
| 1bdf6f9969 | |||
| 0b32892fa8 | |||
| fea5b8a716 | |||
| 90b3707409 | |||
| 1c0224fbe7 | |||
| 626fcd1140 | |||
| b01c84b14c | |||
| 412492b4d1 | |||
| 9a6f7a4316 | |||
| 660f887923 | |||
| fe9c67ed91 | |||
| d3bbd05e4f | |||
| 34585129aa | |||
| 955cd4c173 | |||
| 4da63a8fd7 | |||
| fa27789608 | |||
| f9e9f35157 | |||
| 4a6604f0ab | |||
| 971d1221da | |||
| ba69885477 | |||
| 8e23098037 | |||
| 8da7bf029c | |||
| e16e58cbfa |
@@ -0,0 +1,3 @@
|
||||
.gitattributes export-ignore
|
||||
/Wiki export-ignore
|
||||
.gitignore export-ignore
|
||||
+5
-1
@@ -13,7 +13,6 @@ build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
@@ -53,3 +52,8 @@ coverage.xml
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# pycharm
|
||||
.idea
|
||||
|
||||
icon.psd
|
||||
main-icon.psd
|
||||
Executable
+1216
File diff suppressed because it is too large
Load Diff
Regular → Executable
+248
-127
@@ -1,154 +1,275 @@
|
||||
# hdbits.org
|
||||
# coding=utf-8
|
||||
import sys
|
||||
import datetime
|
||||
|
||||
from subzero.sandbox import fix_environment_stuff
|
||||
|
||||
module = sys.modules['__main__']
|
||||
fix_environment_stuff(module, {})
|
||||
|
||||
globals = getattr(module, "__builtins__")["globals"]
|
||||
for key, value in getattr(module, "__builtins__").iteritems():
|
||||
if key != "globals":
|
||||
globals()[key] = value
|
||||
|
||||
import string, os, urllib, zipfile, re, copy
|
||||
from babelfish import Language
|
||||
from datetime import timedelta
|
||||
import subliminal
|
||||
import subliminal_patch
|
||||
import logger
|
||||
|
||||
OS_PLEX_USERAGENT = 'plexapp.com v9.0'
|
||||
sys.modules["logger"] = logger
|
||||
|
||||
import support
|
||||
|
||||
import interface
|
||||
sys.modules["interface"] = interface
|
||||
|
||||
from subzero.constants import OS_PLEX_USERAGENT
|
||||
from interface.menu import *
|
||||
from support.plex_media import media_to_videos, get_media_item_ids
|
||||
from support.extract import agent_extract_embedded
|
||||
from support.scanning import scan_videos
|
||||
from support.storage import save_subtitles, store_subtitle_info
|
||||
from support.items import is_wanted
|
||||
from support.config import config
|
||||
from support.lib import get_intent
|
||||
from support.helpers import track_usage, get_title_for_video_metadata, get_identifier, cast_bool
|
||||
from support.history import get_history
|
||||
from support.data import dispatch_migrate
|
||||
from support.activities import activity
|
||||
from support.download import download_best_subtitles
|
||||
from support.localmedia import find_subtitles
|
||||
|
||||
DEPENDENCY_MODULE_NAMES = ['subliminal', 'subliminal_patch', 'enzyme', 'guessit', 'requests']
|
||||
|
||||
def Start():
|
||||
HTTP.CacheTime = 0
|
||||
HTTP.Headers['User-agent'] = OS_PLEX_USERAGENT
|
||||
Log.Debug("START CALLED")
|
||||
logger.registerLoggingHander(DEPENDENCY_MODULE_NAMES)
|
||||
# configured cache to be in memory as per https://github.com/Diaoul/subliminal/issues/303
|
||||
subliminal.region.configure('dogpile.cache.memory')
|
||||
|
||||
def ValidatePrefs():
|
||||
Log.Debug("Validate Prefs called.")
|
||||
return
|
||||
config.init_cache()
|
||||
|
||||
# Prepare a list of languages we want subs for
|
||||
def getLangList():
|
||||
langList = {Language.fromietf(Prefs["langPref1"])}
|
||||
if(Prefs["langPref2"] != "None"):
|
||||
langList.update({Language.fromietf(Prefs["langPref2"])})
|
||||
if(Prefs["langPref3"] != "None"):
|
||||
langList.update({Language.fromietf(Prefs["langPref3"])})
|
||||
|
||||
return langList
|
||||
# clear expired intents
|
||||
intent = get_intent()
|
||||
intent.cleanup()
|
||||
|
||||
def getProviders():
|
||||
providers = {'opensubtitles' : Prefs['provider.opensubtitles.enabled'],
|
||||
'thesubdb' : Prefs['provider.thesubdb.enabled'],
|
||||
'podnapisi' : Prefs['provider.podnapisi.enabled'],
|
||||
'addic7ed' : Prefs['provider.addic7ed.enabled'],
|
||||
'tvsubtitles' : Prefs['provider.tvsubtitles.enabled']
|
||||
}
|
||||
return filter(lambda prov: providers[prov], providers)
|
||||
#Locale.DefaultLocale = "de"
|
||||
|
||||
def getProviderSettings():
|
||||
provider_settings = {'addic7ed': {'username': Prefs['provider.addic7ed.username'],
|
||||
'password': Prefs['provider.addic7ed.password'],
|
||||
'use_random_agents': Prefs['provider.addic7ed.use_random_agents'],
|
||||
},
|
||||
}
|
||||
return provider_settings
|
||||
# clear expired menu history items
|
||||
now = datetime.datetime.now()
|
||||
if "menu_history" in Dict:
|
||||
for key, timeout in Dict["menu_history"].copy().items():
|
||||
if now > timeout:
|
||||
try:
|
||||
del Dict["menu_history"][key]
|
||||
except:
|
||||
pass
|
||||
|
||||
def scanTvMedia(media):
|
||||
videos = {}
|
||||
for season in media.seasons:
|
||||
for episode in media.seasons[season].episodes:
|
||||
for item in media.seasons[season].episodes[episode].items:
|
||||
for part in item.parts:
|
||||
scannedVideo = scanVideo(part)
|
||||
videos[scannedVideo] = part
|
||||
return videos
|
||||
# run migrations
|
||||
if "subs" in Dict or "history" in Dict:
|
||||
Thread.Create(dispatch_migrate)
|
||||
|
||||
def scanMovieMedia(media):
|
||||
videos = {}
|
||||
for item in media.items:
|
||||
for part in item.parts:
|
||||
scannedVideo = scanVideo(part)
|
||||
videos[scannedVideo] = part
|
||||
return videos
|
||||
# clear old task data
|
||||
scheduler.clear_task_data()
|
||||
|
||||
def scanVideo(part):
|
||||
embedded_subtitles = Prefs['subtitles.scan.embedded']
|
||||
external_subtitles = Prefs['subtitles.scan.external']
|
||||
|
||||
Log.Debug("Scanning video: %s, subtitles=%s, embedded_subtitles=%s" % (part.file, external_subtitles, embedded_subtitles))
|
||||
try:
|
||||
return subliminal.video.scan_video(part.file, subtitles=external_subtitles, embedded_subtitles=embedded_subtitles)
|
||||
except ValueError:
|
||||
Log.Warn("File could not be guessed by subliminal")
|
||||
# init defaults; perhaps not the best idea to use ValidatePrefs here, but we'll see
|
||||
ValidatePrefs()
|
||||
Log.Debug(config.full_version)
|
||||
|
||||
def downloadBestSubtitles(videos):
|
||||
min_score = int(Prefs['subtitles.search.minimumScore'])
|
||||
hearing_impaired = Prefs['subtitles.search.hearingImpaired']
|
||||
Log.Debug("Download best subtitles using settings: min_score: %s, hearing_impaired: %s" %(min_score, hearing_impaired))
|
||||
|
||||
return subliminal.api.download_best_subtitles(videos, getLangList(), min_score, hearing_impaired, providers=getProviders(), provider_configs=getProviderSettings())
|
||||
if not config.permissions_ok:
|
||||
Log.Error("Insufficient permissions on library folders:")
|
||||
for title, path in config.missing_permissions:
|
||||
Log.Error("Insufficient permissions on library %s, folder: %s" % (title, path))
|
||||
|
||||
def saveSubtitles(videos, subtitles):
|
||||
if Prefs['subtitles.save.filesystem']:
|
||||
Log.Debug("Saving subtitles to filesystem")
|
||||
saveSubtitlesToFile(subtitles)
|
||||
else:
|
||||
Log.Debug("Saving subtitles as metadata")
|
||||
saveSubtitlesToMetadata(videos, subtitles)
|
||||
# run task scheduler
|
||||
scheduler.run()
|
||||
|
||||
def saveSubtitlesToFile(subtitles):
|
||||
fld_custom = Prefs["subtitles.save.subFolder.Custom"].strip() if bool(Prefs["subtitles.save.subFolder.Custom"]) else None
|
||||
if Prefs["subtitles.save.subFolder"] != "current folder" or fld_custom:
|
||||
# specific subFolder requested, create it if it doesn't exist
|
||||
for video, video_subtitles in subtitles.items():
|
||||
fld_base = os.path.split(video.name)[0]
|
||||
if fld_custom:
|
||||
if fld_custom.startswith("/"):
|
||||
# absolute folder
|
||||
fld = fld_custom
|
||||
# bind activities
|
||||
if config.enable_channel:
|
||||
Thread.Create(activity.start)
|
||||
|
||||
if "anon_id" not in Dict:
|
||||
Dict["anon_id"] = get_identifier()
|
||||
|
||||
# track usage
|
||||
if cast_bool(Prefs["track_usage"]):
|
||||
if "first_use" not in Dict:
|
||||
Dict["first_use"] = datetime.datetime.utcnow()
|
||||
Dict.Save()
|
||||
track_usage("General", "plugin", "first_start", config.version)
|
||||
track_usage("General", "plugin", "start", config.version)
|
||||
|
||||
|
||||
def update_local_media(videos, ignore_parts_cleanup=None):
|
||||
for video in videos:
|
||||
find_subtitles(video["plex_part"], ignore_parts_cleanup=ignore_parts_cleanup)
|
||||
|
||||
|
||||
class SubZeroAgent(object):
|
||||
agent_type = None
|
||||
agent_type_verbose = None
|
||||
languages = [Locale.Language.English]
|
||||
primary_provider = False
|
||||
score_prefs_key = None
|
||||
debounce = 10
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(SubZeroAgent, self).__init__(*args, **kwargs)
|
||||
self.agent_type = "movies" if isinstance(self, Agent.Movies) else "series"
|
||||
self.name = "Sub-Zero Subtitles (%s, %s)" % (self.agent_type_verbose, config.get_version())
|
||||
|
||||
def search(self, results, media, lang):
|
||||
Log.Debug("Sub-Zero %s, %s search" % (config.version, self.agent_type))
|
||||
results.Append(MetadataSearchResult(id='null', score=100))
|
||||
|
||||
def store_blank_subtitle_metadata(self, video_part_map):
|
||||
store_subtitle_info(video_part_map, dict((k, []) for k in video_part_map.keys()), None, mode="a")
|
||||
|
||||
def update(self, metadata, media, lang):
|
||||
if not config.enable_agent:
|
||||
Log.Debug("Skipping Sub-Zero agent(s)")
|
||||
return
|
||||
|
||||
Log.Debug("Sub-Zero %s, %s update called" % (config.version, self.agent_type))
|
||||
|
||||
if not media:
|
||||
Log.Error("Called with empty media, something is really wrong with your setup!")
|
||||
return
|
||||
|
||||
intent = get_intent()
|
||||
|
||||
item_ids = []
|
||||
try:
|
||||
config.init_subliminal_patches()
|
||||
all_videos = media_to_videos(media, kind=self.agent_type)
|
||||
|
||||
# media ignored?
|
||||
ignore_parts_cleanup = []
|
||||
videos = []
|
||||
for video in all_videos:
|
||||
if not is_wanted(video["id"], item=video["item"]):
|
||||
Log.Debug(u'Skipping "%s"' % video["filename"])
|
||||
ignore_parts_cleanup.append(video["path"])
|
||||
continue
|
||||
videos.append(video)
|
||||
|
||||
# find local media
|
||||
update_local_media(all_videos, ignore_parts_cleanup=ignore_parts_cleanup)
|
||||
|
||||
if not videos:
|
||||
Log.Debug(u"Nothing to do.")
|
||||
return
|
||||
|
||||
try:
|
||||
use_score = int(Prefs[self.score_prefs_key].strip())
|
||||
except ValueError:
|
||||
Log.Error("Please only put numbers into the scores setting. Exiting")
|
||||
return
|
||||
|
||||
set_refresh_menu_state(media, media_type=self.agent_type)
|
||||
|
||||
# scanned_video_part_map = {subliminal.Video: plex_part, ...}
|
||||
providers = config.get_providers(media_type=self.agent_type)
|
||||
try:
|
||||
scanned_video_part_map = scan_videos(videos, providers=providers)
|
||||
except IOError, e:
|
||||
Log.Exception("Permission error, please check your folder/file permissions. Exiting.")
|
||||
if cast_bool(Prefs["check_permissions"]):
|
||||
config.permissions_ok = False
|
||||
config.missing_permissions = e.message
|
||||
return
|
||||
|
||||
# auto extract embedded
|
||||
if config.embedded_auto_extract:
|
||||
if config.plex_transcoder:
|
||||
agent_extract_embedded(scanned_video_part_map)
|
||||
else:
|
||||
fld = os.path.join(fld_base, fld_custom)
|
||||
Log.Warn("Plex Transcoder not found, can't auto extract")
|
||||
|
||||
# clear missing subtitles menu data
|
||||
if not scheduler.is_task_running("MissingSubtitles"):
|
||||
scheduler.clear_task_data("MissingSubtitles")
|
||||
|
||||
downloaded_subtitles = None
|
||||
|
||||
# debounce for self.debounce seconds
|
||||
now = datetime.datetime.now()
|
||||
if "last_call" in Dict:
|
||||
last_call = Dict["last_call"]
|
||||
if last_call + datetime.timedelta(seconds=self.debounce) > now:
|
||||
wait = self.debounce - (now - last_call).seconds
|
||||
if wait >= 1:
|
||||
Log.Debug("Waiting %s seconds until continuing", wait)
|
||||
Thread.Sleep(wait)
|
||||
|
||||
# downloaded_subtitles = {subliminal.Video: [subtitle, subtitle, ...]}
|
||||
try:
|
||||
downloaded_subtitles = download_best_subtitles(scanned_video_part_map, min_score=use_score,
|
||||
throttle_time=self.debounce, providers=providers)
|
||||
except:
|
||||
Log.Exception("Something went wrong when downloading subtitles")
|
||||
|
||||
if downloaded_subtitles is not None:
|
||||
Dict["last_call"] = datetime.datetime.now()
|
||||
|
||||
item_ids = get_media_item_ids(media, kind=self.agent_type)
|
||||
|
||||
downloaded_any = False
|
||||
if downloaded_subtitles:
|
||||
downloaded_any = any(downloaded_subtitles.values())
|
||||
|
||||
if downloaded_any:
|
||||
save_successful = False
|
||||
try:
|
||||
save_successful = save_subtitles(scanned_video_part_map, downloaded_subtitles,
|
||||
mods=config.default_mods)
|
||||
except:
|
||||
Log.Exception("Something went wrong when saving subtitles")
|
||||
|
||||
track_usage("Subtitle", "refreshed", "download", 1)
|
||||
|
||||
# store SZ meta info even if download wasn't successful
|
||||
if not save_successful:
|
||||
self.store_blank_subtitle_metadata(scanned_video_part_map)
|
||||
|
||||
else:
|
||||
for video, video_subtitles in downloaded_subtitles.items():
|
||||
# store item(s) in history
|
||||
for subtitle in video_subtitles:
|
||||
history = get_history()
|
||||
item_title = get_title_for_video_metadata(video.plexapi_metadata, add_section_title=False)
|
||||
history.add(item_title, video.id, section_title=video.plexapi_metadata["section"],
|
||||
thumb=video.plexapi_metadata["super_thumb"],
|
||||
subtitle=subtitle)
|
||||
history.destroy()
|
||||
else:
|
||||
fld = os.path.join(fld_base, Prefs["subtitles.save.subFolder"])
|
||||
if not os.path.exists(fld):
|
||||
os.makedirs(fld)
|
||||
subliminal.api.save_subtitles(video, video_subtitles, directory=fld)
|
||||
|
||||
else:
|
||||
subliminal.api.save_subtitles(subtitles)
|
||||
# store SZ meta info even if we've downloaded none
|
||||
self.store_blank_subtitle_metadata(scanned_video_part_map)
|
||||
|
||||
def saveSubtitlesToMetadata(videos, subtitles):
|
||||
for video, video_subtitles in subtitles.items():
|
||||
mediaPart = videos[video]
|
||||
for subtitle in video_subtitles:
|
||||
mediaPart.subtitles[Locale.Language.Match(subtitle.language.alpha2)][subtitle.page_link] = Proxy.Media(subtitle.content, ext="srt")
|
||||
update_local_media(videos)
|
||||
|
||||
class SubliminalSubtitlesAgentMovies(Agent.Movies):
|
||||
name = 'Subliminal Movie Subtitles'
|
||||
languages = [Locale.Language.English]
|
||||
primary_provider = False
|
||||
contributes_to = ['com.plexapp.agents.imdb']
|
||||
finally:
|
||||
# update the menu state
|
||||
set_refresh_menu_state(None)
|
||||
|
||||
def search(self, results, media, lang):
|
||||
Log.Debug("MOVIE SEARCH CALLED")
|
||||
results.Append(MetadataSearchResult(id='null', score=100))
|
||||
# notify any running tasks about our finished update
|
||||
for item_id in item_ids:
|
||||
#scheduler.signal("updated_metadata", item_id)
|
||||
|
||||
def update(self, metadata, media, lang):
|
||||
Log.Debug("MOVIE UPDATE CALLED")
|
||||
videos = scanMovieMedia(media)
|
||||
subtitles = downloadBestSubtitles(videos.keys())
|
||||
saveSubtitles(videos, subtitles)
|
||||
# resolve existing intent for that id
|
||||
intent.resolve("force", item_id)
|
||||
|
||||
class SubliminalSubtitlesAgentTvShows(Agent.TV_Shows):
|
||||
|
||||
name = 'Subliminal TV Subtitles'
|
||||
languages = [Locale.Language.English]
|
||||
primary_provider = False
|
||||
contributes_to = ['com.plexapp.agents.thetvdb', 'com.plexapp.agents.thetvdbdvdorder']
|
||||
Dict.Save()
|
||||
|
||||
def search(self, results, media, lang):
|
||||
Log.Debug("TV SEARCH CALLED")
|
||||
results.Append(MetadataSearchResult(id='null', score=100))
|
||||
# fsync cache
|
||||
if config.new_style_cache:
|
||||
config.sync_cache()
|
||||
|
||||
def update(self, metadata, media, lang):
|
||||
Log.Debug("TvUpdate. Lang %s" % lang)
|
||||
videos = scanTvMedia(media)
|
||||
subtitles = downloadBestSubtitles(videos.keys())
|
||||
saveSubtitles(videos, subtitles)
|
||||
|
||||
class SubZeroSubtitlesAgentMovies(SubZeroAgent, Agent.Movies):
|
||||
contributes_to = ['com.plexapp.agents.imdb', 'com.plexapp.agents.xbmcnfo', 'com.plexapp.agents.themoviedb',
|
||||
'com.plexapp.agents.hama', 'tv.plex.agents.movie']
|
||||
score_prefs_key = "subtitles.search.minimumMovieScore2"
|
||||
agent_type_verbose = "Movies"
|
||||
|
||||
|
||||
class SubZeroSubtitlesAgentTvShows(SubZeroAgent, Agent.TV_Shows):
|
||||
contributes_to = ['com.plexapp.agents.thetvdb', 'com.plexapp.agents.themoviedb',
|
||||
'com.plexapp.agents.thetvdbdvdorder', 'com.plexapp.agents.xbmcnfotv', 'com.plexapp.agents.hama']
|
||||
score_prefs_key = "subtitles.search.minimumTVScore2"
|
||||
agent_type_verbose = "TV"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import sys
|
||||
|
||||
import menu
|
||||
sys.modules["interface.menu"] = menu
|
||||
sys.modules["menu"] = menu
|
||||
|
||||
import menu_helpers
|
||||
sys.modules["interface.menu_helpers"] = menu_helpers
|
||||
|
||||
import advanced
|
||||
sys.modules["interface.advanced"] = advanced
|
||||
|
||||
import main
|
||||
sys.modules["interface.main"] = main
|
||||
|
||||
import refresh_item
|
||||
sys.modules["interface.refresh_item"] = refresh_item
|
||||
|
||||
import item_details
|
||||
sys.modules["interface.item_details"] = item_details
|
||||
|
||||
import sub_mod
|
||||
sys.modules["interface.modification"] = sub_mod
|
||||
@@ -0,0 +1,454 @@
|
||||
# coding=utf-8
|
||||
import datetime
|
||||
import StringIO
|
||||
import glob
|
||||
import os
|
||||
import traceback
|
||||
import urlparse
|
||||
|
||||
from zipfile import ZipFile, ZIP_DEFLATED
|
||||
|
||||
from subzero.language import Language
|
||||
|
||||
from subzero.lib.io import FileIO
|
||||
from subzero.constants import PREFIX, PLUGIN_IDENTIFIER
|
||||
from menu_helpers import SubFolderObjectContainer, debounce, set_refresh_menu_state, ZipObject, ObjectContainer, route
|
||||
from main import fatality
|
||||
from support.helpers import timestamp, pad_title
|
||||
from support.config import config
|
||||
from support.lib import Plex
|
||||
from support.storage import reset_storage, log_storage, get_subtitle_storage
|
||||
from support.scheduler import scheduler
|
||||
from support.items import set_mods_for_part, get_item_kind_from_rating_key
|
||||
from support.i18n import _
|
||||
|
||||
|
||||
@route(PREFIX + '/advanced')
|
||||
def AdvancedMenu(randomize=None, header=None, message=None):
|
||||
oc = SubFolderObjectContainer(
|
||||
header=header or _("Internal stuff, pay attention!"),
|
||||
message=message,
|
||||
no_cache=True,
|
||||
no_history=True,
|
||||
replace_parent=False,
|
||||
title2=_("Advanced"))
|
||||
|
||||
if config.lock_advanced_menu and not config.pin_correct:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(
|
||||
PinMenu,
|
||||
randomize=timestamp(),
|
||||
success_go_to=_("advanced")),
|
||||
title=pad_title(_("Enter PIN")),
|
||||
summary=_("The owner has restricted the access to this menu. Please enter the correct pin"),
|
||||
))
|
||||
return oc
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(TriggerRestart, randomize=timestamp()),
|
||||
title=pad_title(_("Restart the plugin")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(GetLogsLink),
|
||||
title=_("Get my logs (copy the appearing link and open it in your browser, please)"),
|
||||
summary=_("Copy the appearing link and open it in your browser, please"),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(TriggerBetterSubtitles, randomize=timestamp()),
|
||||
title=pad_title(_("Trigger find better subtitles")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SkipFindBetterSubtitles, randomize=timestamp()),
|
||||
title=pad_title(_("Skip next find better subtitles (sets last run to now)")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SkipRecentlyAddedMissing, randomize=timestamp()),
|
||||
title=pad_title(_("Skip next find recently added with missing subtitles (sets last run to now)")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(TriggerStorageMaintenance, randomize=timestamp()),
|
||||
title=pad_title(_("Trigger subtitle storage maintenance")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(TriggerStorageMigration, randomize=timestamp()),
|
||||
title=pad_title(_("Trigger subtitle storage migration (expensive)")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(TriggerCacheMaintenance, randomize=timestamp()),
|
||||
title=pad_title(_("Trigger cache maintenance (refiners, providers and packs/archives)")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ApplyDefaultMods, randomize=timestamp()),
|
||||
title=pad_title(_("Apply configured default subtitle mods to all (active) stored subtitles")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ReApplyMods, randomize=timestamp()),
|
||||
title=pad_title(_("Re-Apply mods of all stored subtitles")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(LogStorage, key="tasks", randomize=timestamp()),
|
||||
title=pad_title(_("Log the plugin's scheduled tasks state storage")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(LogStorage, key="ignore", randomize=timestamp()),
|
||||
title=pad_title(_("Log the plugin's internal ignorelist storage")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(LogStorage, key=None, randomize=timestamp()),
|
||||
title=pad_title(_("Log the plugin's complete state storage")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ResetStorage, key="tasks", randomize=timestamp()),
|
||||
title=pad_title(_("Reset the plugin's scheduled tasks state storage")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ResetStorage, key="ignore", randomize=timestamp()),
|
||||
title=pad_title(_("Reset the plugin's internal ignorelist storage")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ResetStorage, key="menu_history", randomize=timestamp()),
|
||||
title=pad_title(_("Reset the plugin's menu history storage")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(InvalidateCache, randomize=timestamp()),
|
||||
title=pad_title(_("Invalidate Sub-Zero metadata caches (subliminal)")),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ResetProviderThrottle, randomize=timestamp()),
|
||||
title=pad_title(_("Reset provider throttle states")),
|
||||
))
|
||||
return oc
|
||||
|
||||
|
||||
def DispatchRestart():
|
||||
Thread.CreateTimer(1.0, Restart)
|
||||
|
||||
|
||||
@route(PREFIX + '/advanced/restart/trigger')
|
||||
@debounce
|
||||
def TriggerRestart(randomize=None):
|
||||
set_refresh_menu_state(_("Restarting the plugin"))
|
||||
DispatchRestart()
|
||||
return fatality(
|
||||
header=_("Restart triggered, please wait about 5 seconds"),
|
||||
force_title=" ",
|
||||
only_refresh=True,
|
||||
replace_parent=True,
|
||||
no_history=True,
|
||||
randomize=timestamp())
|
||||
|
||||
|
||||
@route(PREFIX + '/advanced/restart/execute')
|
||||
@debounce
|
||||
def Restart(randomize=None):
|
||||
Plex[":/plugins"].restart(PLUGIN_IDENTIFIER)
|
||||
|
||||
|
||||
@route(PREFIX + '/storage/reset', sure=bool)
|
||||
@debounce
|
||||
def ResetStorage(key, randomize=None, sure=False):
|
||||
if not sure:
|
||||
oc = SubFolderObjectContainer(
|
||||
no_history=True,
|
||||
title1=_("Reset subtitle storage"),
|
||||
title2=_("Are you sure?"))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(
|
||||
ResetStorage,
|
||||
key=key,
|
||||
sure=True,
|
||||
randomize=timestamp()),
|
||||
title=pad_title(_("Are you really sure?")),
|
||||
|
||||
))
|
||||
return oc
|
||||
|
||||
reset_storage(key)
|
||||
|
||||
if key == "tasks":
|
||||
# reinitialize the scheduler
|
||||
scheduler.init_storage()
|
||||
scheduler.setup_tasks()
|
||||
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("Information Storage (%s) reset", key)
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/storage/log')
|
||||
def LogStorage(key, randomize=None):
|
||||
log_storage(key)
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("Information Storage (%s) logged", key)
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/triggerbetter')
|
||||
@debounce
|
||||
def TriggerBetterSubtitles(randomize=None):
|
||||
scheduler.dispatch_task("FindBetterSubtitles")
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("FindBetterSubtitles triggered")
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/skipbetter')
|
||||
@debounce
|
||||
def SkipFindBetterSubtitles(randomize=None):
|
||||
task = scheduler.task("FindBetterSubtitles")
|
||||
task.last_run = datetime.datetime.now()
|
||||
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("FindBetterSubtitles skipped")
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/skipram')
|
||||
@debounce
|
||||
def SkipRecentlyAddedMissing(randomize=None):
|
||||
task = scheduler.task("SearchAllRecentlyAddedMissing")
|
||||
task.last_run = datetime.datetime.now()
|
||||
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("SearchAllRecentlyAddedMissing skipped")
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/triggermaintenance')
|
||||
@debounce
|
||||
def TriggerStorageMaintenance(randomize=None):
|
||||
scheduler.dispatch_task("SubtitleStorageMaintenance")
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("SubtitleStorageMaintenance triggered")
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/triggerstoragemigration')
|
||||
@debounce
|
||||
def TriggerStorageMigration(randomize=None):
|
||||
scheduler.dispatch_task("MigrateSubtitleStorage")
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("MigrateSubtitleStorage triggered")
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/triggercachemaintenance')
|
||||
@debounce
|
||||
def TriggerCacheMaintenance(randomize=None):
|
||||
scheduler.dispatch_task("CacheMaintenance")
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("TriggerCacheMaintenance triggered")
|
||||
)
|
||||
|
||||
|
||||
def apply_default_mods(reapply_current=False, scandir_generic=False):
|
||||
storage = get_subtitle_storage()
|
||||
subs_applied = 0
|
||||
|
||||
try:
|
||||
for fn in storage.get_all_files(scandir_generic=scandir_generic):
|
||||
data = storage.load(None, filename=fn)
|
||||
if data:
|
||||
video_id = data.video_id
|
||||
item_type = get_item_kind_from_rating_key(video_id)
|
||||
if not item_type:
|
||||
continue
|
||||
|
||||
for part_id, part in data.parts.iteritems():
|
||||
for lang, subs in part.iteritems():
|
||||
current_sub = subs.get("current")
|
||||
if not current_sub:
|
||||
continue
|
||||
sub = subs[current_sub]
|
||||
|
||||
if not sub.content:
|
||||
continue
|
||||
|
||||
current_mods = sub.mods or []
|
||||
if not reapply_current:
|
||||
add_mods = list(set(config.default_mods).difference(set(current_mods)))
|
||||
if not add_mods:
|
||||
continue
|
||||
else:
|
||||
if not current_mods:
|
||||
continue
|
||||
add_mods = []
|
||||
|
||||
try:
|
||||
set_mods_for_part(video_id, part_id, Language.fromietf(lang), item_type, add_mods, mode="add")
|
||||
except:
|
||||
Log.Error("Couldn't set mods for %s:%s: %s", video_id, part_id, traceback.format_exc())
|
||||
continue
|
||||
|
||||
subs_applied += 1
|
||||
except OSError:
|
||||
return apply_default_mods(reapply_current=reapply_current, scandir_generic=True)
|
||||
storage.destroy()
|
||||
Log.Debug("Applied mods to %i items" % subs_applied)
|
||||
|
||||
|
||||
@route(PREFIX + '/applydefaultmods')
|
||||
@debounce
|
||||
def ApplyDefaultMods(randomize=None):
|
||||
Thread.CreateTimer(1.0, apply_default_mods)
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("This may take some time ...")
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/reapplyallmods')
|
||||
@debounce
|
||||
def ReApplyMods(randomize=None):
|
||||
Thread.CreateTimer(1.0, apply_default_mods, reapply_current=True)
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("This may take some time ...")
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/get_logs_link')
|
||||
def GetLogsLink():
|
||||
if not config.plex_token:
|
||||
oc = ObjectContainer(
|
||||
title2=_("Download Logs"),
|
||||
no_cache=True,
|
||||
no_history=True,
|
||||
header=_("Sorry, feature unavailable"),
|
||||
message=_("Universal Plex token not available"))
|
||||
return oc
|
||||
|
||||
# try getting the link base via the request in context, first, otherwise use the public ip
|
||||
req_headers = Core.sandbox.context.request.headers
|
||||
get_external_ip = True
|
||||
link_base = ""
|
||||
|
||||
if "Origin" in req_headers:
|
||||
link_base = req_headers["Origin"]
|
||||
Log.Debug("Using origin-based link_base")
|
||||
get_external_ip = False
|
||||
|
||||
elif "Referer" in req_headers:
|
||||
parsed = urlparse.urlparse(req_headers["Referer"])
|
||||
link_base = "%s://%s%s" % (parsed.scheme, parsed.hostname, (":%s" % parsed.port) if parsed.port else "")
|
||||
Log.Debug("Using referer-based link_base")
|
||||
get_external_ip = False
|
||||
|
||||
if get_external_ip or "plex.tv" in link_base:
|
||||
ip = Core.networking.http_request("http://www.plexapp.com/ip.php", cacheTime=7200).content.strip()
|
||||
link_base = "https://%s:32400" % ip
|
||||
Log.Debug("Using ip-based fallback link_base")
|
||||
|
||||
logs_link = "%s%s?X-Plex-Token=%s" % (link_base, PREFIX + '/logs', config.plex_token)
|
||||
oc = ObjectContainer(
|
||||
title2=logs_link,
|
||||
no_cache=True,
|
||||
no_history=True,
|
||||
header=_("Copy this link and open this in your browser, please"),
|
||||
message=logs_link)
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/logs')
|
||||
def DownloadLogs():
|
||||
buff = StringIO.StringIO()
|
||||
zip_archive = ZipFile(buff, mode='w', compression=ZIP_DEFLATED)
|
||||
|
||||
logs = sorted(glob.glob(config.plugin_log_path + '*')) + [config.server_log_path]
|
||||
for path in logs:
|
||||
data = StringIO.StringIO()
|
||||
data.write(FileIO.read(path))
|
||||
zip_archive.writestr(os.path.basename(path), data.getvalue())
|
||||
|
||||
zip_archive.close()
|
||||
|
||||
return ZipObject(buff.getvalue())
|
||||
|
||||
|
||||
@route(PREFIX + '/invalidatecache')
|
||||
@debounce
|
||||
def InvalidateCache(randomize=None):
|
||||
from subliminal.cache import region
|
||||
if config.new_style_cache:
|
||||
region.backend.clear()
|
||||
else:
|
||||
region.invalidate()
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("Cache invalidated")
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/pin')
|
||||
def PinMenu(pin="", randomize=None, success_go_to="channel"):
|
||||
oc = ObjectContainer(
|
||||
title2=_("Enter PIN number ") + str(len(pin) + 1),
|
||||
no_cache=True,
|
||||
no_history=True,
|
||||
skip_pin_lock=True)
|
||||
|
||||
if pin == config.pin:
|
||||
Dict["pin_correct_time"] = datetime.datetime.now()
|
||||
config.locked = False
|
||||
if success_go_to == "channel":
|
||||
return fatality(
|
||||
force_title=_("PIN correct"),
|
||||
header=_("PIN correct"),
|
||||
no_history=True)
|
||||
elif success_go_to == "advanced":
|
||||
return AdvancedMenu(randomize=timestamp())
|
||||
|
||||
for i in range(10):
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(
|
||||
PinMenu,
|
||||
randomize=timestamp(),
|
||||
pin=pin + str(i),
|
||||
success_go_to=success_go_to),
|
||||
title=pad_title(str(i)),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(
|
||||
PinMenu,
|
||||
randomize=timestamp(),
|
||||
success_go_to=success_go_to),
|
||||
title=pad_title(_("Reset")),
|
||||
))
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/pin_lock')
|
||||
def ClearPin(randomize=None):
|
||||
Dict["pin_correct_time"] = None
|
||||
config.locked = True
|
||||
return fatality(force_title=_("Menu locked"), header=" ", no_history=True)
|
||||
|
||||
|
||||
@route(PREFIX + '/reset_throttle')
|
||||
def ResetProviderThrottle(randomize=None):
|
||||
Dict["provider_throttle"] = {}
|
||||
Dict.Save()
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header=_("Success"),
|
||||
message=_("Provider throttles reset")
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
# coding=utf-8
|
||||
|
||||
import datetime
|
||||
import operator
|
||||
|
||||
from support.config import config
|
||||
from support.helpers import timestamp
|
||||
|
||||
|
||||
def enable_channel_wrapper(func, enforce_route=False):
|
||||
"""
|
||||
returns the original wrapper :func: (route or handler) if applicable, else the plain to-be-wrapped function
|
||||
:param func: original wrapper
|
||||
:return: original wrapper or wrapped function
|
||||
"""
|
||||
def noop(*args, **kwargs):
|
||||
def inner(*a, **k):
|
||||
"""
|
||||
:param a: args
|
||||
:param k: kwargs
|
||||
:return: originally to-be-wrapped function
|
||||
"""
|
||||
return a[0] if len(a) else a
|
||||
|
||||
return inner
|
||||
|
||||
def wrap(*args, **kwargs):
|
||||
return (func if (config.enable_channel or enforce_route) else noop)(*args, **kwargs)
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
ROUTE_REGISTRY = {}
|
||||
|
||||
|
||||
def get_func_name(args):
|
||||
return list(args).pop(0).__name__
|
||||
|
||||
|
||||
def get_lookup_key(f, args, kwargs):
|
||||
return tuple([f.__name__, tuple(args), tuple([(key, value) for key, value in kwargs.iteritems()])])
|
||||
|
||||
|
||||
def should_debounce(f, key, kw):
|
||||
return getattr(f, "debounce", False) and "randomize" in kw and key in Dict["menu_history"]
|
||||
|
||||
|
||||
def register_route_function(f):
|
||||
fn = f.__name__
|
||||
if fn != "ValidatePrefs" and fn not in ROUTE_REGISTRY:
|
||||
ROUTE_REGISTRY[fn] = f
|
||||
return f
|
||||
|
||||
|
||||
def main_menu_fallback():
|
||||
key = get_lookup_key(ROUTE_REGISTRY["fatality"], [], {})
|
||||
Dict["last_menu_item"] = key
|
||||
add_to_menu_history(key)
|
||||
|
||||
return ROUTE_REGISTRY["fatality"](randomize=timestamp())
|
||||
|
||||
|
||||
def add_to_menu_history(key):
|
||||
# add function to menu history
|
||||
mh = Dict["menu_history"]
|
||||
if key in mh:
|
||||
del mh[key]
|
||||
|
||||
mh[key] = datetime.datetime.now() + datetime.timedelta(hours=6)
|
||||
|
||||
# limit to 25 items
|
||||
Dict["menu_history"] = dict(sorted(sorted(mh.items(), key=operator.itemgetter(1),
|
||||
reverse=True)[:25]))
|
||||
|
||||
try:
|
||||
Dict.Save()
|
||||
except TypeError:
|
||||
Log.Error("Can't save menu history for: %r", key)
|
||||
del Dict["menu_history"][key]
|
||||
|
||||
|
||||
def route_wrapper(*args, **kwargs):
|
||||
def wrap(f):
|
||||
already_wrapped = getattr(f, "orig_f", False)
|
||||
|
||||
register_route_function(f)
|
||||
|
||||
def inner(*a, **kw):
|
||||
if "menu_history" not in Dict:
|
||||
Dict["menu_history"] = {}
|
||||
|
||||
if "last_menu_item" not in Dict:
|
||||
Dict["last_menu_item"] = None
|
||||
|
||||
key = get_lookup_key(f, list(a), kw)
|
||||
|
||||
ret_f = f
|
||||
ret_a = a
|
||||
ret_kw = kw
|
||||
# mh = Dict["menu_history"]
|
||||
# mh_keys = [k for k, v in sorted(mh.items(), key=operator.itemgetter(1))]
|
||||
#
|
||||
# fallback_needed = False
|
||||
# fallback_found = False
|
||||
|
||||
if should_debounce(ret_f, key, kw):
|
||||
# special case for TriggerRestart
|
||||
if ret_f.__name__ in ("TriggerRestart", "Restart"):
|
||||
Log.Debug("Don't trigger a re-restart, falling back to main menu")
|
||||
else:
|
||||
Log.Debug("not triggering %s twice with %s, %s, returning to main menu" %
|
||||
(f.__name__, a, kw))
|
||||
|
||||
return main_menu_fallback()
|
||||
#
|
||||
# fallback_needed = True
|
||||
#
|
||||
# # try to find a suitable fallback route in case we've encountered an already visited
|
||||
# # debounced route
|
||||
# fallbacks = []
|
||||
# current_last_visit = mh[key]
|
||||
# last_menu_item = Dict["last_menu_item"]
|
||||
# direction_backwards = True
|
||||
#
|
||||
# if last_menu_item and last_menu_item in mh and key in mh:
|
||||
# last_mi_pos = mh_keys.index(last_menu_item)
|
||||
# current_mi_pos = mh_keys.index(key)
|
||||
# if current_mi_pos > -1 and last_mi_pos > -1:
|
||||
# print "SHEKEL", current_mi_pos, last_mi_pos, current_mi_pos < last_mi_pos
|
||||
|
||||
# only consider items in menu history that have an older timestamp than the current
|
||||
# for key_, last_visit in sorted(mh.items(), key=operator.itemgetter(1),
|
||||
# reverse=True):
|
||||
# if last_visit < current_last_visit:
|
||||
# fallbacks.append(key_)
|
||||
#
|
||||
# for key_ in fallbacks:
|
||||
# # old data structure
|
||||
# if not len(key_) == 3 or not (isinstance(key_[1], tuple) and isinstance(key_[2], tuple)):
|
||||
# continue
|
||||
#
|
||||
# old_f, old_a, old_kw = key_
|
||||
# if old_f == "ValidatePrefs":
|
||||
# continue
|
||||
#
|
||||
# possible_fallback = ROUTE_REGISTRY[old_f]
|
||||
#
|
||||
# # non-debounced function found
|
||||
# if not getattr(possible_fallback, "debounce", False):
|
||||
# ret_kw = dict(old_kw)
|
||||
# ret_a = old_a
|
||||
# if "randomize" in ret_kw:
|
||||
# ret_kw["randomize"] = timestamp()
|
||||
#
|
||||
# ret_f = possible_fallback
|
||||
# key = get_lookup_key(ret_f, list(ret_a), ret_kw)
|
||||
# fallback_found = True
|
||||
#
|
||||
# Log.Debug("not triggering %s twice with %s, %s, returning to %s, %s, %s" %
|
||||
# (f.__name__, a, kw, ret_f.__name__, ret_a, ret_kw))
|
||||
#
|
||||
# break
|
||||
#
|
||||
# if not fallback_found:
|
||||
# Log.Debug("No fallback found in menu history for %s, falling back to main menu", f)
|
||||
# return main_menu_fallback()
|
||||
|
||||
# if not fallback_needed:
|
||||
# add_to_menu_history(key)
|
||||
# if ret_f.__name__ != "ValidatePrefs":
|
||||
# Dict["last_menu_item"] = key
|
||||
#
|
||||
add_to_menu_history(key)
|
||||
Dict["last_menu_item"] = key
|
||||
return ret_f(*ret_a, **ret_kw)
|
||||
|
||||
# @route may be used multiple times
|
||||
enforce_route = kwargs.pop("enforce_route", None)
|
||||
if not already_wrapped:
|
||||
inner.orig_f = f
|
||||
|
||||
return enable_channel_wrapper(route(*args, **kwargs), enforce_route=enforce_route)(inner)
|
||||
return enable_channel_wrapper(route(*args, **kwargs), enforce_route=enforce_route)(f)
|
||||
|
||||
return wrap
|
||||
@@ -0,0 +1,725 @@
|
||||
# coding=utf-8
|
||||
import os
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from subzero.language import Language
|
||||
|
||||
from sub_mod import SubtitleModificationsMenu
|
||||
from menu_helpers import debounce, SubFolderObjectContainer, default_thumb, add_incl_excl_options, get_item_task_data, \
|
||||
set_refresh_menu_state, route
|
||||
from support.extract import extract_embedded_sub
|
||||
|
||||
from refresh_item import RefreshItem
|
||||
from subzero.constants import PREFIX
|
||||
from support.config import config, TEXT_SUBTITLE_EXTS
|
||||
from support.helpers import timestamp, df, get_language, display_language, get_language_from_stream
|
||||
from support.items import get_item_kind_from_rating_key, get_item, get_current_sub, get_item_title, save_stored_sub
|
||||
from support.plex_media import get_plex_metadata, get_part, get_embedded_subtitle_streams, is_stream_forced, \
|
||||
update_stream_info
|
||||
from support.scanning import scan_videos
|
||||
from support.scheduler import scheduler
|
||||
from support.storage import get_subtitle_storage
|
||||
from support.i18n import _
|
||||
|
||||
|
||||
# fixme: needs kwargs cleanup
|
||||
|
||||
@route(PREFIX + '/item/{rating_key}/actions')
|
||||
def ItemDetailsMenu(rating_key, title=None, base_title=None, item_title=None, randomize=None, header=None,
|
||||
message=None):
|
||||
"""
|
||||
displays the item details menu of an item that doesn't contain any deeper tree, such as a movie or an episode
|
||||
:param rating_key:
|
||||
:param title:
|
||||
:param base_title:
|
||||
:param item_title:
|
||||
:param randomize:
|
||||
:return:
|
||||
"""
|
||||
from interface.main import InclExclMenu
|
||||
|
||||
title = unicode(base_title) + " > " + unicode(title) if base_title else unicode(title)
|
||||
item = plex_item = get_item(rating_key)
|
||||
current_kind = get_item_kind_from_rating_key(rating_key)
|
||||
|
||||
timeout = 30
|
||||
|
||||
oc = SubFolderObjectContainer(
|
||||
title2=title,
|
||||
replace_parent=True,
|
||||
header=header,
|
||||
message=message)
|
||||
|
||||
if not item:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(
|
||||
ItemDetailsMenu,
|
||||
rating_key=rating_key,
|
||||
title=title,
|
||||
base_title=base_title,
|
||||
item_title=item_title,
|
||||
randomize=timestamp()),
|
||||
title=_(u"Item not found: %s!", item_title),
|
||||
summary=_("Plex didn't return any information about the item, please refresh it and come back later"),
|
||||
thumb=default_thumb
|
||||
))
|
||||
return oc
|
||||
|
||||
# add back to season for episode
|
||||
if current_kind == "episode":
|
||||
from interface.menu import MetadataMenu
|
||||
show = get_item(item.show.rating_key)
|
||||
season = get_item(item.season.rating_key)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(
|
||||
MetadataMenu,
|
||||
rating_key=season.rating_key,
|
||||
title=season.title,
|
||||
base_title=show.title,
|
||||
previous_item_type="show",
|
||||
previous_rating_key=show.rating_key,
|
||||
display_items=True,
|
||||
randomize=timestamp()),
|
||||
title=_(u"< Back to %s", season.title),
|
||||
summary=_("Back to %s > %s", show.title, season.title),
|
||||
thumb=season.thumb or default_thumb
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(
|
||||
RefreshItem,
|
||||
rating_key=rating_key,
|
||||
item_title=item_title,
|
||||
randomize=timestamp(),
|
||||
timeout=timeout * 1000),
|
||||
title=_(u"Refresh: %s", item_title),
|
||||
summary=_("Refreshes %(the_movie_series_season_episode)s, possibly searching for missing and picking up "
|
||||
"new subtitles on disk", the_movie_series_season_episode=_(u"the %s" % current_kind)),
|
||||
thumb=item.thumb or default_thumb
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RefreshItem, rating_key=rating_key, item_title=item_title, force=True, randomize=timestamp(),
|
||||
timeout=timeout * 1000),
|
||||
title=_(u"Force-find subtitles: %(item_title)s", item_title=item_title),
|
||||
summary=_("Issues a forced refresh, ignoring known subtitles and searching for new ones"),
|
||||
thumb=item.thumb or default_thumb
|
||||
))
|
||||
|
||||
# get stored subtitle info for item id
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
stored_subs = subtitle_storage.load_or_new(item)
|
||||
|
||||
# look for subtitles for all available media parts and all of their languages
|
||||
has_multiple_parts = len(plex_item.media) > 1
|
||||
part_index = 0
|
||||
for media in plex_item.media:
|
||||
for part in media.parts:
|
||||
filename = os.path.basename(part.file)
|
||||
if not os.path.exists(part.file):
|
||||
continue
|
||||
|
||||
update_stream_info(part)
|
||||
|
||||
part_id = str(part.id)
|
||||
part_index += 1
|
||||
|
||||
part_index_addon = u""
|
||||
part_summary_addon = u""
|
||||
if has_multiple_parts:
|
||||
part_index_addon = _(u"File %(file_part_index)s: ", file_part_index=part_index)
|
||||
part_summary_addon = u"%s " % filename
|
||||
|
||||
# iterate through all configured languages
|
||||
for lang in config.lang_list:
|
||||
# get corresponding stored subtitle data for that media part (physical media item), for language
|
||||
current_sub = stored_subs.get_any(part_id, lang)
|
||||
current_sub_id = None
|
||||
current_sub_provider_name = None
|
||||
|
||||
summary = _(u"%(part_summary)sNo current subtitle in storage", part_summary=part_summary_addon)
|
||||
current_score = None
|
||||
if current_sub:
|
||||
current_sub_id = current_sub.id
|
||||
current_sub_provider_name = current_sub.provider_name
|
||||
current_score = current_sub.score
|
||||
|
||||
summary = _(u"%(part_summary)sCurrent subtitle: %(provider_name)s (added: %(date_added)s, "
|
||||
u"%(mode)s), Language: %(language)s, Score: %(score)i, Storage: %(storage_type)s",
|
||||
part_summary=part_summary_addon,
|
||||
provider_name=_(current_sub.provider_name),
|
||||
date_added=df(current_sub.date_added),
|
||||
mode=_(current_sub.mode_verbose),
|
||||
language=display_language(lang),
|
||||
score=current_sub.score,
|
||||
storage_type=current_sub.storage_type)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleOptionsMenu, rating_key=rating_key, part_id=part_id, title=title,
|
||||
item_title=item_title, language=lang, language_name=display_language(lang),
|
||||
current_id=current_sub_id,
|
||||
item_type=plex_item.type, filename=filename, current_data=summary,
|
||||
randomize=timestamp(), current_provider=current_sub_provider_name,
|
||||
current_score=current_score),
|
||||
title=_(u"%(part_summary)sManage %(language)s subtitle", part_summary=part_index_addon,
|
||||
language=display_language(lang)),
|
||||
summary=summary
|
||||
))
|
||||
else:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ListAvailableSubsForItemMenu, rating_key=rating_key, part_id=part_id, title=title,
|
||||
item_title=item_title, language=lang, language_name=display_language(lang),
|
||||
current_id=current_sub_id,
|
||||
item_type=plex_item.type, filename=filename, current_data=summary,
|
||||
randomize=timestamp(), current_provider=current_sub_provider_name,
|
||||
current_score=current_score),
|
||||
title=_(u"%(part_summary)sList %(language)s subtitles", part_summary=part_index_addon,
|
||||
language=display_language(lang)),
|
||||
summary=summary
|
||||
))
|
||||
|
||||
if config.plex_transcoder:
|
||||
# embedded subtitles
|
||||
embedded_count = 0
|
||||
embedded_langs = []
|
||||
for stream in part.streams:
|
||||
# subtitle stream
|
||||
if stream.stream_type == 3 and not stream.stream_key and stream.codec in TEXT_SUBTITLE_EXTS:
|
||||
lang = get_language_from_stream(stream.language_code)
|
||||
is_forced = is_stream_forced(stream)
|
||||
|
||||
if not lang and config.treat_und_as_first:
|
||||
lang = list(config.lang_list)[0]
|
||||
|
||||
if lang:
|
||||
lang = Language.rebuild(lang, forced=is_forced)
|
||||
embedded_langs.append(lang)
|
||||
embedded_count += 1
|
||||
|
||||
if embedded_count:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ListEmbeddedSubsForItemMenu, rating_key=rating_key, part_id=part_id, title=title,
|
||||
item_type=plex_item.type, item_title=item_title, base_title=base_title,
|
||||
randomize=timestamp()),
|
||||
title=_(u"%(part_summary)sEmbedded subtitles (%(languages)s)",
|
||||
part_summary=part_index_addon,
|
||||
languages=", ".join(display_language(l)
|
||||
for l in list(OrderedDict.fromkeys(embedded_langs)))),
|
||||
summary=_(u"Extract embedded subtitle streams")
|
||||
))
|
||||
|
||||
ignore_title = item_title
|
||||
if current_kind == "episode":
|
||||
ignore_title = get_item_title(item)
|
||||
add_incl_excl_options(oc, "videos", title=ignore_title, rating_key=rating_key, callback_menu=InclExclMenu)
|
||||
subtitle_storage.destroy()
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/current_sub/{rating_key}/{part_id}')
|
||||
def SubtitleOptionsMenu(**kwargs):
|
||||
oc = SubFolderObjectContainer(title2=unicode(kwargs["title"]), replace_parent=True, header=kwargs.get("header"),
|
||||
message=kwargs.get("message"))
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
language = kwargs["language"]
|
||||
current_data = unicode(kwargs["current_data"])
|
||||
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
subs_count = stored_subs.count(part_id, language)
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ItemDetailsMenu, rating_key=kwargs["rating_key"], item_title=kwargs["item_title"],
|
||||
title=kwargs["title"], randomize=timestamp()),
|
||||
title=_(u"< Back to %s", kwargs["title"]),
|
||||
summary=current_data,
|
||||
thumb=default_thumb
|
||||
))
|
||||
if subs_count:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ListStoredSubsForItemMenu, randomize=timestamp(), **kwargs),
|
||||
title=_(u"Select active %(language)s subtitle", language=kwargs["language_name"]),
|
||||
summary=_(u"%(count)d subtitles in storage", count=subs_count)
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ListAvailableSubsForItemMenu, randomize=timestamp(), **kwargs),
|
||||
title=_(u"List available %(language)s subtitles", language=kwargs["language_name"]),
|
||||
summary=current_data
|
||||
))
|
||||
if current_sub:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleModificationsMenu, randomize=timestamp(), **kwargs),
|
||||
title=_(u"Modify current %(language)s subtitle", language=kwargs["language_name"]),
|
||||
summary=_(u"Currently applied mods: %(mod_list)s",
|
||||
mod_list=(", ".join(current_sub.mods) if current_sub.mods else "none"))
|
||||
))
|
||||
|
||||
if current_sub.provider_name != "embedded":
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(BlacklistSubtitleMenu, randomize=timestamp(), **kwargs),
|
||||
title=_(u"Blacklist current %(language)s subtitle and search for a new one",
|
||||
language=kwargs["language_name"]),
|
||||
summary=current_data
|
||||
))
|
||||
|
||||
current_bl, subs = stored_subs.get_blacklist(part_id, language)
|
||||
if current_bl:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ManageBlacklistMenu, randomize=timestamp(), **kwargs),
|
||||
title=_(u"Manage blacklist (%(amount)s contained)", amount=len(current_bl)),
|
||||
summary=_(u"Inspect currently blacklisted subtitles")
|
||||
))
|
||||
|
||||
storage.destroy()
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/list_stored_subs/{rating_key}/{part_id}')
|
||||
def ListStoredSubsForItemMenu(**kwargs):
|
||||
oc = SubFolderObjectContainer(title2=unicode(kwargs["title"]), replace_parent=True)
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
language = Language.fromietf(kwargs["language"])
|
||||
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
all_subs = stored_subs.get_all(part_id, language)
|
||||
kwargs.pop("randomize")
|
||||
|
||||
for key, subtitle in sorted(filter(lambda x: x[0] not in ("current", "blacklist"), all_subs.items()),
|
||||
key=lambda x: x[1].date_added, reverse=True):
|
||||
is_current = key == all_subs["current"]
|
||||
|
||||
summary = _(u"added: %(date_added)s, %(mode)s, Language: %(language)s, Score: %(score)i, Storage: "
|
||||
u"%(storage_type)s",
|
||||
date_added=df(subtitle.date_added),
|
||||
mode=_(subtitle.mode_verbose),
|
||||
language=display_language(language),
|
||||
score=subtitle.score,
|
||||
storage_type=subtitle.storage_type)
|
||||
|
||||
sub_name = subtitle.provider_name
|
||||
if sub_name == "embedded":
|
||||
sub_name += " (%s)" % subtitle.id
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SelectStoredSubForItemMenu, randomize=timestamp(), sub_key="__".join(key), **kwargs),
|
||||
title=_(u"%(current_state)s%(subtitle_name)s, Score: %(score)s",
|
||||
current_state=_("Current: ") if is_current else _("Stored: "),
|
||||
subtitle_name=sub_name,
|
||||
score=subtitle.score),
|
||||
summary=summary
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/set_current_sub/{rating_key}/{part_id}')
|
||||
@debounce
|
||||
def SelectStoredSubForItemMenu(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
language = Language.fromietf(kwargs["language"])
|
||||
item_type = kwargs["item_type"]
|
||||
sub_key = tuple(kwargs.pop("sub_key").split("__"))
|
||||
|
||||
plex_item = get_item(rating_key)
|
||||
storage = get_subtitle_storage()
|
||||
stored_subs = storage.load(plex_item.rating_key)
|
||||
|
||||
subtitles = stored_subs.get_all(part_id, language)
|
||||
subtitle = subtitles[sub_key]
|
||||
|
||||
save_stored_sub(subtitle, rating_key, part_id, language, item_type, plex_item=plex_item, storage=storage,
|
||||
stored_subs=stored_subs)
|
||||
|
||||
stored_subs.set_current(part_id, language, sub_key)
|
||||
storage.save(stored_subs)
|
||||
storage.destroy()
|
||||
|
||||
kwa = {
|
||||
"header": _("Success"),
|
||||
"message": _("Subtitle saved to disk"),
|
||||
"title": kwargs["title"],
|
||||
"item_title": kwargs["item_title"],
|
||||
"base_title": kwargs.get("base_title")
|
||||
}
|
||||
|
||||
# fixme: return to SubtitleOptionsMenu properly? (needs recomputation of current_data
|
||||
|
||||
return ItemDetailsMenu(rating_key, randomize=timestamp(), **kwa)
|
||||
|
||||
|
||||
@route(PREFIX + '/item/blacklist_recent/{language}')
|
||||
@route(PREFIX + '/item/blacklist_recent')
|
||||
def BlacklistRecentSubtitleMenu(**kwargs):
|
||||
if "last_played_items" not in Dict or not Dict["last_played_items"]:
|
||||
return
|
||||
|
||||
rating_key = Dict["last_played_items"][0]
|
||||
kwargs["rating_key"] = rating_key
|
||||
return BlacklistAllPartsSubtitleMenu(**kwargs)
|
||||
|
||||
|
||||
@route(PREFIX + '/item/blacklist_all/{rating_key}/{language}')
|
||||
@route(PREFIX + '/item/blacklist_all/{rating_key}')
|
||||
def BlacklistAllPartsSubtitleMenu(**kwargs):
|
||||
rating_key = kwargs.get("rating_key")
|
||||
language = kwargs.get("language")
|
||||
if language:
|
||||
language = Language.fromietf(language)
|
||||
|
||||
item = get_item(rating_key)
|
||||
|
||||
if not item:
|
||||
return
|
||||
|
||||
item_title = get_item_title(item)
|
||||
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
stored_subs = subtitle_storage.load_or_new(item)
|
||||
for part_id, languages in stored_subs.parts.iteritems():
|
||||
sub_dict = languages
|
||||
if language:
|
||||
key = str(language)
|
||||
if key not in sub_dict:
|
||||
continue
|
||||
|
||||
sub_dict = {key: sub_dict[key]}
|
||||
|
||||
for language, subs in sub_dict.iteritems():
|
||||
if "current" in subs:
|
||||
stored_subs.blacklist(part_id, language, subs["current"])
|
||||
Log.Info("Added %s to blacklist", subs["current"])
|
||||
|
||||
subtitle_storage.save(stored_subs)
|
||||
subtitle_storage.destroy()
|
||||
|
||||
return RefreshItem(rating_key=rating_key, item_title=item_title, force=True, randomize=timestamp(), timeout=30000)
|
||||
|
||||
|
||||
def blacklist(rating_key, part_id, language):
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
if not current_sub:
|
||||
return
|
||||
|
||||
stored_subs.blacklist(part_id, language, current_sub.key)
|
||||
storage.save(stored_subs)
|
||||
storage.destroy()
|
||||
|
||||
Log.Info("Added %s to blacklist", current_sub.key)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@route(PREFIX + '/item/blacklist/{rating_key}/{part_id}')
|
||||
@debounce
|
||||
def BlacklistSubtitleMenu(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
language = kwargs["language"]
|
||||
item_title = kwargs["item_title"]
|
||||
|
||||
blacklist(rating_key, part_id, language)
|
||||
kwargs.pop("randomize")
|
||||
|
||||
return RefreshItem(rating_key=rating_key, item_title=item_title, force=True, randomize=timestamp(), timeout=30000)
|
||||
|
||||
|
||||
@route(PREFIX + '/item/manage_blacklist/{rating_key}/{part_id}', force=bool)
|
||||
@debounce
|
||||
def ManageBlacklistMenu(**kwargs):
|
||||
oc = SubFolderObjectContainer(title2=unicode(kwargs["title"]), replace_parent=True)
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
language = kwargs["language"]
|
||||
remove_sub_key = kwargs.pop("remove_sub_key", None)
|
||||
current_data = unicode(kwargs["current_data"])
|
||||
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
current_bl, subs = stored_subs.get_blacklist(part_id, language)
|
||||
|
||||
if remove_sub_key:
|
||||
remove_sub_key = tuple(remove_sub_key.split("__"))
|
||||
stored_subs.blacklist(part_id, language, remove_sub_key, add=False)
|
||||
storage.save(stored_subs)
|
||||
Log.Info("Removed %s from blacklist", remove_sub_key)
|
||||
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ItemDetailsMenu, rating_key=kwargs["rating_key"], item_title=kwargs["item_title"],
|
||||
title=kwargs["title"], randomize=timestamp()),
|
||||
title=_(u"< Back to %s", kwargs["title"]),
|
||||
summary=current_data,
|
||||
thumb=default_thumb
|
||||
))
|
||||
|
||||
def sorter(pair):
|
||||
# thanks RestrictedModule parser for messing with lambda (x, y)
|
||||
return pair[1]["date_added"]
|
||||
|
||||
for sub_key, data in sorted(current_bl.iteritems(), key=sorter, reverse=True):
|
||||
provider_name, subtitle_id = sub_key
|
||||
title = _(u"%(provider_name)s, %(subtitle_id)s (added: %(date_added)s, %(mode)s), Language: %(language)s, "
|
||||
u"Score: %(score)i, Storage: %(storage_type)s",
|
||||
provider_name=_(provider_name),
|
||||
subtitle_id=subtitle_id,
|
||||
date_added=df(data["date_added"]),
|
||||
mode=_(current_sub.get_mode_verbose(data["mode"])),
|
||||
language=display_language(Language.fromietf(language)),
|
||||
score=data["score"],
|
||||
storage_type=data["storage_type"])
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ManageBlacklistMenu, remove_sub_key="__".join(sub_key), randomize=timestamp(), **kwargs),
|
||||
title=title,
|
||||
summary=_(u"Remove subtitle from blacklist")
|
||||
))
|
||||
|
||||
storage.destroy()
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/search/{rating_key}/{part_id}', force=bool)
|
||||
def ListAvailableSubsForItemMenu(rating_key=None, part_id=None, title=None, item_title=None, filename=None,
|
||||
item_type="episode", language=None, language_name=None, force=False, current_id=None,
|
||||
current_data=None,
|
||||
current_provider=None, current_score=None, randomize=None):
|
||||
assert rating_key, part_id
|
||||
|
||||
running = scheduler.is_task_running("AvailableSubsForItem")
|
||||
search_results = get_item_task_data("AvailableSubsForItem", rating_key, language)
|
||||
|
||||
current_data = unicode(current_data) if current_data else None
|
||||
|
||||
if (search_results is None or force) and not running:
|
||||
scheduler.dispatch_task("AvailableSubsForItem", rating_key=rating_key, item_type=item_type, part_id=part_id,
|
||||
language=language)
|
||||
running = True
|
||||
|
||||
oc = SubFolderObjectContainer(title2=unicode(title), replace_parent=True)
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ItemDetailsMenu, rating_key=rating_key, item_title=item_title, title=title, randomize=timestamp()),
|
||||
title=_(u"< Back to %s", title),
|
||||
summary=current_data,
|
||||
thumb=default_thumb
|
||||
))
|
||||
|
||||
metadata = get_plex_metadata(rating_key, part_id, item_type)
|
||||
plex_part = None
|
||||
if not config.low_impact_mode:
|
||||
scanned_parts = scan_videos([metadata], ignore_all=True)
|
||||
|
||||
if not scanned_parts:
|
||||
Log.Error("Couldn't list available subtitles for %s", rating_key)
|
||||
return oc
|
||||
|
||||
video, plex_part = scanned_parts.items()[0]
|
||||
|
||||
video_display_data = [video.format] if video.format else []
|
||||
if video.release_group:
|
||||
video_display_data.append(unicode(_(u"by %(release_group)s", release_group=video.release_group)))
|
||||
video_display_data = " ".join(video_display_data)
|
||||
else:
|
||||
video_display_data = metadata["filename"]
|
||||
|
||||
current_display = (_(u"Current: %(provider_name)s (%(score)s) ",
|
||||
provider_name=_(current_provider),
|
||||
score=current_score if current_provider else ""))
|
||||
if not running:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ListAvailableSubsForItemMenu, rating_key=rating_key, item_title=item_title, language=language,
|
||||
filename=filename, part_id=part_id, title=title, current_id=current_id, force=True,
|
||||
current_provider=current_provider, current_score=current_score,
|
||||
current_data=current_data, item_type=item_type, randomize=timestamp()),
|
||||
title=_(u"Search for %(language)s subs (%(video_data)s)",
|
||||
language=get_language(language).name,
|
||||
video_data=video_display_data),
|
||||
summary=_(u"%(current_info)sFilename: %(filename)s", current_info=current_display, filename=filename),
|
||||
thumb=default_thumb
|
||||
))
|
||||
|
||||
if search_results == "found_none":
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ListAvailableSubsForItemMenu, rating_key=rating_key, item_title=item_title,
|
||||
language=language, filename=filename, current_data=current_data, force=True,
|
||||
part_id=part_id, title=title, current_id=current_id, item_type=item_type,
|
||||
current_provider=current_provider, current_score=current_score,
|
||||
randomize=timestamp()),
|
||||
title=_(u"No subtitles found"),
|
||||
summary=_(u"%(current_info)sFilename: %(filename)s", current_info=current_display, filename=filename),
|
||||
thumb=default_thumb
|
||||
))
|
||||
else:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ListAvailableSubsForItemMenu, rating_key=rating_key, item_title=item_title,
|
||||
language=language, filename=filename, current_data=current_data,
|
||||
part_id=part_id, title=title, current_id=current_id, item_type=item_type,
|
||||
current_provider=current_provider, current_score=current_score,
|
||||
randomize=timestamp()),
|
||||
title=_(u"Searching for %(language)s subs (%(video_data)s), refresh here ...",
|
||||
language=display_language(get_language(language)),
|
||||
video_data=video_display_data),
|
||||
summary=_(u"%(current_info)sFilename: %(filename)s", current_info=current_display, filename=filename),
|
||||
thumb=default_thumb
|
||||
))
|
||||
|
||||
if not search_results or search_results == "found_none":
|
||||
return oc
|
||||
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
current_bl, subs = stored_subs.get_blacklist(part_id, language)
|
||||
|
||||
seen = []
|
||||
for subtitle in search_results:
|
||||
if subtitle.id in seen:
|
||||
continue
|
||||
|
||||
bl_addon = ""
|
||||
if (str(subtitle.provider_name), str(subtitle.id)) in current_bl:
|
||||
bl_addon = "Blacklisted "
|
||||
|
||||
wrong_fps_addon = ""
|
||||
wrong_series_addon = ""
|
||||
wrong_season_ep_addon = ""
|
||||
if subtitle.wrong_fps:
|
||||
if plex_part:
|
||||
wrong_fps_addon = _(" (wrong FPS, sub: %(subtitle_fps)s, media: %(media_fps)s)",
|
||||
subtitle_fps=subtitle.fps,
|
||||
media_fps=plex_part.fps)
|
||||
else:
|
||||
wrong_fps_addon = _(" (wrong FPS, sub: %(subtitle_fps)s, media: unknown, low impact mode)",
|
||||
subtitle_fps=subtitle.fps)
|
||||
|
||||
if subtitle.wrong_series:
|
||||
wrong_series_addon = _(" (possibly wrong series)")
|
||||
|
||||
if subtitle.wrong_season_ep:
|
||||
wrong_season_ep_addon = _(" (possibly wrong season/episode)")
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(TriggerDownloadSubtitle, rating_key=rating_key, randomize=timestamp(), item_title=item_title,
|
||||
subtitle_id=str(subtitle.id), language=language),
|
||||
title=_(u"%(blacklisted_state)s%(current_state)s: %(provider_name)s, score: %(score)s%(wrong_fps_state)s"
|
||||
u"%(wrong_series_state)s%(wrong_season_ep_state)s",
|
||||
blacklisted_state=bl_addon,
|
||||
current_state=_("Available") if current_id != subtitle.id else _("Current"),
|
||||
provider_name=_(subtitle.provider_name),
|
||||
score=subtitle.score,
|
||||
wrong_fps_state=wrong_fps_addon,
|
||||
wrong_series_state=wrong_series_addon,
|
||||
wrong_season_ep_state=wrong_season_ep_addon),
|
||||
summary=_(u"Release: %(release_info)s, Matches: %(matches)s",
|
||||
release_info=subtitle.release_info,
|
||||
matches=", ".join(subtitle.matches)),
|
||||
thumb=default_thumb
|
||||
))
|
||||
|
||||
seen.append(subtitle.id)
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/download_subtitle/{rating_key}')
|
||||
@debounce
|
||||
def TriggerDownloadSubtitle(rating_key=None, subtitle_id=None, item_title=None, language=None, randomize=None):
|
||||
from interface.main import fatality
|
||||
|
||||
set_refresh_menu_state(_("Downloading subtitle for %(title_or_id)s", title_or_id=item_title or rating_key))
|
||||
search_results = get_item_task_data("AvailableSubsForItem", rating_key, language)
|
||||
|
||||
download_subtitle = None
|
||||
for subtitle in search_results:
|
||||
if str(subtitle.id) == subtitle_id:
|
||||
download_subtitle = subtitle
|
||||
break
|
||||
if not download_subtitle:
|
||||
Log.Error(u"Something went horribly wrong")
|
||||
|
||||
else:
|
||||
scheduler.dispatch_task("DownloadSubtitleForItem", rating_key=rating_key, subtitle=download_subtitle)
|
||||
|
||||
scheduler.clear_task_data("AvailableSubsForItem")
|
||||
|
||||
return fatality(randomize=timestamp(), header=" ", replace_parent=True)
|
||||
|
||||
|
||||
@route(PREFIX + '/item/embedded/{rating_key}/{part_id}')
|
||||
def ListEmbeddedSubsForItemMenu(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
title = kwargs["title"]
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc = SubFolderObjectContainer(title2=title, replace_parent=True)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ItemDetailsMenu, rating_key=kwargs["rating_key"], item_title=kwargs["item_title"],
|
||||
base_title=kwargs["base_title"], title=kwargs["item_title"], randomize=timestamp()),
|
||||
title=_("< Back to %s", kwargs["title"]),
|
||||
thumb=default_thumb
|
||||
))
|
||||
|
||||
plex_item = get_item(rating_key)
|
||||
part = get_part(plex_item, part_id)
|
||||
|
||||
if part:
|
||||
for stream_data in get_embedded_subtitle_streams(part, skip_duplicate_unknown=False):
|
||||
language = stream_data["language"]
|
||||
is_unknown = stream_data["is_unknown"]
|
||||
stream = stream_data["stream"]
|
||||
is_forced = stream_data["is_forced"]
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(TriggerExtractEmbeddedSubForItemMenu, randomize=timestamp(),
|
||||
stream_index=str(stream.index), language=language, with_mods=True, **kwargs),
|
||||
title=_(u"Extract stream %(stream_index)s, %(language)s%(unknown_state)s%(forced_state)s"
|
||||
u"%(stream_title)s with default mods",
|
||||
stream_index=stream.index,
|
||||
language=display_language(language),
|
||||
unknown_state=_(" (unknown)") if is_unknown else "",
|
||||
forced_state=_(" (forced)") if is_forced else "",
|
||||
stream_title=" (\"%s\")" % stream.title if stream.title else ""),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(TriggerExtractEmbeddedSubForItemMenu, randomize=timestamp(),
|
||||
stream_index=str(stream.index), language=language, **kwargs),
|
||||
title=_(u"Extract stream %(stream_index)s, %(language)s%(unknown_state)s%(forced_state)s"
|
||||
u"%(stream_title)s",
|
||||
stream_index=stream.index,
|
||||
language=display_language(language),
|
||||
unknown_state=_(" (unknown)") if is_unknown else "",
|
||||
forced_state=_(" (forced)") if is_forced else "",
|
||||
stream_title=" (\"%s\")" % stream.title if stream.title else ""),
|
||||
))
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/extract_embedded/{rating_key}/{part_id}/{stream_index}')
|
||||
@debounce
|
||||
def TriggerExtractEmbeddedSubForItemMenu(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs.get("part_id")
|
||||
stream_index = kwargs.get("stream_index")
|
||||
|
||||
Thread.Create(extract_embedded_sub, extract_mode="m", **kwargs)
|
||||
header = _(u"Extracting of embedded subtitle %s of part %s:%s triggered",
|
||||
stream_index, rating_key, part_id)
|
||||
|
||||
kwargs.pop("randomize")
|
||||
kwargs.pop("item_type")
|
||||
kwargs.pop("stream_index")
|
||||
kwargs.pop("part_id")
|
||||
kwargs.pop("with_mods", False)
|
||||
kwargs.pop("language")
|
||||
kwargs["title"] = kwargs["item_title"]
|
||||
kwargs["header"] = header
|
||||
kwargs["message"] = header
|
||||
|
||||
return ItemDetailsMenu(randomize=timestamp(), **kwargs)
|
||||
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
# coding=utf-8
|
||||
|
||||
from subzero.constants import PREFIX, TITLE, ART
|
||||
from support.config import config
|
||||
from support.helpers import pad_title, timestamp, df, display_language
|
||||
from support.scheduler import scheduler
|
||||
from support.ignore import get_decision_list
|
||||
from support.items import get_item_thumb, get_on_deck_items, get_all_items, get_items_info, get_item, get_item_title
|
||||
from menu_helpers import main_icon, debounce, SubFolderObjectContainer, default_thumb, dig_tree, add_incl_excl_options, \
|
||||
ObjectContainer, route, handler
|
||||
from support.i18n import _
|
||||
from item_details import ItemDetailsMenu
|
||||
|
||||
|
||||
@handler(PREFIX, TITLE if not config.is_development else TITLE + " DEV", art=ART, thumb=main_icon)
|
||||
@route(PREFIX)
|
||||
def fatality(randomize=None, force_title=None, header=None, message=None, only_refresh=False, no_history=False,
|
||||
replace_parent=False):
|
||||
"""
|
||||
subzero main menu
|
||||
"""
|
||||
from interface.advanced import PinMenu, ClearPin, AdvancedMenu
|
||||
from interface.menu import RefreshMissing, IgnoreListMenu, HistoryMenu
|
||||
|
||||
title = config.full_version # force_title if force_title is not None else config.full_version
|
||||
oc = ObjectContainer(title1=title, title2=title, header=unicode(header) if header else title, message=message,
|
||||
no_history=no_history,
|
||||
replace_parent=replace_parent, no_cache=True)
|
||||
|
||||
# always re-check permissions
|
||||
config.refresh_permissions_status()
|
||||
|
||||
# always re-check enabled sections
|
||||
config.refresh_enabled_sections()
|
||||
|
||||
if config.lock_menu and not config.pin_correct:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(PinMenu, randomize=timestamp()),
|
||||
title=pad_title(_("Enter PIN")),
|
||||
summary=_("The owner has restricted the access to this menu. Please enter the correct pin"),
|
||||
))
|
||||
return oc
|
||||
|
||||
if not config.permissions_ok and config.missing_permissions:
|
||||
if not isinstance(config.missing_permissions, list):
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(fatality, randomize=timestamp()),
|
||||
title=pad_title(_("Insufficient permissions")),
|
||||
summary=config.missing_permissions,
|
||||
))
|
||||
else:
|
||||
for title, path in config.missing_permissions:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(fatality, randomize=timestamp()),
|
||||
title=pad_title(_("Insufficient permissions")),
|
||||
summary=_("Insufficient permissions on library %(title)s, folder: %(path)s",
|
||||
title=title,
|
||||
path=path),
|
||||
))
|
||||
return oc
|
||||
|
||||
if not config.enabled_sections:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(fatality, randomize=timestamp()),
|
||||
title=pad_title(_("I'm not enabled!")),
|
||||
summary=_("Please enable me for some of your libraries in your server settings; currently I do nothing"),
|
||||
))
|
||||
return oc
|
||||
|
||||
if not only_refresh:
|
||||
if Dict["current_refresh_state"]:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(fatality, force_title=" ", randomize=timestamp()),
|
||||
title=pad_title(_("Working ... refresh here")),
|
||||
summary=_("Current state: %s; Last state: %s",
|
||||
(Dict["current_refresh_state"] or _("Idle")) if "current_refresh_state" in Dict else _("Idle"),
|
||||
(Dict["last_refresh_state"] or _("None")) if "last_refresh_state" in Dict else _("None")
|
||||
)
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(OnDeckMenu),
|
||||
title=_("On-deck items"),
|
||||
summary=_("Shows the current on deck items and allows you to individually (force-) refresh their metadata/subtitles."),
|
||||
thumb=R("icon-ondeck.jpg")
|
||||
))
|
||||
if "last_played_items" in Dict and Dict["last_played_items"]:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RecentlyPlayedMenu),
|
||||
title=pad_title(_("Recently played items")),
|
||||
summary=_("Shows the %s recently played items and allows you to individually (force-) refresh their metadata/subtitles.", config.store_recently_played_amount),
|
||||
thumb=R("icon-played.jpg")
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RecentlyAddedMenu),
|
||||
title=_("Recently-added items"),
|
||||
summary=_("Shows the recently added items per section."),
|
||||
thumb=R("icon-added.jpg")
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RecentMissingSubtitlesMenu, randomize=timestamp()),
|
||||
title=_("Show recently added items with missing subtitles"),
|
||||
summary=_("Lists items with missing subtitles. Click on \"Find recent items with missing subs\" to update list"),
|
||||
thumb=R("icon-missing.jpg")
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SectionsMenu),
|
||||
title=_("Browse all items"),
|
||||
summary=_("Go through your whole library and manage your ignore list. You can also (force-) refresh the metadata/subtitles of individual items."),
|
||||
thumb=R("icon-browse.jpg")
|
||||
))
|
||||
|
||||
task_name = "SearchAllRecentlyAddedMissing"
|
||||
task = scheduler.task(task_name)
|
||||
|
||||
if task.ready_for_display:
|
||||
task_state = _("Running: %(items_done)s/%(items_searching)s (%(percentage)s%%)",
|
||||
items_done=task.items_done,
|
||||
items_searching=task.items_searching,
|
||||
percentage=task.percentage)
|
||||
else:
|
||||
lr = scheduler.last_run(task_name)
|
||||
nr = scheduler.next_run(task_name)
|
||||
task_state = _("Last run: %s; Next scheduled run: %s; Last runtime: %s",
|
||||
df(scheduler.last_run(task_name)) if lr else "never",
|
||||
df(scheduler.next_run(task_name)) if nr else "never",
|
||||
str(task.last_run_time).split(".")[0])
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RefreshMissing, randomize=timestamp()),
|
||||
title=_("Search for missing subtitles (in recently-added items, max-age: %s)", Prefs[
|
||||
"scheduler.item_is_recent_age"]),
|
||||
summary=_("Automatically run periodically by the scheduler, if configured. %s", task_state),
|
||||
thumb=R("icon-search.jpg")
|
||||
))
|
||||
|
||||
ref_list = get_decision_list()
|
||||
incl_excl_ref = _("include list") if ref_list.store == "include" else _("ignore list")
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(IgnoreListMenu),
|
||||
title=_("Display %(incl_excl_list_name)s (%(count)d)",
|
||||
incl_excl_list_name=incl_excl_ref, count=len(ref_list)),
|
||||
summary=_("Show the current %(incl_excl_list_name)s (mainly used for the automatic tasks)",
|
||||
incl_excl_list_name=incl_excl_ref),
|
||||
thumb=R("icon-ignore.jpg")
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(HistoryMenu),
|
||||
title=_("History"),
|
||||
summary=_("Show the last %i downloaded subtitles", int(Prefs["history_size"])),
|
||||
thumb=R("icon-history.jpg")
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(fatality, force_title=" ", randomize=timestamp()),
|
||||
title=pad_title(_("Refresh")),
|
||||
summary=_("Current state: %s; Last state: %s",
|
||||
(Dict["current_refresh_state"] or _("Idle")) if "current_refresh_state" in Dict else _("Idle"),
|
||||
(Dict["last_refresh_state"] or _("None")) if "last_refresh_state" in Dict else _("None")
|
||||
),
|
||||
thumb=R("icon-refresh.jpg")
|
||||
))
|
||||
|
||||
# add re-lock after pin unlock
|
||||
if config.pin:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ClearPin, randomize=timestamp()),
|
||||
title=pad_title(_("Re-lock menu(s)")),
|
||||
summary=_("Enabled the PIN again for menu(s)")
|
||||
))
|
||||
|
||||
if not only_refresh:
|
||||
if "provider_throttle" in Dict and Dict["provider_throttle"].keys():
|
||||
summary_data = []
|
||||
for provider, data in Dict["provider_throttle"].iteritems():
|
||||
reason, until, desc = data
|
||||
summary_data.append(unicode(_("%(throttled_provider)s until %(until_date)s (%(reason)s)",
|
||||
throttled_provider=provider,
|
||||
until_date=until.strftime("%y/%m/%d %H:%M"),
|
||||
reason=reason)))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(fatality, force_title=" ", randomize=timestamp()),
|
||||
title=pad_title(_("Throttled providers: %s", ", ".join(Dict["provider_throttle"].keys()))),
|
||||
summary=", ".join(summary_data),
|
||||
thumb=R("icon-throttled.jpg")
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(AdvancedMenu),
|
||||
title=pad_title(_("Advanced functions")),
|
||||
summary=_("Use at your own risk"),
|
||||
thumb=R("icon-advanced.jpg")
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/on_deck')
|
||||
def OnDeckMenu(message=None):
|
||||
"""
|
||||
displays the items on deck
|
||||
:param message:
|
||||
:return:
|
||||
"""
|
||||
return mergedItemsMenu(title=_("Items On Deck"), base_title=_("Items On Deck"), itemGetter=get_on_deck_items)
|
||||
|
||||
|
||||
@route(PREFIX + '/recently_played')
|
||||
def RecentlyPlayedMenu():
|
||||
base_title = _("Recently Played")
|
||||
oc = SubFolderObjectContainer(title2=base_title, replace_parent=True)
|
||||
|
||||
for item in [get_item(rating_key) for rating_key in Dict["last_played_items"]]:
|
||||
if not item:
|
||||
continue
|
||||
|
||||
if getattr(getattr(item, "__class__"), "__name__") not in ("Episode", "Movie"):
|
||||
continue
|
||||
|
||||
item_title = get_item_title(item)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
thumb=get_item_thumb(item) or default_thumb,
|
||||
title=item_title,
|
||||
key=Callback(ItemDetailsMenu, title=base_title + " > " + item.title, item_title=item.title,
|
||||
rating_key=item.rating_key)
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/recently_added')
|
||||
def RecentlyAddedMenu(message=None):
|
||||
"""
|
||||
displays the items recently added per section
|
||||
:param message:
|
||||
:return:
|
||||
"""
|
||||
return SectionsMenu(base_title=_("Recently added"), section_items_key="recently_added", ignore_options=False)
|
||||
|
||||
|
||||
@route(PREFIX + '/recent', force=bool)
|
||||
@debounce
|
||||
def RecentMissingSubtitlesMenu(force=False, randomize=None):
|
||||
title = _("Items with missing subtitles")
|
||||
oc = SubFolderObjectContainer(title2=title, no_cache=True, no_history=True)
|
||||
|
||||
running = scheduler.is_task_running("MissingSubtitles")
|
||||
task_data = scheduler.get_task_data("MissingSubtitles")
|
||||
missing_items = task_data["missing_subtitles"] if task_data else None
|
||||
|
||||
if ((missing_items is None) or force) and not running:
|
||||
scheduler.dispatch_task("MissingSubtitles")
|
||||
running = True
|
||||
|
||||
if not running:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RecentMissingSubtitlesMenu, force=True, randomize=timestamp()),
|
||||
title=_(u"Find recent items with missing subtitles"),
|
||||
thumb=default_thumb
|
||||
))
|
||||
else:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RecentMissingSubtitlesMenu, force=False, randomize=timestamp()),
|
||||
title=_(u"Updating, refresh here ..."),
|
||||
thumb=default_thumb
|
||||
))
|
||||
|
||||
if missing_items is not None:
|
||||
for added_at, item_id, item_title, item, missing_languages in missing_items:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ItemDetailsMenu, title=title + " > " + item_title, item_title=item_title,
|
||||
rating_key=item_id),
|
||||
title=item_title,
|
||||
summary=_("Missing: %s", ", ".join(display_language(l) for l in missing_languages)),
|
||||
thumb=get_item_thumb(item) or default_thumb
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
def mergedItemsMenu(title, itemGetter, itemGetterKwArgs=None, base_title=None, *args, **kwargs):
|
||||
"""
|
||||
displays an item list of dynamic kinds of items
|
||||
:param title:
|
||||
:param itemGetter:
|
||||
:param itemGetterKwArgs:
|
||||
:param base_title:
|
||||
:param args:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
oc = SubFolderObjectContainer(title2=title, no_cache=True, no_history=True)
|
||||
items = itemGetter(*args, **kwargs)
|
||||
|
||||
for kind, title, item_id, deeper, item in items:
|
||||
oc.add(DirectoryObject(
|
||||
title=title,
|
||||
key=Callback(ItemDetailsMenu, title=base_title + " > " + title, item_title=title, rating_key=item_id),
|
||||
thumb=get_item_thumb(item) or default_thumb
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
def determine_section_display(kind, item, pass_kwargs=None):
|
||||
"""
|
||||
returns the menu function for a section based on the size of it (amount of items)
|
||||
:param kind:
|
||||
:param item:
|
||||
:return:
|
||||
"""
|
||||
if pass_kwargs and pass_kwargs.get("section_items_key", "all") != "all":
|
||||
return SectionMenu
|
||||
if item.size > 80:
|
||||
return SectionFirstLetterMenu
|
||||
return SectionMenu
|
||||
|
||||
|
||||
@route(PREFIX + '/incl_excl/set/{kind}/{rating_key}/{todo}/sure={sure}', kind=str, rating_key=str, todo=str, sure=bool)
|
||||
def InclExclMenu(kind, rating_key, title=None, sure=False, todo="not_set"):
|
||||
"""
|
||||
displays the ignore options for a menu
|
||||
:param kind:
|
||||
:param rating_key:
|
||||
:param title:
|
||||
:param sure:
|
||||
:param todo:
|
||||
:return:
|
||||
"""
|
||||
ref_list = get_decision_list()
|
||||
include = ref_list.store == "include"
|
||||
list_str_ref = "include" if include else "ignore"
|
||||
in_list = rating_key in ref_list[kind]
|
||||
|
||||
if include:
|
||||
# shortcut
|
||||
sure = True
|
||||
todo = "add" if not in_list else "remove"
|
||||
|
||||
if not sure:
|
||||
t = u"Add %(kind)s %(title)s to the ignore list"
|
||||
if in_list:
|
||||
t = u"Remove %(kind)s %(title)s from the ignore list"
|
||||
oc = SubFolderObjectContainer(no_history=True, replace_parent=True,
|
||||
title1=_(t,
|
||||
kind=ref_list.verbose(kind),
|
||||
title=title
|
||||
),
|
||||
title2=_("Are you sure?"))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(InclExclMenu, kind=kind, rating_key=rating_key, title=title, sure=True,
|
||||
todo="add" if not in_list else "remove"),
|
||||
title=pad_title(_("Are you sure?")),
|
||||
))
|
||||
return oc
|
||||
|
||||
rel = ref_list[kind]
|
||||
dont_change = False
|
||||
state = None
|
||||
if todo == "remove":
|
||||
if not in_list:
|
||||
dont_change = True
|
||||
else:
|
||||
rel.remove(rating_key)
|
||||
Log.Info("Removed %s (%s) from the %s list", title, rating_key, list_str_ref)
|
||||
ref_list.remove_title(kind, rating_key)
|
||||
ref_list.save()
|
||||
elif todo == "add":
|
||||
if in_list:
|
||||
dont_change = True
|
||||
else:
|
||||
rel.append(rating_key)
|
||||
Log.Info("Added %s (%s) to the %s list", title, rating_key, list_str_ref)
|
||||
ref_list.add_title(kind, rating_key, title)
|
||||
ref_list.save()
|
||||
else:
|
||||
dont_change = True
|
||||
|
||||
if dont_change:
|
||||
return fatality(force_title=" ", header=_("Didn't change the %(incl_excl_list_name)s",
|
||||
incl_excl_list_name=_(list_str_ref)), no_history=True)
|
||||
|
||||
if include:
|
||||
t = "%(title)s added to the include list"
|
||||
if todo == "remove":
|
||||
t = "%(title)s removed from the include list"
|
||||
else:
|
||||
t = "%(title)s added to the ignore list"
|
||||
if todo == "remove":
|
||||
t = "%(title)s removed from the ignore list"
|
||||
return fatality(force_title=" ", header=_(t, title=title,), no_history=True)
|
||||
|
||||
|
||||
@route(PREFIX + '/sections')
|
||||
def SectionsMenu(base_title=_("Sections"), section_items_key="all", ignore_options=True):
|
||||
"""
|
||||
displays the menu for all sections
|
||||
:return:
|
||||
"""
|
||||
items = get_all_items("sections")
|
||||
|
||||
return dig_tree(SubFolderObjectContainer(title2=_("Sections"), no_cache=True, no_history=True), items, None,
|
||||
menu_determination_callback=determine_section_display, pass_kwargs={"base_title": base_title,
|
||||
"section_items_key": section_items_key,
|
||||
"ignore_options": ignore_options},
|
||||
fill_args={"title": "section_title"})
|
||||
|
||||
|
||||
@route(PREFIX + '/section', ignore_options=bool)
|
||||
def SectionMenu(rating_key, title=None, base_title=None, section_title=None, ignore_options=True,
|
||||
section_items_key="all"):
|
||||
"""
|
||||
displays the contents of a section
|
||||
:param section_items_key:
|
||||
:param rating_key:
|
||||
:param title:
|
||||
:param base_title:
|
||||
:param section_title:
|
||||
:param ignore_options:
|
||||
:return:
|
||||
"""
|
||||
from menu import MetadataMenu
|
||||
items = get_all_items(key=section_items_key, value=rating_key, base="library/sections")
|
||||
|
||||
kind, deeper = get_items_info(items)
|
||||
title = unicode(title)
|
||||
|
||||
section_title = title
|
||||
title = base_title + " > " + title
|
||||
oc = SubFolderObjectContainer(title2=title, no_cache=True, no_history=True)
|
||||
if ignore_options:
|
||||
add_incl_excl_options(oc, "sections", title=section_title, rating_key=rating_key, callback_menu=InclExclMenu)
|
||||
|
||||
return dig_tree(oc, items, MetadataMenu,
|
||||
pass_kwargs={"base_title": title, "display_items": deeper, "previous_item_type": "section",
|
||||
"previous_rating_key": rating_key})
|
||||
|
||||
|
||||
@route(PREFIX + '/section/firstLetter', deeper=bool)
|
||||
def SectionFirstLetterMenu(rating_key, title=None, base_title=None, section_title=None, ignore_options=True,
|
||||
section_items_key="all"):
|
||||
"""
|
||||
displays the contents of a section indexed by its first char (A-Z, 0-9...)
|
||||
:param ignore_options: ignored
|
||||
:param section_items_key: ignored
|
||||
:param rating_key:
|
||||
:param title:
|
||||
:param base_title:
|
||||
:param section_title:
|
||||
:return:
|
||||
"""
|
||||
from menu import FirstLetterMetadataMenu
|
||||
items = get_all_items(key="first_character", value=rating_key, base="library/sections")
|
||||
|
||||
kind, deeper = get_items_info(items)
|
||||
|
||||
title = unicode(title)
|
||||
oc = SubFolderObjectContainer(title2=section_title, no_cache=True, no_history=True)
|
||||
title = base_title + " > " + title
|
||||
add_incl_excl_options(oc, "sections", title=section_title, rating_key=rating_key, callback_menu=InclExclMenu)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SectionMenu, title=_("All"), base_title=title, rating_key=rating_key, ignore_options=False),
|
||||
title="All"
|
||||
)
|
||||
)
|
||||
return dig_tree(oc, items, FirstLetterMetadataMenu, force_rating_key=rating_key, fill_args={"key": "key"},
|
||||
pass_kwargs={"base_title": title, "display_items": deeper, "previous_rating_key": rating_key})
|
||||
@@ -0,0 +1,377 @@
|
||||
# coding=utf-8
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import traceback
|
||||
|
||||
import logger
|
||||
import copy
|
||||
|
||||
from requests import HTTPError
|
||||
from item_details import ItemDetailsMenu
|
||||
from refresh_item import RefreshItem
|
||||
from menu_helpers import add_incl_excl_options, dig_tree, set_refresh_menu_state, \
|
||||
default_thumb, debounce, ObjectContainer, SubFolderObjectContainer, route
|
||||
from main import fatality, InclExclMenu
|
||||
from advanced import DispatchRestart
|
||||
from subzero.constants import ART, PREFIX, DEPENDENCY_MODULE_NAMES
|
||||
from support.extract import season_extract_embedded
|
||||
from support.scheduler import scheduler
|
||||
from support.config import config
|
||||
from support.helpers import timestamp, df, display_language
|
||||
from support.ignore import get_decision_list
|
||||
from support.items import get_all_items, get_items_info, get_item_kind_from_rating_key, get_item, get_item_title
|
||||
from support.i18n import _
|
||||
|
||||
# init GUI
|
||||
ObjectContainer.art = R(ART)
|
||||
ObjectContainer.no_cache = True
|
||||
|
||||
# default thumb for DirectoryObjects
|
||||
DirectoryObject.thumb = default_thumb
|
||||
Plugin.AddViewGroup("full_details", viewMode="InfoList", mediaType="items", type="list", summary=2)
|
||||
|
||||
|
||||
@route(PREFIX + '/section/firstLetter/key', deeper=bool)
|
||||
def FirstLetterMetadataMenu(rating_key, key, title=None, base_title=None, display_items=False, previous_item_type=None,
|
||||
previous_rating_key=None):
|
||||
"""
|
||||
displays the contents of a section filtered by the first letter
|
||||
:param rating_key: actually is the section's key
|
||||
:param key: the firstLetter wanted
|
||||
:param title: the first letter, or #
|
||||
:param deeper:
|
||||
:return:
|
||||
"""
|
||||
title = base_title + " > " + unicode(title)
|
||||
oc = SubFolderObjectContainer(title2=title, no_cache=True, no_history=True)
|
||||
|
||||
items = get_all_items(key="first_character", value=[rating_key, key], base="library/sections", flat=False)
|
||||
kind, deeper = get_items_info(items)
|
||||
dig_tree(oc, items, MetadataMenu,
|
||||
pass_kwargs={"base_title": title, "display_items": deeper, "previous_item_type": kind,
|
||||
"previous_rating_key": rating_key})
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/section/contents', display_items=bool)
|
||||
def MetadataMenu(rating_key, title=None, base_title=None, display_items=False, previous_item_type=None,
|
||||
previous_rating_key=None, message=None, header=None, randomize=None):
|
||||
"""
|
||||
displays the contents of a section based on whether it has a deeper tree or not (movies->movie (item) list; series->series list)
|
||||
:param rating_key:
|
||||
:param title:
|
||||
:param base_title:
|
||||
:param display_items:
|
||||
:param previous_item_type:
|
||||
:param previous_rating_key:
|
||||
:return:
|
||||
"""
|
||||
title = unicode(title)
|
||||
item_title = title
|
||||
title = base_title + " > " + title
|
||||
oc = SubFolderObjectContainer(title2=title, no_cache=True, no_history=True, header=header, message=message,
|
||||
view_group="full_details")
|
||||
|
||||
current_kind = get_item_kind_from_rating_key(rating_key)
|
||||
|
||||
if display_items:
|
||||
timeout = 30
|
||||
show = None
|
||||
|
||||
# add back to series for season
|
||||
if current_kind == "season":
|
||||
timeout = 720
|
||||
|
||||
show = get_item(previous_rating_key)
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(MetadataMenu, rating_key=show.rating_key, title=show.title, base_title=show.section.title,
|
||||
previous_item_type="section", display_items=True, randomize=timestamp()),
|
||||
title=_(u"< Back to %s", show.title),
|
||||
thumb=show.thumb or default_thumb
|
||||
))
|
||||
elif current_kind == "series":
|
||||
# it shouldn't take more than 6 minutes to scan all of a series' files and determine the force refresh
|
||||
timeout = 3600
|
||||
|
||||
items = get_all_items(key="children", value=rating_key, base="library/metadata")
|
||||
kind, deeper = get_items_info(items)
|
||||
dig_tree(oc, items, MetadataMenu,
|
||||
pass_kwargs={"base_title": title, "display_items": deeper, "previous_item_type": kind,
|
||||
"previous_rating_key": rating_key})
|
||||
|
||||
# we don't know exactly where we are here, only add ignore option to series
|
||||
if current_kind in ("series", "season"):
|
||||
item = get_item(rating_key)
|
||||
sub_title = get_item_title(item)
|
||||
add_incl_excl_options(oc, current_kind, title=sub_title, rating_key=rating_key, callback_menu=InclExclMenu)
|
||||
|
||||
# mass-extract embedded
|
||||
if current_kind == "season" and config.plex_transcoder:
|
||||
for lang in config.lang_list:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SeasonExtractEmbedded, rating_key=rating_key, language=lang,
|
||||
base_title=show.section.title, display_items=display_items, item_title=item_title,
|
||||
title=title,
|
||||
previous_item_type=previous_item_type, with_mods=True,
|
||||
previous_rating_key=previous_rating_key, randomize=timestamp()),
|
||||
title=_(u"Extract missing %(language)s embedded subtitles", language=display_language(lang)),
|
||||
summary=_("Extracts the not yet extracted embedded subtitles of all episodes for the current "
|
||||
"season with all configured default modifications")
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SeasonExtractEmbedded, rating_key=rating_key, language=lang,
|
||||
base_title=show.section.title, display_items=display_items, item_title=item_title,
|
||||
title=title, force=True,
|
||||
previous_item_type=previous_item_type, with_mods=True,
|
||||
previous_rating_key=previous_rating_key, randomize=timestamp()),
|
||||
title=_(u"Extract and activate %(language)s embedded subtitles", language=display_language(lang)),
|
||||
summary=_("Extracts embedded subtitles of all episodes for the current season "
|
||||
"with all configured default modifications")
|
||||
))
|
||||
|
||||
# add refresh
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RefreshItem, rating_key=rating_key, item_title=title, refresh_kind=current_kind,
|
||||
previous_rating_key=previous_rating_key, timeout=timeout * 1000, randomize=timestamp()),
|
||||
title=_(u"Refresh: %s", item_title),
|
||||
summary=_("Refreshes %(the_movie_series_season_episode)s, possibly searching for missing and picking up "
|
||||
"new subtitles on disk", the_movie_series_season_episode=_(u"the %s" % current_kind))
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RefreshItem, rating_key=rating_key, item_title=title, force=True,
|
||||
refresh_kind=current_kind, previous_rating_key=previous_rating_key, timeout=timeout * 1000,
|
||||
randomize=timestamp()),
|
||||
title=_(u"Auto-Find subtitles: %s", item_title),
|
||||
summary=_("Issues a forced refresh, ignoring known subtitles and searching for new ones")
|
||||
))
|
||||
else:
|
||||
return ItemDetailsMenu(rating_key=rating_key, title=title, item_title=item_title)
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/season/extract_embedded/{rating_key}/{language}')
|
||||
def SeasonExtractEmbedded(**kwargs):
|
||||
rating_key = kwargs.get("rating_key")
|
||||
requested_language = kwargs.pop("language")
|
||||
with_mods = kwargs.pop("with_mods")
|
||||
item_title = kwargs.pop("item_title")
|
||||
title = kwargs.pop("title")
|
||||
force = kwargs.pop("force", False)
|
||||
|
||||
Thread.Create(season_extract_embedded, **{"rating_key": rating_key, "requested_language": requested_language,
|
||||
"with_mods": with_mods, "force": force})
|
||||
|
||||
kwargs["header"] = _("Success")
|
||||
kwargs["message"] = _(u"Extracting of embedded subtitles for %s triggered", title)
|
||||
|
||||
kwargs.pop("randomize")
|
||||
return MetadataMenu(randomize=timestamp(), title=item_title, **kwargs)
|
||||
|
||||
|
||||
@route(PREFIX + '/ignore_list')
|
||||
def IgnoreListMenu():
|
||||
ref_list = get_decision_list()
|
||||
include = ref_list.store == "include"
|
||||
list_title = _("Include list" if include else "Ignore list")
|
||||
oc = SubFolderObjectContainer(title2=list_title, replace_parent=True)
|
||||
for key in ref_list.key_order:
|
||||
values = ref_list[key]
|
||||
for value in values:
|
||||
add_incl_excl_options(oc, key, title=ref_list.get_title(key, value), rating_key=value,
|
||||
callback_menu=InclExclMenu)
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/history')
|
||||
def HistoryMenu():
|
||||
from support.history import get_history
|
||||
history = get_history()
|
||||
oc = SubFolderObjectContainer(title2=_("History"), replace_parent=True)
|
||||
|
||||
for item in history.items[:100]:
|
||||
possible_language = item.language
|
||||
language_display = item.lang_name if not possible_language else display_language(possible_language)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ItemDetailsMenu, title=item.title, item_title=item.item_title,
|
||||
rating_key=item.rating_key),
|
||||
title=u"%s (%s)" % (item.item_title, _(item.mode_verbose)),
|
||||
summary=_(u"%s in %s (%s, score: %s), %s", language_display, item.section_title,
|
||||
_(item.provider_name), item.score, df(item.time)),
|
||||
thumb=item.thumb or default_thumb
|
||||
))
|
||||
|
||||
history.destroy()
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/missing/refresh')
|
||||
@debounce
|
||||
def RefreshMissing(randomize=None):
|
||||
scheduler.dispatch_task("SearchAllRecentlyAddedMissing")
|
||||
header = "Refresh of recently added items with missing subtitles triggered"
|
||||
return fatality(header=header, replace_parent=True)
|
||||
|
||||
|
||||
def replace_item(obj, key, replace_value):
|
||||
for k, v in obj.items():
|
||||
if isinstance(v, dict):
|
||||
obj[k] = replace_item(v, key, replace_value)
|
||||
if key in obj:
|
||||
obj[key] = replace_value
|
||||
return obj
|
||||
|
||||
|
||||
def check_connections():
|
||||
# debug drone
|
||||
Log.Debug("Checking connections ...")
|
||||
log_buffer = []
|
||||
try:
|
||||
from subliminal_patch.refiners.drone import SonarrClient, RadarrClient
|
||||
log_buffer.append(["----- Connections -----"])
|
||||
for key, cls in [("sonarr", SonarrClient), ("radarr", RadarrClient)]:
|
||||
if key in config.refiner_settings:
|
||||
cname = key.capitalize()
|
||||
try:
|
||||
status = cls(**config.refiner_settings[key]).status(timeout=5)
|
||||
except HTTPError, e:
|
||||
if e.response.status_code == 401:
|
||||
log_buffer.append(("%s: NOT WORKING - BAD API KEY", cname))
|
||||
else:
|
||||
log_buffer.append(("%s: NOT WORKING - %s", cname, traceback.format_exc()))
|
||||
except:
|
||||
log_buffer.append(("%s: NOT WORKING - %s", cname, traceback.format_exc()))
|
||||
else:
|
||||
if status and status["version"]:
|
||||
log_buffer.append(("%s: OK - %s", cname, status["version"]))
|
||||
else:
|
||||
log_buffer.append(("%s: NOT WORKING - %s", cname))
|
||||
except:
|
||||
log_buffer.append(("Something went really wrong when evaluating Sonarr/Radarr: %s", traceback.format_exc()))
|
||||
finally:
|
||||
Core.log.setLevel(logging.DEBUG)
|
||||
for entry in log_buffer:
|
||||
Log.Debug(*entry)
|
||||
|
||||
Core.log.setLevel(logging.getLevelName(Prefs["log_level"]))
|
||||
|
||||
|
||||
@route(PREFIX + '/ValidatePrefs', enforce_route=True)
|
||||
def ValidatePrefs():
|
||||
Core.log.setLevel(logging.DEBUG)
|
||||
|
||||
if Prefs["log_console"]:
|
||||
Core.log.addHandler(logger.console_handler)
|
||||
Log.Debug("Logging to console from now on")
|
||||
else:
|
||||
Core.log.removeHandler(logger.console_handler)
|
||||
Log.Debug("Stop logging to console")
|
||||
|
||||
# cache the channel state
|
||||
update_dict = False
|
||||
restart = False
|
||||
|
||||
# reset pin
|
||||
Dict["pin_correct_time"] = None
|
||||
|
||||
config.initialize()
|
||||
if "channel_enabled" not in Dict:
|
||||
update_dict = True
|
||||
|
||||
elif Dict["channel_enabled"] != config.enable_channel:
|
||||
Log.Debug("Interface features %s, restarting plugin", "enabled" if config.enable_channel else "disabled")
|
||||
update_dict = True
|
||||
restart = True
|
||||
|
||||
if "plugin_pin_mode2" not in Dict:
|
||||
update_dict = True
|
||||
|
||||
elif Dict["plugin_pin_mode2"] != Prefs["plugin_pin_mode2"]:
|
||||
update_dict = True
|
||||
restart = True
|
||||
|
||||
if update_dict:
|
||||
Dict["channel_enabled"] = config.enable_channel
|
||||
Dict["plugin_pin_mode2"] = Prefs["plugin_pin_mode2"]
|
||||
Dict.Save()
|
||||
|
||||
if restart:
|
||||
scheduler.stop()
|
||||
DispatchRestart()
|
||||
return
|
||||
|
||||
scheduler.setup_tasks()
|
||||
scheduler.clear_task_data("MissingSubtitles")
|
||||
set_refresh_menu_state(None)
|
||||
|
||||
Log.Debug("Validate Prefs called.")
|
||||
|
||||
# SZ config debug
|
||||
Log.Debug("--- SZ Config-Debug ---")
|
||||
for attr in [
|
||||
"version", "app_support_path", "data_path", "data_items_path", "enable_agent",
|
||||
"enable_channel", "permissions_ok", "missing_permissions", "fs_encoding",
|
||||
"subtitle_destination_folder", "include", "include_exclude_paths", "include_exclude_sz_files",
|
||||
"new_style_cache", "dbm_supported", "lang_list", "providers", "normal_subs", "forced_only", "forced_also",
|
||||
"plex_transcoder", "refiner_settings", "unrar", "adv_cfg_path", "use_custom_dns",
|
||||
"has_anticaptcha", "anticaptcha_cls", "mediainfo_bin"]:
|
||||
|
||||
value = getattr(config, attr)
|
||||
if isinstance(value, dict):
|
||||
d = replace_item(copy.deepcopy(value), "api_key", "xxxxxxxxxxxxxxxxxxxxxxxxx")
|
||||
Log.Debug("config.%s: %s", attr, d)
|
||||
continue
|
||||
|
||||
if attr in ("api_key",):
|
||||
value = "xxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
Log.Debug("config.%s: %s", attr, value)
|
||||
|
||||
for attr in ["plugin_log_path", "server_log_path"]:
|
||||
value = getattr(config, attr)
|
||||
|
||||
if value:
|
||||
access = os.access(value, os.R_OK)
|
||||
if Core.runtime.os == "Windows":
|
||||
try:
|
||||
f = open(value, "r")
|
||||
f.read(1)
|
||||
f.close()
|
||||
except:
|
||||
access = False
|
||||
|
||||
Log.Debug("config.%s: %s (accessible: %s)", attr, value, access)
|
||||
|
||||
for attr in [
|
||||
"subtitles.save.filesystem", ]:
|
||||
Log.Debug("Pref.%s: %s", attr, Prefs[attr])
|
||||
|
||||
if "sonarr" in config.refiner_settings or "radarr" in config.refiner_settings:
|
||||
Thread.Create(check_connections)
|
||||
|
||||
# fixme: check existance of and os access of logs
|
||||
Log.Debug("----- Environment -----")
|
||||
Log.Debug("Platform: %s", Core.runtime.platform)
|
||||
Log.Debug("OS: %s", Core.runtime.os)
|
||||
Log.Debug("Python: %s", platform.python_version())
|
||||
for key, value in os.environ.iteritems():
|
||||
if key.startswith("PLEX") or key.startswith("SZ_"):
|
||||
if "TOKEN" in key:
|
||||
outval = "xxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
else:
|
||||
outval = value
|
||||
Log.Debug("%s: %s", key, outval)
|
||||
Log.Debug("Locale: %s", locale.getdefaultlocale())
|
||||
Log.Debug("-----------------------")
|
||||
|
||||
Log.Debug("Setting log-level to %s", Prefs["log_level"])
|
||||
logger.register_logging_handler(DEPENDENCY_MODULE_NAMES, level=Prefs["log_level"])
|
||||
Core.log.setLevel(logging.getLevelName(Prefs["log_level"]))
|
||||
os.environ['U1pfT01EQl9LRVk'] = '789CF30DAC2C8B0AF433F5C9AD34290A712DF30D7135F12D0FB3E502006FDE081E'
|
||||
|
||||
return
|
||||
@@ -0,0 +1,198 @@
|
||||
# coding=utf-8
|
||||
import types
|
||||
import datetime
|
||||
|
||||
from func import enable_channel_wrapper, route_wrapper
|
||||
from support.i18n import is_localized_string, _
|
||||
from support.items import get_item_thumb
|
||||
from support.helpers import get_video_display_title, pad_title
|
||||
from support.ignore import get_decision_list
|
||||
from support.lib import get_intent
|
||||
from support.config import config
|
||||
from subzero.constants import ICON_SUB, ICON
|
||||
from support.scheduler import scheduler
|
||||
|
||||
default_thumb = R(ICON_SUB)
|
||||
main_icon = ICON if not config.is_development else "icon-dev.jpg"
|
||||
|
||||
# noinspection PyUnboundLocalVariable
|
||||
route = route_wrapper
|
||||
# noinspection PyUnboundLocalVariable
|
||||
handler = enable_channel_wrapper(handler)
|
||||
|
||||
|
||||
def add_incl_excl_options(oc, kind, callback_menu=None, title=None, rating_key=None, add_kind=True):
|
||||
"""
|
||||
|
||||
:param oc: oc to add our options to
|
||||
:param kind: movie, show, episode ... - gets translated to the ignore key (sections, series, items)
|
||||
:param callback_menu: menu to inject
|
||||
:param title:
|
||||
:param rating_key:
|
||||
:return:
|
||||
"""
|
||||
# try to translate kind to the ignore key
|
||||
use_kind = kind
|
||||
ref_list = get_decision_list()
|
||||
if kind not in ref_list:
|
||||
use_kind = ref_list.translate_key(kind)
|
||||
if not use_kind or use_kind not in ref_list:
|
||||
return
|
||||
|
||||
in_list = rating_key in ref_list[use_kind]
|
||||
include = ref_list.store == "include"
|
||||
|
||||
if include:
|
||||
t = u"Enable Sub-Zero for %(kind)s \"%(title)s\""
|
||||
if in_list:
|
||||
t = u"Disable Sub-Zero for %(kind)s \"%(title)s\""
|
||||
else:
|
||||
t = u"Ignore %(kind)s \"%(title)s\""
|
||||
if in_list:
|
||||
t = u"Un-ignore %(kind)s \"%(title)s\""
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(callback_menu, kind=use_kind, sure=False, todo="not_set", rating_key=str(rating_key), title=title),
|
||||
title=_(t,
|
||||
kind=ref_list.verbose(kind) if add_kind else "",
|
||||
title=unicode(title))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def dig_tree(oc, items, menu_callback, menu_determination_callback=None, force_rating_key=None, fill_args=None,
|
||||
pass_kwargs=None, thumb=default_thumb):
|
||||
for kind, title, key, dig_deeper, item in items:
|
||||
thumb = get_item_thumb(item) or thumb
|
||||
|
||||
add_kwargs = {}
|
||||
if fill_args:
|
||||
add_kwargs = dict((name, getattr(item, k)) for k, name in fill_args.iteritems() if item and hasattr(item, k))
|
||||
if pass_kwargs:
|
||||
add_kwargs.update(pass_kwargs)
|
||||
|
||||
# force details view for show/season
|
||||
summary = " " if kind in ("show", "season") else None
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(menu_callback or menu_determination_callback(kind, item, pass_kwargs=pass_kwargs), title=title,
|
||||
rating_key=force_rating_key or key, **add_kwargs),
|
||||
title=pad_title(title) if kind in ("show", "season") else title, thumb=thumb, summary=summary
|
||||
))
|
||||
return oc
|
||||
|
||||
|
||||
def set_refresh_menu_state(state_or_media, media_type="movies"):
|
||||
"""
|
||||
|
||||
:param state_or_media: string, None, or Media argument from Agent.update()
|
||||
:param media_type: movies or series
|
||||
:return:
|
||||
"""
|
||||
if not state_or_media:
|
||||
# store it in last state and remove the current
|
||||
Dict["last_refresh_state"] = Dict["current_refresh_state"]
|
||||
Dict["current_refresh_state"] = None
|
||||
Dict.Save()
|
||||
return
|
||||
|
||||
if isinstance(state_or_media, types.StringTypes) or is_localized_string(state_or_media):
|
||||
Dict["current_refresh_state"] = unicode(state_or_media)
|
||||
Dict.Save()
|
||||
return
|
||||
|
||||
media = state_or_media
|
||||
media_id = media.id
|
||||
title = None
|
||||
if media_type == "series":
|
||||
for season in media.seasons:
|
||||
for episode in media.seasons[season].episodes:
|
||||
ep = media.seasons[season].episodes[episode]
|
||||
media_id = ep.id
|
||||
title = get_video_display_title(_("show"), ep.title, parent_title=media.title, season=int(season), episode=int(episode))
|
||||
else:
|
||||
title = get_video_display_title(_("movie"), media.title)
|
||||
|
||||
intent = get_intent()
|
||||
force_refresh = intent.get("force", media_id)
|
||||
|
||||
t = u"Refreshing %(title)s"
|
||||
if force_refresh:
|
||||
t = u"Force-refreshing %(title)s"
|
||||
|
||||
Dict["current_refresh_state"] = unicode(_(t,
|
||||
title=unicode(title)))
|
||||
Dict.Save()
|
||||
|
||||
|
||||
def get_item_task_data(task_name, rating_key, language):
|
||||
task_data = scheduler.get_task_data(task_name)
|
||||
search_results = task_data.get(rating_key, {}) if task_data else {}
|
||||
return search_results.get(language)
|
||||
|
||||
|
||||
def debounce(func):
|
||||
"""
|
||||
prevent func from being called twice with the same arguments
|
||||
:param func:
|
||||
:return:
|
||||
"""
|
||||
|
||||
func.debounce = True
|
||||
|
||||
return func
|
||||
|
||||
|
||||
class SZObjectContainer(ObjectContainer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
skip_pin_lock = kwargs.pop("skip_pin_lock", False)
|
||||
|
||||
super(SZObjectContainer, self).__init__(*args, **kwargs)
|
||||
|
||||
if (config.lock_menu or config.lock_advanced_menu) and not config.pin_correct and not skip_pin_lock:
|
||||
config.locked = True
|
||||
|
||||
def add(self, *args, **kwargs):
|
||||
# disable self.add if we're in lockdown
|
||||
container = args[0]
|
||||
current_menu_target = container.key.split("?")[0]
|
||||
is_pin_menu = current_menu_target.endswith("/pin")
|
||||
|
||||
if config.locked and config.lock_menu and not is_pin_menu:
|
||||
return
|
||||
return super(SZObjectContainer, self).add(*args, **kwargs)
|
||||
|
||||
|
||||
OriginalObjectContainer = ObjectContainer
|
||||
ObjectContainer = SZObjectContainer
|
||||
|
||||
|
||||
class SubFolderObjectContainer(ObjectContainer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(SubFolderObjectContainer, self).__init__(*args, **kwargs)
|
||||
from interface.menu import fatality
|
||||
from support.helpers import pad_title, timestamp
|
||||
self.add(DirectoryObject(
|
||||
key=Callback(fatality, force_title=" ", randomize=timestamp()),
|
||||
title=pad_title(_("<< Back to home")),
|
||||
summary=_("Current state: %s; Last state: %s",
|
||||
(Dict["current_refresh_state"] or _("Idle")) if "current_refresh_state" in Dict else _("Idle"),
|
||||
(Dict["last_refresh_state"] or _("None")) if "last_refresh_state" in Dict else _("None")
|
||||
)
|
||||
))
|
||||
|
||||
|
||||
ObjectClass = getattr(getattr(Redirect, "_object_class"), "__bases__")[0]
|
||||
|
||||
|
||||
class ZipObject(ObjectClass):
|
||||
def __init__(self, data):
|
||||
ObjectClass.__init__(self, "")
|
||||
self.zipdata = data
|
||||
self.SetHeader("Content-Type", "application/zip")
|
||||
|
||||
def Content(self):
|
||||
self.SetHeader("Content-Disposition",
|
||||
'attachment; filename="' + datetime.datetime.now().strftime("Logs_%y%m%d_%H-%M-%S.zip")
|
||||
+ '"')
|
||||
return self.zipdata
|
||||
@@ -0,0 +1,32 @@
|
||||
# coding=utf-8
|
||||
|
||||
from subzero.constants import PREFIX
|
||||
from menu_helpers import debounce, set_refresh_menu_state, route
|
||||
from support.items import refresh_item
|
||||
from support.helpers import timestamp
|
||||
from support.i18n import _
|
||||
|
||||
|
||||
@route(PREFIX + '/item/refresh/{rating_key}/force', force=True)
|
||||
@route(PREFIX + '/item/refresh/{rating_key}')
|
||||
@debounce
|
||||
def RefreshItem(rating_key=None, came_from="/recent", item_title=None, force=False, refresh_kind=None,
|
||||
previous_rating_key=None, timeout=8000, randomize=None, trigger=True):
|
||||
assert rating_key
|
||||
from interface.main import fatality
|
||||
header = " "
|
||||
if trigger:
|
||||
t = u"Triggering refresh for %(title)s"
|
||||
if force:
|
||||
u"Triggering forced refresh for %(title)s"
|
||||
set_refresh_menu_state(_(t,
|
||||
title=item_title))
|
||||
Thread.Create(refresh_item, rating_key=rating_key, force=force, refresh_kind=refresh_kind,
|
||||
parent_rating_key=previous_rating_key, timeout=int(timeout))
|
||||
|
||||
t = u"Refresh of item %(item_id)s triggered"
|
||||
if force:
|
||||
t = u"Forced refresh of item %(item_id)s triggered"
|
||||
header = _(t,
|
||||
item_id=rating_key)
|
||||
return fatality(randomize=timestamp(), header=header, replace_parent=True)
|
||||
@@ -0,0 +1,284 @@
|
||||
# coding=utf-8
|
||||
|
||||
import traceback
|
||||
import types
|
||||
|
||||
from subzero.language import Language
|
||||
|
||||
from menu_helpers import debounce, SubFolderObjectContainer, default_thumb, route
|
||||
from subzero.modification import registry as mod_registry, SubtitleModifications
|
||||
from subzero.constants import PREFIX
|
||||
from support.plex_media import get_plex_metadata
|
||||
from support.scanning import scan_videos
|
||||
from support.helpers import timestamp, pad_title
|
||||
from support.items import get_current_sub, set_mods_for_part
|
||||
from support.i18n import _
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_mods/{rating_key}/{part_id}', force=bool)
|
||||
def SubtitleModificationsMenu(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
language = kwargs["language"]
|
||||
lang_instance = Language.fromietf(language)
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
kwargs.pop("randomize")
|
||||
|
||||
current_mods = current_sub.mods or []
|
||||
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
|
||||
from interface.item_details import SubtitleOptionsMenu
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleOptionsMenu, randomize=timestamp(), **kwargs),
|
||||
title=_(u"< Back to subtitle options for: %s", kwargs["title"]),
|
||||
summary=unicode(kwargs["current_data"]),
|
||||
thumb=default_thumb
|
||||
))
|
||||
|
||||
for identifier, mod in mod_registry.mods.iteritems():
|
||||
if mod.advanced:
|
||||
continue
|
||||
|
||||
if mod.exclusive and identifier in current_mods:
|
||||
continue
|
||||
|
||||
if mod.languages and lang_instance not in mod.languages:
|
||||
continue
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=identifier, mode="add", randomize=timestamp(), **kwargs),
|
||||
title=pad_title(_(mod.description)), summary=_(mod.long_description) or ""
|
||||
))
|
||||
|
||||
fps_mod = SubtitleModifications.get_mod_class("change_FPS")
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleFPSModMenu, randomize=timestamp(), **kwargs),
|
||||
title=pad_title(_(fps_mod.description)), summary=_(fps_mod.long_description) or ""
|
||||
))
|
||||
|
||||
shift_mod = SubtitleModifications.get_mod_class("shift_offset")
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleShiftModUnitMenu, randomize=timestamp(), **kwargs),
|
||||
title=pad_title(_(shift_mod.description)), summary=_(shift_mod.long_description) or ""
|
||||
))
|
||||
|
||||
color_mod = SubtitleModifications.get_mod_class("color")
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleColorModMenu, randomize=timestamp(), **kwargs),
|
||||
title=pad_title(_(color_mod.description)), summary=_(color_mod.long_description) or ""
|
||||
))
|
||||
|
||||
if current_mods:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=None, mode="remove_last", randomize=timestamp(), **kwargs),
|
||||
title=pad_title(_("Remove last applied mod (%s)", current_mods[-1])),
|
||||
summary=_(u"Currently applied mods: %(mod_list)s", mod_list=", ".join(current_mods) if current_mods else _("none"))
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleListMods, randomize=timestamp(), **kwargs),
|
||||
title=pad_title(_("Manage applied mods")),
|
||||
summary=_(u"Currently applied mods: %(mod_list)s", mod_list=", ".join(current_mods))
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleReapplyMods, randomize=timestamp(), **kwargs),
|
||||
title=pad_title(_("Reapply applied mods")),
|
||||
summary=_(u"Currently applied mods: %(mod_list)s", mod_list=", ".join(current_mods) if current_mods else _("none"))
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=None, mode="clear", randomize=timestamp(), **kwargs),
|
||||
title=pad_title(_("Restore original version")),
|
||||
summary=_(u"Currently applied mods: %(mod_list)s", mod_list=", ".join(current_mods) if current_mods else _("none"))
|
||||
))
|
||||
|
||||
storage.destroy()
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_mod_fps/{rating_key}/{part_id}', force=bool)
|
||||
def SubtitleFPSModMenu(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
item_type = kwargs["item_type"]
|
||||
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleModificationsMenu, randomize=timestamp(), **kwargs),
|
||||
title=_("< Back to subtitle modification menu")
|
||||
))
|
||||
|
||||
metadata = get_plex_metadata(rating_key, part_id, item_type)
|
||||
scanned_parts = scan_videos([metadata], ignore_all=True, skip_hashing=True)
|
||||
video, plex_part = scanned_parts.items()[0]
|
||||
|
||||
target_fps = plex_part.fps
|
||||
|
||||
for fps in ["23.980", "23.976", "24.000", "25.000", "29.970", "30.000", "50.000", "59.940", "60.000"]:
|
||||
if float(fps) == float(target_fps):
|
||||
continue
|
||||
|
||||
if float(fps) > float(target_fps):
|
||||
indicator = _("subs constantly getting faster")
|
||||
else:
|
||||
indicator = _("subs constantly getting slower")
|
||||
|
||||
mod_ident = SubtitleModifications.get_mod_signature("change_FPS", **{"from": fps, "to": target_fps})
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=mod_ident, mode="add", randomize=timestamp(), **kwargs),
|
||||
title=_("%(from_fps)s fps -> %(to_fps)s fps (%(slower_or_faster_indicator)s)",
|
||||
from_fps=fps,
|
||||
to_fps=target_fps,
|
||||
slower_or_faster_indicator=indicator)
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
POSSIBLE_UNITS = (("ms", "milliseconds"), ("s", "seconds"), ("m", "minutes"), ("h", "hours"))
|
||||
POSSIBLE_UNITS_D = dict(POSSIBLE_UNITS)
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_mod_shift_unit/{rating_key}/{part_id}', force=bool)
|
||||
def SubtitleShiftModUnitMenu(**kwargs):
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleModificationsMenu, randomize=timestamp(), **kwargs),
|
||||
title=_("< Back to subtitle modifications")
|
||||
))
|
||||
|
||||
for unit, title in POSSIBLE_UNITS:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleShiftModMenu, unit=unit, randomize=timestamp(), **kwargs),
|
||||
title=_("Adjust by %(time_and_unit)s", time_and_unit=title)
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_mod_shift/{rating_key}/{part_id}/{unit}', force=bool)
|
||||
def SubtitleShiftModMenu(unit=None, **kwargs):
|
||||
if unit not in POSSIBLE_UNITS_D:
|
||||
raise NotImplementedError
|
||||
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleShiftModUnitMenu, randomize=timestamp(), **kwargs),
|
||||
title=_("< Back to unit selection")
|
||||
))
|
||||
|
||||
rng = []
|
||||
if unit == "h":
|
||||
rng = list(reversed(range(-10, 0))) + list(reversed(range(1, 11)))
|
||||
elif unit in ("m", "s"):
|
||||
rng = list(reversed(range(-15, 0))) + list(reversed(range(1, 16)))
|
||||
elif unit == "ms":
|
||||
rng = list(reversed(range(-900, 0, 100))) + list(reversed(range(100, 1000, 100)))
|
||||
|
||||
for i in rng:
|
||||
if i == 0:
|
||||
continue
|
||||
|
||||
mod_ident = SubtitleModifications.get_mod_signature("shift_offset", **{unit: i})
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=mod_ident, mode="add", randomize=timestamp(), **kwargs),
|
||||
title="%s %s" % (("%s" if i < 0 else "+%s") % i, unit)
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_mod_colors/{rating_key}/{part_id}', force=bool)
|
||||
def SubtitleColorModMenu(**kwargs):
|
||||
kwargs.pop("randomize")
|
||||
|
||||
color_mod = SubtitleModifications.get_mod_class("color")
|
||||
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleModificationsMenu, randomize=timestamp(), **kwargs),
|
||||
title=_("< Back to subtitle modification menu")
|
||||
))
|
||||
|
||||
for color, code in color_mod.colors.iteritems():
|
||||
mod_ident = SubtitleModifications.get_mod_signature("color", **{"name": color})
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=mod_ident, mode="add", randomize=timestamp(), **kwargs),
|
||||
title="%s (%s)" % (color, code)
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_set_mods/{rating_key}/{part_id}/{mods}/{mode}', force=bool)
|
||||
@debounce
|
||||
def SubtitleSetMods(mods=None, mode=None, **kwargs):
|
||||
if not isinstance(mods, types.ListType) and mods:
|
||||
mods = [mods]
|
||||
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
lang_a2 = kwargs["language"]
|
||||
item_type = kwargs["item_type"]
|
||||
|
||||
language = Language.fromietf(lang_a2)
|
||||
|
||||
set_mods_for_part(rating_key, part_id, language, item_type, mods, mode=mode)
|
||||
|
||||
kwargs.pop("randomize")
|
||||
return SubtitleModificationsMenu(randomize=timestamp(), **kwargs)
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_reapply_mods/{rating_key}/{part_id}', force=bool)
|
||||
@debounce
|
||||
def SubtitleReapplyMods(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
lang_a2 = kwargs["language"]
|
||||
item_type = kwargs["item_type"]
|
||||
|
||||
language = Language.fromietf(lang_a2)
|
||||
|
||||
set_mods_for_part(rating_key, part_id, language, item_type, [], mode="add")
|
||||
|
||||
kwargs.pop("randomize")
|
||||
return SubtitleModificationsMenu(randomize=timestamp(), **kwargs)
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_list_mods/{rating_key}/{part_id}', force=bool)
|
||||
@debounce
|
||||
def SubtitleListMods(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
language = kwargs["language"]
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleModificationsMenu, randomize=timestamp(), **kwargs),
|
||||
title=_("< Back to subtitle modifications")
|
||||
))
|
||||
|
||||
for identifier in current_sub.mods:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=identifier, mode="remove", randomize=timestamp(), **kwargs),
|
||||
title=_("Remove: %(mod_name)s", mod_name=identifier)
|
||||
))
|
||||
|
||||
storage.destroy()
|
||||
|
||||
return oc
|
||||
+20
-8
@@ -1,15 +1,22 @@
|
||||
import logging
|
||||
|
||||
def registerLoggingHander(dependencies):
|
||||
plexHandler = PlexLoggerHandler()
|
||||
for dependency in dependencies:
|
||||
Log.Debug("Registering LoggerHandler for dependency: %s" % dependency)
|
||||
|
||||
def register_logging_handler(dependencies, level="ERROR"):
|
||||
plex_handler = PlexLoggerHandler()
|
||||
for dependency in dependencies:
|
||||
Log.Debug("Registering LoggerHandler for dependency: %s" % dependency)
|
||||
log = logging.getLogger(dependency)
|
||||
log.setLevel('DEBUG')
|
||||
log.addHandler(plexHandler)
|
||||
# remove previous plex logging handlers
|
||||
# fixme: this is not the most elegant solution...
|
||||
for handler in log.handlers:
|
||||
if isinstance(handler, PlexLoggerHandler):
|
||||
log.removeHandler(handler)
|
||||
|
||||
log.setLevel(level)
|
||||
log.addHandler(plex_handler)
|
||||
|
||||
|
||||
class PlexLoggerHandler(logging.StreamHandler):
|
||||
|
||||
def __init__(self, level=0):
|
||||
super(PlexLoggerHandler, self).__init__(level)
|
||||
|
||||
@@ -30,4 +37,9 @@ class PlexLoggerHandler(logging.StreamHandler):
|
||||
elif record.levelno == logging.FATAL:
|
||||
Log.Exception(self.getFormattedString(record))
|
||||
else:
|
||||
Log.Error("UNKNOWN LEVEL: %s", record.getMessage())
|
||||
Log.Error("UNKNOWN LEVEL: %s", record.getMessage())
|
||||
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_formatter = Framework.core.LogFormatter('%(asctime)-15s - %(name)-32s (%(thread)x) : %(levelname)s (%(module)s:%(lineno)d) - %(message)s')
|
||||
console_handler.setFormatter(console_formatter)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
License for parts taken out of plexinc-agents/LocalMedia.bundle
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
If the software submitted to this repository accesses or calls any software provided by Plex (“Interfacing Software”), then as a condition for receiving services from Plex in response to such accesses or calls, you agree to grant and do hereby grant to Plex and its affiliates worldwide a worldwide, nonexclusive, and royalty-free right and license to use (including testing, hosting and linking to), copy, publicly perform, publicly display, reproduce in copies for distribution, and distribute the copies of any Interfacing Software made by you or with your assistance; provided, however, that you may notify Plex at legal@plex.tv if you do not wish for Plex to use, distribute, copy, publicly perform, publicly display, reproduce in copies for distribution, or distribute copies of an Interfacing Software that was created by you, and Plex will reasonable efforts to comply with such a request within a reasonable time.
|
||||
@@ -0,0 +1,76 @@
|
||||
import sys
|
||||
# thanks, https://github.com/trakt/Plex-Trakt-Scrobbler/blob/master/Trakttv.bundle/Contents/Code/core/__init__.py
|
||||
|
||||
import config
|
||||
|
||||
sys.modules["support.config"] = config
|
||||
|
||||
import helpers
|
||||
|
||||
sys.modules["support.helpers"] = helpers
|
||||
|
||||
import lib
|
||||
|
||||
sys.modules["support.lib"] = lib
|
||||
|
||||
import i18n
|
||||
|
||||
sys.modules["support.i18n"] = i18n
|
||||
|
||||
helpers._ = i18n._
|
||||
|
||||
import history
|
||||
|
||||
sys.modules["support.history"] = history
|
||||
|
||||
import plex_media
|
||||
sys.modules["support.plex_media"] = plex_media
|
||||
|
||||
import localmedia
|
||||
|
||||
sys.modules["support.localmedia"] = localmedia
|
||||
|
||||
import subtitlehelpers
|
||||
|
||||
sys.modules["support.subtitlehelpers"] = subtitlehelpers
|
||||
|
||||
import items
|
||||
|
||||
sys.modules["support.items"] = items
|
||||
|
||||
import scheduler
|
||||
|
||||
sys.modules["support.scheduler"] = scheduler
|
||||
|
||||
import storage
|
||||
|
||||
sys.modules["support.storage"] = storage
|
||||
|
||||
import scanning
|
||||
sys.modules["support.scanning"] = scanning
|
||||
|
||||
import missing_subtitles
|
||||
|
||||
sys.modules["support.missing_subtitles"] = missing_subtitles
|
||||
|
||||
import extract
|
||||
|
||||
sys.modules["support.extract"] = extract
|
||||
|
||||
import tasks
|
||||
|
||||
sys.modules["support.tasks"] = tasks
|
||||
|
||||
import ignore
|
||||
|
||||
sys.modules["support.ignore"] = ignore
|
||||
|
||||
import data
|
||||
|
||||
sys.modules["support.data"] = data
|
||||
|
||||
import activities
|
||||
sys.modules["support.activities"] = activities
|
||||
|
||||
import download
|
||||
sys.modules["support.download"] = download
|
||||
@@ -0,0 +1,132 @@
|
||||
# coding=utf-8
|
||||
from wraptor.decorators import throttle
|
||||
from config import config
|
||||
from items import get_item, get_item_kind_from_item, refresh_item
|
||||
|
||||
Activity = None
|
||||
try:
|
||||
from plex_activity import Activity
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class PlexActivityManager(object):
|
||||
def start(self):
|
||||
activity_sources_enabled = None
|
||||
|
||||
if not Activity:
|
||||
return
|
||||
|
||||
if config.plex_token:
|
||||
from plex import Plex
|
||||
Plex.configuration.defaults.authentication(config.plex_token)
|
||||
activity_sources_enabled = ["websocket"]
|
||||
Activity.on('websocket.playing', self.on_playing)
|
||||
|
||||
if activity_sources_enabled:
|
||||
Activity.start(activity_sources_enabled)
|
||||
|
||||
@throttle(5, instance_method=True)
|
||||
def on_playing(self, info):
|
||||
# ignore non-playing states and anything too far in
|
||||
if info["state"] != "playing" or info["viewOffset"] > 60000:
|
||||
return
|
||||
|
||||
# don't trigger on the first hit ever
|
||||
if "last_played_items" not in Dict:
|
||||
Dict["last_played_items"] = []
|
||||
Dict.Save()
|
||||
return
|
||||
|
||||
rating_key = info["ratingKey"]
|
||||
|
||||
# only use integer based rating keys
|
||||
try:
|
||||
int(rating_key)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if rating_key in Dict["last_played_items"] and rating_key != Dict["last_played_items"][0]:
|
||||
# shift last played
|
||||
Dict["last_played_items"].insert(0,
|
||||
Dict["last_played_items"].pop(Dict["last_played_items"].index(rating_key)))
|
||||
Dict.Save()
|
||||
|
||||
elif rating_key not in Dict["last_played_items"]:
|
||||
# new playing; store last X recently played items
|
||||
Dict["last_played_items"].insert(0, rating_key)
|
||||
Dict["last_played_items"] = Dict["last_played_items"][:config.store_recently_played_amount]
|
||||
|
||||
Dict.Save()
|
||||
|
||||
if not config.react_to_activities:
|
||||
return
|
||||
|
||||
debug_msg = "Started playing %s. Refreshing it." % rating_key
|
||||
|
||||
# todo: cleanup debug messages for hybrid-plus
|
||||
|
||||
keys_to_refresh = []
|
||||
if config.activity_mode in ["refresh", "next_episode", "hybrid", "hybrid-plus"]:
|
||||
# next episode or next episode and current movie
|
||||
if config.activity_mode in ["next_episode", "hybrid", "hybrid-plus"]:
|
||||
plex_item = get_item(rating_key)
|
||||
if not plex_item:
|
||||
Log.Warn("Can't determine media type of %s, skipping" % rating_key)
|
||||
return
|
||||
|
||||
if get_item_kind_from_item(plex_item) == "episode":
|
||||
next_ep = self.get_next_episode(rating_key)
|
||||
if config.activity_mode == "hybrid-plus":
|
||||
keys_to_refresh.append(rating_key)
|
||||
if next_ep:
|
||||
keys_to_refresh.append(next_ep.rating_key)
|
||||
debug_msg = "Started playing %s. Refreshing next episode (%s, S%02iE%02i)." % \
|
||||
(rating_key, next_ep.rating_key, int(next_ep.season.index), int(next_ep.index))
|
||||
|
||||
else:
|
||||
if config.activity_mode in ("hybrid", "hybrid-plus"):
|
||||
keys_to_refresh.append(rating_key)
|
||||
elif config.activity_mode == "refresh":
|
||||
keys_to_refresh.append(rating_key)
|
||||
|
||||
if keys_to_refresh:
|
||||
Log.Debug(debug_msg)
|
||||
Log.Debug("Refreshing %s", keys_to_refresh)
|
||||
for key in keys_to_refresh:
|
||||
refresh_item(key)
|
||||
|
||||
def get_next_episode(self, rating_key):
|
||||
plex_item = get_item(rating_key)
|
||||
if not plex_item:
|
||||
return
|
||||
|
||||
if get_item_kind_from_item(plex_item) == "episode":
|
||||
# get season
|
||||
season = get_item(plex_item.season.rating_key)
|
||||
if not season:
|
||||
return
|
||||
|
||||
# determine next episode
|
||||
# next episode is in the same season
|
||||
if plex_item.index < season.episode_count:
|
||||
# get next ep
|
||||
for ep in season.children():
|
||||
if ep.index == plex_item.index + 1:
|
||||
return ep
|
||||
|
||||
# it's not, try getting the first episode of the next season
|
||||
else:
|
||||
# get show
|
||||
show = get_item(plex_item.show.rating_key)
|
||||
# is there a next season?
|
||||
if season.index < show.season_count:
|
||||
for other_season in show.children():
|
||||
if other_season.index == season.index + 1:
|
||||
next_season = other_season
|
||||
for ep in next_season.children():
|
||||
if ep.index == 1:
|
||||
return ep
|
||||
|
||||
|
||||
activity = PlexActivityManager()
|
||||
@@ -0,0 +1,42 @@
|
||||
# coding=utf-8
|
||||
|
||||
|
||||
def refresh_plex_token():
|
||||
username = Prefs["plex_username"]
|
||||
password = Prefs["plex_password"]
|
||||
|
||||
if not username or not password:
|
||||
if "token" in Dict:
|
||||
del Dict["token"]
|
||||
Dict.Save()
|
||||
return
|
||||
|
||||
if "uuid" not in Dict:
|
||||
Dict["uuid"] = String.UUID()
|
||||
Dict.Save()
|
||||
|
||||
current_uuid = Dict["uuid"]
|
||||
|
||||
headers = {
|
||||
'X-Plex-Device-Name': 'Sub-Zero',
|
||||
'X-Plex-Product': 'Sub-Zero',
|
||||
'X-Plex-Version': '1.3.0',
|
||||
'X-Plex-Client-Identifier': "%s" % current_uuid,
|
||||
}
|
||||
|
||||
request = HTTP.Request("https://plex.tv/users/sign_in.json", headers=headers,
|
||||
values={'user[login]': Prefs["plex_username"], 'user[password]': Prefs["plex_password"]}, immediate=True)
|
||||
token = None
|
||||
if request:
|
||||
try:
|
||||
data = JSON.ObjectFromString(request.content)
|
||||
token = data["user"]["authentication_token"]
|
||||
log_data = data.copy()
|
||||
log_data["user"]["authentication_token"] = "xxxxxxxxxxxxxxxxxx"
|
||||
Log.Debug("Data returned from plex.tv: %s", log_data)
|
||||
except:
|
||||
pass
|
||||
if token:
|
||||
Dict["token"] = token
|
||||
Dict.Save()
|
||||
return True
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
# coding=utf-8
|
||||
import traceback
|
||||
|
||||
|
||||
def dispatch_migrate():
|
||||
try:
|
||||
migrate()
|
||||
except:
|
||||
Log.Error("Migration failed: %s" % traceback.format_exc())
|
||||
del Dict["subs"]
|
||||
Dict.Save()
|
||||
|
||||
|
||||
def migrate():
|
||||
"""
|
||||
some Dict/Data migrations here, no need for a more in-depth migration path for now
|
||||
:return:
|
||||
"""
|
||||
|
||||
# migrate subtitle history from Dict to Data
|
||||
if "history" in Dict and Dict["history"].get("history_items"):
|
||||
Log.Debug("Running migration for history data")
|
||||
from support.history import get_history
|
||||
history = get_history()
|
||||
|
||||
for item in reversed(Dict["history"]["history_items"]):
|
||||
history.add(item.item_title, item.rating_key, item.section_title, subtitle=item.subtitle, mode=item.mode,
|
||||
time=item.time)
|
||||
|
||||
del Dict["history"]
|
||||
history.destroy()
|
||||
Dict.Save()
|
||||
|
||||
# migrate subtitle storage from Dict to Data
|
||||
if "subs" in Dict:
|
||||
from support.storage import get_subtitle_storage
|
||||
from subzero.subtitle_storage import StoredSubtitle
|
||||
from support.plex_media import get_item
|
||||
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
|
||||
for video_id, parts in Dict["subs"].iteritems():
|
||||
try:
|
||||
item = get_item(video_id)
|
||||
except:
|
||||
continue
|
||||
|
||||
if not item:
|
||||
continue
|
||||
|
||||
stored_subs = subtitle_storage.load_or_new(item)
|
||||
stored_subs.version = 1
|
||||
|
||||
Log.Debug(u"Migrating %s" % video_id)
|
||||
|
||||
stored_any = False
|
||||
for part_id, lang_dict in parts.iteritems():
|
||||
part_id = str(part_id)
|
||||
Log.Debug(u"Migrating %s, %s" % (video_id, part_id))
|
||||
|
||||
for lang, subs in lang_dict.iteritems():
|
||||
lang = str(lang)
|
||||
if "current" in subs:
|
||||
current_key = subs["current"]
|
||||
provider_name, subtitle_id = current_key
|
||||
sub = subs.get(current_key)
|
||||
if sub and sub.get("title") and sub.get("mode"): # ditch legacy data without sufficient info
|
||||
stored_subs.title = sub["title"]
|
||||
new_sub = StoredSubtitle(sub["score"], sub["storage"], sub["hash"], provider_name,
|
||||
subtitle_id, date_added=sub["date_added"], mode=sub["mode"])
|
||||
|
||||
if part_id not in stored_subs.parts:
|
||||
stored_subs.parts[part_id] = {}
|
||||
|
||||
if lang not in stored_subs.parts[part_id]:
|
||||
stored_subs.parts[part_id][lang] = {}
|
||||
|
||||
Log.Debug(u"Migrating %s, %s, %s" % (video_id, part_id, current_key))
|
||||
|
||||
stored_subs.parts[part_id][lang][current_key] = new_sub
|
||||
stored_subs.parts[part_id][lang]["current"] = current_key
|
||||
stored_any = True
|
||||
|
||||
if stored_any:
|
||||
subtitle_storage.save(stored_subs)
|
||||
|
||||
subtitle_storage.destroy()
|
||||
del Dict["subs"]
|
||||
Dict.Save()
|
||||
@@ -0,0 +1,142 @@
|
||||
# coding=utf-8
|
||||
import os
|
||||
|
||||
from subzero.language import Language
|
||||
|
||||
import subliminal_patch as subliminal
|
||||
|
||||
from support.config import config
|
||||
from support.helpers import audio_streams_match_languages
|
||||
from subliminal_patch import compute_score
|
||||
from support.plex_media import get_blacklist_from_part_map
|
||||
from subzero.video import refine_video
|
||||
from support.storage import get_pack_data, store_pack_data
|
||||
|
||||
|
||||
def get_missing_languages(video, part):
|
||||
languages_list = config.get_lang_list(ordered=True)
|
||||
languages = set(languages_list)
|
||||
valid_langs_in_media = set()
|
||||
|
||||
if Prefs["subtitles.when"] != "Always":
|
||||
valid_langs_in_media = audio_streams_match_languages(video, languages_list)
|
||||
languages = languages.difference(valid_langs_in_media)
|
||||
if languages:
|
||||
Log.Debug("Languages missing after taking the audio streams into account: %s" % languages)
|
||||
|
||||
if valid_langs_in_media and not languages:
|
||||
Log.Debug("Skipping subtitle search for %s, audio streams are in correct language(s)",
|
||||
video)
|
||||
return set()
|
||||
|
||||
# should we treat IETF as alpha3? (ditch the country part)
|
||||
alpha3_map = {}
|
||||
if config.ietf_as_alpha3:
|
||||
for language in languages:
|
||||
if language and language.country:
|
||||
alpha3_map[language.alpha3] = language.country
|
||||
language.country = None
|
||||
|
||||
have_languages = video.subtitle_languages.copy()
|
||||
if config.ietf_as_alpha3:
|
||||
for language in have_languages:
|
||||
if language and language.country:
|
||||
alpha3_map[language.alpha3] = language.country
|
||||
language.country = None
|
||||
|
||||
missing_languages = (languages - have_languages)
|
||||
|
||||
if config.any_language_is_enough != "Always search for all configured languages":
|
||||
not_in_forced = "foreign" in config.any_language_is_enough
|
||||
if "External or embedded subtitle" in config.any_language_is_enough:
|
||||
langs = video.subtitle_languages if not not_in_forced else \
|
||||
filter(lambda l: not l.forced, video.subtitle_languages)
|
||||
if langs:
|
||||
Log.Debug("We have at least one subtitle for any configured language.")
|
||||
return set()
|
||||
|
||||
elif "External subtitle" in config.any_language_is_enough:
|
||||
langs = video.external_subtitle_languages if not not_in_forced else \
|
||||
filter(lambda l: not l.forced, video.external_subtitle_languages)
|
||||
if langs:
|
||||
Log.Debug("We have at least one external subtitle for any configured language.")
|
||||
return set()
|
||||
|
||||
# all languages are found if we either really have subs for all languages or we only want to have exactly one language
|
||||
# and we've only found one (the case for a selected language, Prefs['subtitles.only_one'] (one found sub matches any language))
|
||||
found_one_which_is_enough = len(video.subtitle_languages) >= 1 and Prefs['subtitles.only_one']
|
||||
if not missing_languages or found_one_which_is_enough:
|
||||
if found_one_which_is_enough:
|
||||
Log.Debug('Only one language was requested, and we\'ve got a subtitle for %s', video)
|
||||
else:
|
||||
Log.Debug('All languages %r exist for %s', languages, video)
|
||||
return set()
|
||||
|
||||
# re-add country codes to the missing languages, in case we've removed them above
|
||||
if config.ietf_as_alpha3:
|
||||
for language in languages:
|
||||
language.country = alpha3_map.get(language.alpha3, None)
|
||||
|
||||
return missing_languages
|
||||
|
||||
|
||||
def pre_download_hook(subtitle):
|
||||
if subtitle.is_pack:
|
||||
# try retrieving the subtitle from a cached pack archive
|
||||
pack_data = get_pack_data(subtitle)
|
||||
if pack_data:
|
||||
subtitle.pack_data = pack_data
|
||||
|
||||
|
||||
def post_download_hook(subtitle):
|
||||
# if a new pack was downloaded, store it in the cache; providers' download method is responsible for
|
||||
# setting subtitle.pack_data to None in case the cached pack data we provided was successfully used
|
||||
if subtitle.is_pack and subtitle.pack_data:
|
||||
# store pack data in cache
|
||||
store_pack_data(subtitle, subtitle.pack_data)
|
||||
|
||||
# may be redundant
|
||||
subtitle.pack_data = None
|
||||
|
||||
|
||||
def language_hook(provider):
|
||||
return config.get_lang_list(provider=provider)
|
||||
|
||||
|
||||
def download_best_subtitles(video_part_map, min_score=0, throttle_time=None, providers=None):
|
||||
hearing_impaired = Prefs['subtitles.search.hearingImpaired']
|
||||
languages = set([Language.rebuild(l) for l in config.lang_list])
|
||||
if not languages:
|
||||
return
|
||||
|
||||
use_videos = []
|
||||
missing_languages = set()
|
||||
for video, part in video_part_map.iteritems():
|
||||
if not video.ignore_all:
|
||||
p_missing_languages = get_missing_languages(video, part)
|
||||
else:
|
||||
p_missing_languages = languages
|
||||
|
||||
if p_missing_languages:
|
||||
Log.Info(u"%s has missing languages: %s", os.path.basename(video.name), p_missing_languages)
|
||||
refine_video(video, refiner_settings=config.refiner_settings)
|
||||
use_videos.append(video)
|
||||
missing_languages.update(p_missing_languages)
|
||||
|
||||
# prepare blacklist
|
||||
blacklist = get_blacklist_from_part_map(video_part_map, languages)
|
||||
|
||||
if use_videos and missing_languages:
|
||||
Log.Debug("Download best subtitles using settings: min_score: %s, hearing_impaired: %s, languages: %s" %
|
||||
(min_score, hearing_impaired, missing_languages))
|
||||
|
||||
return subliminal.download_best_subtitles(set(use_videos), missing_languages, min_score, hearing_impaired,
|
||||
providers=providers or config.providers,
|
||||
provider_configs=config.provider_settings,
|
||||
pool_class=config.provider_pool,
|
||||
compute_score=compute_score, throttle_time=throttle_time,
|
||||
blacklist=blacklist, throttle_callback=config.provider_throttle,
|
||||
pre_download_hook=pre_download_hook,
|
||||
post_download_hook=post_download_hook,
|
||||
language_hook=language_hook)
|
||||
Log.Debug("All languages for all requested videos exist. Doing nothing.")
|
||||
@@ -0,0 +1,208 @@
|
||||
# coding=utf-8
|
||||
import os
|
||||
import subprocess
|
||||
import traceback
|
||||
|
||||
from support.helpers import quote_args, mswindows, get_title_for_video_metadata, cast_bool, \
|
||||
audio_streams_match_languages
|
||||
from support.i18n import _
|
||||
from support.items import get_item_kind_from_item, refresh_item, get_all_items, get_item, MI_KEY
|
||||
from support.storage import get_subtitle_storage, save_subtitles
|
||||
from support.config import config
|
||||
from support.history import get_history
|
||||
from support.plex_media import get_all_parts, get_embedded_subtitle_streams, get_part, get_plex_metadata, \
|
||||
update_stream_info, is_stream_forced
|
||||
from support.scanning import scan_videos
|
||||
from subzero.language import Language
|
||||
from subliminal_patch.subtitle import ModifiedSubtitle
|
||||
|
||||
|
||||
def agent_extract_embedded(video_part_map, set_as_existing=False):
|
||||
try:
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
|
||||
to_extract = []
|
||||
item_count = 0
|
||||
|
||||
threads = []
|
||||
|
||||
for scanned_video, part_info in video_part_map.iteritems():
|
||||
plexapi_item = scanned_video.plexapi_metadata["item"]
|
||||
stored_subs = subtitle_storage.load_or_new(plexapi_item)
|
||||
valid_langs_in_media = \
|
||||
audio_streams_match_languages(scanned_video, config.get_lang_list(ordered=True))
|
||||
|
||||
if not config.lang_list.difference(valid_langs_in_media):
|
||||
Log.Debug("Skipping embedded subtitle extraction for %s, audio streams are in correct language(s)",
|
||||
plexapi_item.rating_key)
|
||||
continue
|
||||
|
||||
for plexapi_part in get_all_parts(plexapi_item):
|
||||
item_count = item_count + 1
|
||||
used_one_unknown_stream = False
|
||||
used_one_known_stream = False
|
||||
for requested_language in config.lang_list:
|
||||
skip_unknown = used_one_unknown_stream or used_one_known_stream
|
||||
embedded_subs = stored_subs.get_by_provider(plexapi_part.id, requested_language, "embedded")
|
||||
current = stored_subs.get_any(plexapi_part.id, requested_language) or \
|
||||
requested_language in scanned_video.external_subtitle_languages
|
||||
|
||||
if not embedded_subs:
|
||||
stream_data = get_embedded_subtitle_streams(plexapi_part, requested_language=requested_language,
|
||||
skip_unknown=skip_unknown)
|
||||
|
||||
if stream_data and stream_data[0]["language"]:
|
||||
stream = stream_data[0]["stream"]
|
||||
if stream_data[0]["is_unknown"]:
|
||||
used_one_unknown_stream = True
|
||||
else:
|
||||
used_one_known_stream = True
|
||||
|
||||
to_extract.append(({scanned_video: part_info}, plexapi_part, str(stream.index),
|
||||
str(requested_language), not current))
|
||||
|
||||
if not cast_bool(Prefs["subtitles.search_after_autoextract"]) or set_as_existing:
|
||||
scanned_video.subtitle_languages.update({requested_language})
|
||||
else:
|
||||
Log.Debug("Skipping embedded subtitle extraction for %s, already got %r from %s",
|
||||
plexapi_item.rating_key, requested_language, embedded_subs[0].id)
|
||||
if to_extract:
|
||||
Log.Info("Triggering extraction of %d embedded subtitles of %d items", len(to_extract), item_count)
|
||||
threads.append(Thread.Create(multi_extract_embedded, stream_list=to_extract, refresh=True, with_mods=True,
|
||||
single_thread=not config.advanced.auto_extract_multithread))
|
||||
return threads
|
||||
except:
|
||||
Log.Error("Something went wrong when auto-extracting subtitles, continuing: %s", traceback.format_exc())
|
||||
|
||||
|
||||
def multi_extract_embedded(stream_list, refresh=False, with_mods=False, single_thread=True, extract_mode="a",
|
||||
history_storage=None):
|
||||
def execute():
|
||||
for video_part_map, plexapi_part, stream_index, language, set_current in stream_list:
|
||||
plexapi_item = video_part_map.keys()[0].plexapi_metadata["item"]
|
||||
|
||||
extract_embedded_sub(rating_key=plexapi_item.rating_key, part_id=plexapi_part.id,
|
||||
plex_item=plexapi_item, part=plexapi_part, scanned_videos=video_part_map,
|
||||
stream_index=stream_index, set_current=set_current,
|
||||
language=language, with_mods=with_mods, refresh=refresh, extract_mode=extract_mode,
|
||||
history_storage=history_storage)
|
||||
|
||||
if single_thread:
|
||||
with Thread.Lock(key="extract_embedded"):
|
||||
execute()
|
||||
else:
|
||||
execute()
|
||||
|
||||
|
||||
def season_extract_embedded(rating_key, requested_language, with_mods=False, force=False):
|
||||
# get stored subtitle info for item id
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
|
||||
try:
|
||||
for data in get_all_items(key="children", value=rating_key, base="library/metadata"):
|
||||
item = get_item(data[MI_KEY])
|
||||
if item:
|
||||
stored_subs = subtitle_storage.load_or_new(item)
|
||||
for part in get_all_parts(item):
|
||||
embedded_subs = stored_subs.get_by_provider(part.id, requested_language, "embedded")
|
||||
current = stored_subs.get_any(part.id, requested_language)
|
||||
if not embedded_subs or force:
|
||||
stream_data = get_embedded_subtitle_streams(part, requested_language=requested_language)
|
||||
if stream_data:
|
||||
stream = stream_data[0]["stream"]
|
||||
|
||||
set_current = not current or force
|
||||
refresh = not current
|
||||
|
||||
extract_embedded_sub(rating_key=item.rating_key, part_id=part.id,
|
||||
stream_index=str(stream.index), set_current=set_current,
|
||||
refresh=refresh, language=requested_language, with_mods=with_mods,
|
||||
extract_mode="m")
|
||||
finally:
|
||||
subtitle_storage.destroy()
|
||||
|
||||
|
||||
def extract_embedded_sub(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs.pop("part_id")
|
||||
stream_index = kwargs.pop("stream_index")
|
||||
with_mods = kwargs.pop("with_mods", False)
|
||||
language = Language.fromietf(kwargs.pop("language"))
|
||||
refresh = kwargs.pop("refresh", True)
|
||||
set_current = kwargs.pop("set_current", True)
|
||||
|
||||
plex_item = kwargs.pop("plex_item", get_item(rating_key))
|
||||
item_type = get_item_kind_from_item(plex_item)
|
||||
part = kwargs.pop("part", get_part(plex_item, part_id))
|
||||
scanned_videos = kwargs.pop("scanned_videos", None)
|
||||
extract_mode = kwargs.pop("extract_mode", "a")
|
||||
|
||||
any_successful = False
|
||||
|
||||
from interface.menu_helpers import set_refresh_menu_state
|
||||
|
||||
if part:
|
||||
if not scanned_videos:
|
||||
metadata = get_plex_metadata(rating_key, part_id, item_type, plex_item=plex_item)
|
||||
scanned_videos = scan_videos([metadata], ignore_all=True, skip_hashing=True)
|
||||
|
||||
update_stream_info(part)
|
||||
for stream in part.streams:
|
||||
# subtitle stream
|
||||
if str(stream.index) == stream_index:
|
||||
is_forced = is_stream_forced(stream)
|
||||
bn = os.path.basename(part.file)
|
||||
|
||||
set_refresh_menu_state(_(u"Extracting subtitle %(stream_index)s of %(filename)s",
|
||||
stream_index=stream_index,
|
||||
filename=bn))
|
||||
Log.Info(u"Extracting stream %s (%s) of %s", stream_index, str(language), bn)
|
||||
|
||||
out_codec = stream.codec if stream.codec != "mov_text" else "srt"
|
||||
|
||||
args = [
|
||||
config.plex_transcoder, "-i", part.file, "-map", "0:%s" % stream_index, "-f", out_codec, "-"
|
||||
]
|
||||
|
||||
cmdline = quote_args(args)
|
||||
Log.Debug(u"Calling: %s", cmdline)
|
||||
if mswindows:
|
||||
Log.Debug("MSWindows: Fixing encoding")
|
||||
cmdline = cmdline.encode("mbcs")
|
||||
|
||||
output = None
|
||||
try:
|
||||
output = subprocess.check_output(cmdline, stderr=subprocess.PIPE, shell=True)
|
||||
except:
|
||||
Log.Error("Extraction failed: %s", traceback.format_exc())
|
||||
|
||||
if output:
|
||||
subtitle = ModifiedSubtitle(language, mods=config.default_mods if with_mods else None)
|
||||
subtitle.content = output
|
||||
subtitle.provider_name = "embedded"
|
||||
subtitle.id = "stream_%s" % stream_index
|
||||
subtitle.score = 0
|
||||
subtitle.set_encoding("utf-8")
|
||||
|
||||
# fixme: speedup video; only video.name is needed
|
||||
video = scanned_videos.keys()[0]
|
||||
save_successful = save_subtitles(scanned_videos, {video: [subtitle]}, mode="m",
|
||||
set_current=set_current)
|
||||
set_refresh_menu_state(None)
|
||||
|
||||
if save_successful and refresh:
|
||||
refresh_item(rating_key)
|
||||
|
||||
# add item to history
|
||||
item_title = get_title_for_video_metadata(video.plexapi_metadata,
|
||||
add_section_title=False, add_episode_title=True)
|
||||
|
||||
history = get_history()
|
||||
history.add(item_title, video.id, section_title=video.plexapi_metadata["section"],
|
||||
thumb=video.plexapi_metadata["super_thumb"],
|
||||
subtitle=subtitle, mode=extract_mode)
|
||||
history.destroy()
|
||||
|
||||
any_successful = True
|
||||
|
||||
return any_successful
|
||||
Executable
+446
@@ -0,0 +1,446 @@
|
||||
# coding=utf-8
|
||||
import os
|
||||
import traceback
|
||||
import types
|
||||
import unicodedata
|
||||
import datetime
|
||||
import urllib
|
||||
import time
|
||||
import re
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
|
||||
from babelfish.exceptions import LanguageError
|
||||
|
||||
import chardet
|
||||
|
||||
from bs4 import UnicodeDammit
|
||||
from subzero.language import Language, language_from_stream
|
||||
from subzero.analytics import track_event
|
||||
|
||||
mswindows = (sys.platform == "win32")
|
||||
if mswindows:
|
||||
from subprocess import list2cmdline
|
||||
quote_args = list2cmdline
|
||||
else:
|
||||
# POSIX
|
||||
from pipes import quote
|
||||
|
||||
def quote_args(seq):
|
||||
return ' '.join(quote(arg) for arg in seq)
|
||||
|
||||
# Unicode control characters can appear in ID3v2 tags but are not legal in XML.
|
||||
RE_UNICODE_CONTROL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + \
|
||||
u'|' + \
|
||||
u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' % \
|
||||
(
|
||||
unichr(0xd800), unichr(0xdbff), unichr(0xdc00), unichr(0xdfff),
|
||||
unichr(0xd800), unichr(0xdbff), unichr(0xdc00), unichr(0xdfff),
|
||||
unichr(0xd800), unichr(0xdbff), unichr(0xdc00), unichr(0xdfff)
|
||||
)
|
||||
|
||||
|
||||
def cast_bool(value):
|
||||
return str(value).strip() in ("true", "True")
|
||||
|
||||
|
||||
def cast_int(value, default=None):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
# A platform independent way to split paths which might come in with different separators.
|
||||
def split_path(str):
|
||||
if str.find('\\') != -1:
|
||||
return str.split('\\')
|
||||
else:
|
||||
return str.split('/')
|
||||
|
||||
|
||||
def unicodize(s):
|
||||
filename = s
|
||||
try:
|
||||
filename = unicodedata.normalize('NFC', unicode(s.decode('utf-8')))
|
||||
except:
|
||||
Log('Failed to unicodize: ' + repr(filename))
|
||||
try:
|
||||
filename = re.sub(RE_UNICODE_CONTROL, '', filename)
|
||||
except:
|
||||
Log('Couldn\'t strip control characters: ' + repr(filename))
|
||||
return filename
|
||||
|
||||
|
||||
def force_unicode(s):
|
||||
if not isinstance(s, types.UnicodeType):
|
||||
try:
|
||||
s = s.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
t = chardet.detect(s)
|
||||
try:
|
||||
s = s.decode(t["encoding"])
|
||||
except UnicodeDecodeError:
|
||||
s = UnicodeDammit(s).unicode_markup
|
||||
return s
|
||||
|
||||
|
||||
def clean_filename(filename):
|
||||
# this will remove any whitespace and punctuation chars and replace them with spaces, strip and return as lowercase
|
||||
return string.translate(filename.encode('utf-8'), string.maketrans(string.punctuation + string.whitespace,
|
||||
' ' * len(
|
||||
string.punctuation + string.whitespace))).strip().lower()
|
||||
|
||||
|
||||
def is_recent(t):
|
||||
now = datetime.datetime.now()
|
||||
when = datetime.datetime.fromtimestamp(t)
|
||||
value, key = Prefs["scheduler.item_is_recent_age"].split()
|
||||
if now - datetime.timedelta(**{key: int(value)}) < when:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# thanks, Plex-Trakt-Scrobbler
|
||||
def str_pad(s, length, align='left', pad_char=' ', trim=False):
|
||||
if not s:
|
||||
return s
|
||||
|
||||
if not isinstance(s, (str, unicode)):
|
||||
s = str(s)
|
||||
|
||||
if len(s) == length:
|
||||
return s
|
||||
elif len(s) > length and not trim:
|
||||
return s
|
||||
|
||||
if align == 'left':
|
||||
if len(s) > length:
|
||||
return s[:length]
|
||||
else:
|
||||
return s + (pad_char * (length - len(s)))
|
||||
elif align == 'right':
|
||||
if len(s) > length:
|
||||
return s[len(s) - length:]
|
||||
else:
|
||||
return (pad_char * (length - len(s))) + s
|
||||
else:
|
||||
raise ValueError("Unknown align type, expected either 'left' or 'right'")
|
||||
|
||||
|
||||
def pad_title(value, width=49):
|
||||
"""Pad a title to 30 characters to force the 'details' view."""
|
||||
return str_pad(value, width, pad_char=' ')
|
||||
|
||||
|
||||
def get_plex_item_display_title(item, kind, parent=None, parent_title=None, section_title=None,
|
||||
add_section_title=False):
|
||||
"""
|
||||
:param item: plex item
|
||||
:param kind: show or movie
|
||||
:param parent: season or None
|
||||
:param parent_title: parentTitle or None
|
||||
:return:
|
||||
"""
|
||||
return get_video_display_title(kind, item.title,
|
||||
section_title=(
|
||||
section_title or (parent.section.title if parent and getattr(parent, "section")
|
||||
else None)),
|
||||
parent_title=(parent_title or (parent.show.title if parent else None)),
|
||||
season=parent.index if parent else None,
|
||||
episode=item.index if kind == "show" else None,
|
||||
add_section_title=add_section_title)
|
||||
|
||||
|
||||
def series_num(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def get_video_display_title(kind, title, section_title=None, parent_title=None, season=None, episode=None,
|
||||
add_section_title=False):
|
||||
section_add = ""
|
||||
if add_section_title:
|
||||
section_add = ("%s: " % section_title) if section_title else ""
|
||||
|
||||
if kind in ("season", "show") and parent_title:
|
||||
if series_num(season) is not None and series_num(episode) is not None:
|
||||
return '%s%s S%02dE%02d%s' % (section_add, parent_title, season or 0, episode or 0,
|
||||
(", %s" % title if title else ""))
|
||||
elif series_num(season) is not None:
|
||||
return '%s%s S%02d%s' % (section_add, parent_title, season or 0,
|
||||
(", %s" % title if title else ""))
|
||||
|
||||
return '%s%s%s' % (section_add, parent_title, (", %s" % title if title else ""))
|
||||
return "%s%s" % (section_add, title)
|
||||
|
||||
|
||||
def get_title_for_video_metadata(metadata, add_section_title=True, add_episode_title=False):
|
||||
"""
|
||||
|
||||
:param metadata:
|
||||
:param add_section_title:
|
||||
:param add_episode_title: add the episode's title if its an episode else always add title
|
||||
:return:
|
||||
"""
|
||||
# compute item title
|
||||
add_title = (add_episode_title and metadata["series_id"]) or not metadata["series_id"]
|
||||
return get_video_display_title(
|
||||
"show" if metadata["series_id"] else "movie",
|
||||
metadata["title"] if add_title else "",
|
||||
parent_title=metadata.get("series", None),
|
||||
season=metadata.get("season", None),
|
||||
episode=metadata.get("episode", None),
|
||||
section_title=metadata.get("section", None),
|
||||
add_section_title=add_section_title
|
||||
)
|
||||
|
||||
|
||||
def get_identifier():
|
||||
identifier = None
|
||||
try:
|
||||
identifier = Platform.MachineIdentifier
|
||||
except:
|
||||
pass
|
||||
|
||||
if not identifier:
|
||||
identifier = String.UUID()
|
||||
|
||||
return Hash.SHA1(identifier + "SUBZEROOOOOOOOOO")
|
||||
|
||||
|
||||
def encode_message(base, s):
|
||||
return "%s?message=%s" % (base, urllib.quote_plus(s))
|
||||
|
||||
|
||||
def decode_message(s):
|
||||
return urllib.unquote_plus(s)
|
||||
|
||||
|
||||
def timestamp():
|
||||
return int(time.time()*1000)
|
||||
|
||||
|
||||
def df(d):
|
||||
return d.strftime("%Y-%m-%d %H:%M:%S") if d else "legacy data"
|
||||
|
||||
|
||||
def query_plex(url, args):
|
||||
"""
|
||||
simple http query to the plex API without parsing anything too complicated
|
||||
:param url:
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
use_args = args.copy()
|
||||
|
||||
computed_args = "&".join(["%s=%s" % (key, String.Quote(value)) for key, value in use_args.iteritems()])
|
||||
|
||||
return HTTP.Request(url + ("?%s" % computed_args) if computed_args else "", immediate=True)
|
||||
|
||||
|
||||
def check_write_permissions(path):
|
||||
if platform.system() == "Windows":
|
||||
# physical access check
|
||||
check_path = os.path.join(os.path.realpath(path), ".sz_perm_chk")
|
||||
try:
|
||||
if os.path.exists(check_path):
|
||||
os.rmdir(check_path)
|
||||
os.mkdir(check_path)
|
||||
os.rmdir(check_path)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
else:
|
||||
# os.access check
|
||||
return os.access(path, os.W_OK | os.X_OK)
|
||||
return False
|
||||
|
||||
|
||||
def get_item_hints(data):
|
||||
"""
|
||||
:param data: video item dict of media_to_videos
|
||||
:return:
|
||||
"""
|
||||
hints = {"title": data["original_title"] or data["title"], "type": "movie"}
|
||||
if data["type"] == "episode":
|
||||
hints.update(
|
||||
{
|
||||
"type": "episode",
|
||||
"episode_title": data["title"],
|
||||
"title": data["original_title"] or data["series"],
|
||||
}
|
||||
)
|
||||
if hints["title"]:
|
||||
hints["title"] = hints["title"].replace(":", "")
|
||||
return hints
|
||||
|
||||
|
||||
def notify_executable(exe_info, videos, subtitles, storage):
|
||||
variables = (
|
||||
"subtitle_language", "subtitle_path", "subtitle_filename", "provider", "score", "storage", "series_id",
|
||||
"series", "title", "section", "filename", "path", "folder", "season_id", "type", "id", "season"
|
||||
)
|
||||
to_clean = ("PYTHONPATH", "PYTHONHOME")
|
||||
exe, arguments = exe_info
|
||||
for video, video_subtitles in subtitles.items():
|
||||
for subtitle in video_subtitles:
|
||||
lang = str(subtitle.language)
|
||||
data = video.plexapi_metadata.copy()
|
||||
data.update({
|
||||
"subtitle_language": lang,
|
||||
"provider": subtitle.provider_name,
|
||||
"score": subtitle.score,
|
||||
"storage": storage,
|
||||
"subtitle_path": subtitle.storage_path,
|
||||
"subtitle_filename": os.path.basename(subtitle.storage_path)
|
||||
})
|
||||
|
||||
# fill missing data with None
|
||||
prepared_data = dict((v, data.get(v)) for v in variables)
|
||||
|
||||
prepared_arguments = [arg % prepared_data for arg in arguments]
|
||||
|
||||
Log.Debug(u"Calling %s with arguments: %s" % (exe, prepared_arguments))
|
||||
if not mswindows:
|
||||
env_path = {"PATH": os.pathsep.join(
|
||||
[
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
os.environ.get("PATH", "")
|
||||
]
|
||||
)
|
||||
}
|
||||
env = dict(os.environ, **env_path)
|
||||
env.pop("LD_LIBRARY_PATH", None)
|
||||
else:
|
||||
env = dict(os.environ)
|
||||
|
||||
# clean out any Plex-PYTHONPATH that may bleed through the spawned process
|
||||
for v in to_clean:
|
||||
if v in env and "plex" in env[v].lower():
|
||||
del env[v]
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(quote_args([exe] + prepared_arguments), stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, shell=True, env=env, cwd=os.path.dirname(exe))
|
||||
output, errors = proc.communicate()
|
||||
|
||||
if proc.returncode == 1:
|
||||
Log.Error(u"Calling %s with args %s failed: output:\n%s, error:\n%s", exe, prepared_arguments,
|
||||
output, errors)
|
||||
return
|
||||
|
||||
output = output.decode()
|
||||
|
||||
except:
|
||||
Log.Error(u"Calling %s failed: %s", exe, traceback.format_exc())
|
||||
else:
|
||||
Log.Debug(u"Process output: %s", output)
|
||||
|
||||
|
||||
def track_usage(category=None, action=None, label=None, value=None):
|
||||
if not cast_bool(Prefs["track_usage"]):
|
||||
return
|
||||
|
||||
if "last_tracked" not in Dict:
|
||||
Dict["last_tracked"] = OrderedDict()
|
||||
Dict.Save()
|
||||
|
||||
event_key = (category, action, label, value)
|
||||
now = datetime.datetime.now()
|
||||
if event_key in Dict["last_tracked"] and (Dict["last_tracked"][event_key] + datetime.timedelta(minutes=30)) < now:
|
||||
return
|
||||
|
||||
Dict["last_tracked"][event_key] = now
|
||||
|
||||
# maintenance
|
||||
for key, value in Dict["last_tracked"].copy().iteritems():
|
||||
# kill day old values
|
||||
if value < now - datetime.timedelta(days=1):
|
||||
try:
|
||||
del Dict["last_tracked"][key]
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
Thread.Create(dispatch_track_usage, category, action, label, value,
|
||||
identifier=Dict["anon_id"], first_use=Dict["first_use"],
|
||||
add=Network.PublicAddress)
|
||||
except:
|
||||
Log.Debug("Something went wrong when reporting anonymous user statistics: %s", traceback.format_exc())
|
||||
|
||||
|
||||
def dispatch_track_usage(*args, **kwargs):
|
||||
identifier = kwargs.pop("identifier")
|
||||
first_use = kwargs.pop("first_use")
|
||||
add = kwargs.pop("add")
|
||||
try:
|
||||
track_event(identifier=identifier, first_use=first_use, add=add, *[str(a) for a in args])
|
||||
except:
|
||||
Log.Debug("Something went wrong when reporting anonymous user statistics: %s", traceback.format_exc())
|
||||
|
||||
|
||||
def get_language_from_stream(lang_code):
|
||||
if lang_code:
|
||||
lang = Locale.Language.Match(lang_code)
|
||||
if lang and lang != "xx":
|
||||
# Log.Debug("Found language: %r", lang)
|
||||
return Language.fromietf(lang)
|
||||
elif lang:
|
||||
try:
|
||||
return language_from_stream(lang_code)
|
||||
except LanguageError:
|
||||
pass
|
||||
|
||||
|
||||
def audio_streams_match_languages(video, languages):
|
||||
without_forced = filter(lambda x: not x.forced, languages)
|
||||
if video.audio_languages and without_forced:
|
||||
if Prefs["subtitles.when"] == "Always":
|
||||
return set()
|
||||
|
||||
elif Prefs["subtitles.when"] == "When main audio stream is not Subtitle Language (1)":
|
||||
if video.audio_languages[0] == without_forced[0]:
|
||||
return set(without_forced)
|
||||
|
||||
elif Prefs["subtitles.when"] == "When any audio stream is not Subtitle Language (1)":
|
||||
if without_forced[0] in video.audio_languages:
|
||||
return set(without_forced)
|
||||
|
||||
elif Prefs["subtitles.when"] == "When main audio stream is not any configured language":
|
||||
if video.audio_languages[0] in without_forced:
|
||||
return set(without_forced)
|
||||
|
||||
elif Prefs["subtitles.when"] == "When any audio stream is not any configured language":
|
||||
matching = set(video.audio_languages).intersection(set(without_forced))
|
||||
if matching:
|
||||
return set(without_forced)
|
||||
|
||||
# if Prefs["subtitles.when_forced"] in [
|
||||
# "Always",
|
||||
# "Only for Subtitle Language (1)",
|
||||
# "Only for Subtitle Language (2)",
|
||||
# "Only for Subtitle Language (3)"
|
||||
# ]:
|
||||
|
||||
return set()
|
||||
|
||||
|
||||
def get_language(lang_short):
|
||||
return Language.fromietf(lang_short)
|
||||
|
||||
|
||||
def display_language(l):
|
||||
if not l:
|
||||
return "Unknown"
|
||||
return _(str(l.basename).lower()) + ((u" (%s)" % _("forced")) if l.forced else "")
|
||||
|
||||
|
||||
class PartUnknownException(Exception):
|
||||
pass
|
||||
@@ -0,0 +1,4 @@
|
||||
# coding=utf-8
|
||||
from subzero.history_storage import SubtitleHistory
|
||||
|
||||
get_history = lambda: SubtitleHistory(Data, Thread, int(Prefs["history_size"]))
|
||||
@@ -0,0 +1,109 @@
|
||||
# coding=utf-8
|
||||
|
||||
import inspect
|
||||
|
||||
from support.config import config
|
||||
|
||||
|
||||
core = getattr(Data, "_core")
|
||||
|
||||
|
||||
# get original localization module in order to access its base classes later on
|
||||
def get_localization_module():
|
||||
cls = getattr(core.localization, "__class__")
|
||||
return inspect.getmodule(cls)
|
||||
|
||||
|
||||
plex_i18n_module = get_localization_module()
|
||||
|
||||
|
||||
def old_style_placeholders_count(s):
|
||||
# fixme: incomplete, use regex
|
||||
return sum(s.count(c) for c in ["%s", "%d", "%r", "%f", "%i"])
|
||||
|
||||
|
||||
def check_old_style_placeholders(k, args):
|
||||
# replace escaped %'s?
|
||||
k = k.__str__().replace("%%", "")
|
||||
|
||||
if "%(" in k:
|
||||
Log.Error(u"%r defines named placeholders for formatting" % k)
|
||||
return "NEEDS NAMED ARGUMENTS"
|
||||
|
||||
placeholders_found = old_style_placeholders_count(k)
|
||||
if placeholders_found and not args:
|
||||
Log.Error(u"%r requires a arguments for formatting" % k)
|
||||
return "NEEDS FORMAT ARGUMENTS"
|
||||
|
||||
elif not placeholders_found and args:
|
||||
Log.Error(u"%r doesn't define placeholders for formatting" % k)
|
||||
return "HAS NO FORMAT ARGUMENTS"
|
||||
|
||||
elif placeholders_found and placeholders_found != len(args):
|
||||
Log.Error(u"%r wrong amount of arguments supplied for formatting" % k)
|
||||
return "WRONG FORMAT ARGUMENT COUNT"
|
||||
|
||||
|
||||
class SmartLocalStringFormatter(plex_i18n_module.LocalStringFormatter):
|
||||
"""
|
||||
this allows the use of dictionaries for string formatting, also does some sanity checking on the keys and values
|
||||
"""
|
||||
def __init__(self, string1, string2, locale=None):
|
||||
if isinstance(string2, tuple):
|
||||
# dictionary passed
|
||||
if len(string2) == 1 and hasattr(string2[0], "iteritems"):
|
||||
string2 = string2[0]
|
||||
if config.debug_i18n:
|
||||
if "%(" not in string1.__str__().replace("%%", ""):
|
||||
Log.Error(u"%r: dictionary for non-named format string supplied" % string1.__str__())
|
||||
string1 = "%s"
|
||||
string2 = "NO NAMED ARGUMENTS"
|
||||
|
||||
# arguments
|
||||
elif len(string2) >= 1 and config.debug_i18n:
|
||||
msg = check_old_style_placeholders(string1, string2)
|
||||
if msg:
|
||||
string1 = "%s"
|
||||
string2 = msg
|
||||
|
||||
setattr(self, "_string1", string1)
|
||||
setattr(self, "_string2", string2)
|
||||
setattr(self, "_locale", locale)
|
||||
|
||||
|
||||
def local_string_with_optional_format(key, *args, **kwargs):
|
||||
if kwargs:
|
||||
args = (kwargs,)
|
||||
else:
|
||||
args = tuple(args)
|
||||
|
||||
if args:
|
||||
# fixme: may not be the best idea as this evaluates the string early
|
||||
try:
|
||||
return unicode(SmartLocalStringFormatter(plex_i18n_module.LocalString(core, key, Locale.CurrentLocale), args))
|
||||
except (TypeError, ValueError):
|
||||
Log.Exception("Broken translation!")
|
||||
Log.Debug("EN string: %s", plex_i18n_module.LocalString(core, key, "en"))
|
||||
Log.Debug("%s string: %r", Locale.CurrentLocale,
|
||||
unicode(plex_i18n_module.LocalString(core, key, Locale.CurrentLocale)))
|
||||
return unicode(SmartLocalStringFormatter(plex_i18n_module.LocalString(core, key, "en"), args))
|
||||
|
||||
# check string instances for arguments
|
||||
if config.debug_i18n:
|
||||
msg = check_old_style_placeholders(key, args)
|
||||
if msg:
|
||||
return msg
|
||||
|
||||
try:
|
||||
return unicode(plex_i18n_module.LocalString(core, key, Locale.CurrentLocale))
|
||||
|
||||
except TypeError:
|
||||
Log.Exception("Broken translation!")
|
||||
return unicode(plex_i18n_module.LocalString(core, key, "en"))
|
||||
|
||||
|
||||
_ = local_string_with_optional_format
|
||||
|
||||
|
||||
def is_localized_string(s):
|
||||
return hasattr(s, "localize")
|
||||
@@ -0,0 +1,75 @@
|
||||
# coding=utf-8
|
||||
|
||||
from subzero.lib.dict import DictProxy
|
||||
from config import config
|
||||
|
||||
|
||||
class ExcludeDict(DictProxy):
|
||||
store = "ignore"
|
||||
|
||||
# single item keys returned by helpers.items.getItems mapped to their parents
|
||||
translate_keys = {
|
||||
"section": "sections",
|
||||
"show": "series",
|
||||
"movie": "videos",
|
||||
"episode": "videos",
|
||||
"season": "seasons",
|
||||
}
|
||||
|
||||
# getItems types mapped to their verbose names
|
||||
keys_verbose = {
|
||||
"sections": "Section",
|
||||
"series": "Series",
|
||||
"videos": "Item",
|
||||
"seasons": "Season",
|
||||
}
|
||||
|
||||
key_order = ("sections", "series", "videos", "seasons")
|
||||
|
||||
def __len__(self):
|
||||
try:
|
||||
return sum(len(self.Dict[self.store][key]) for key in self.key_order)
|
||||
except KeyError:
|
||||
# old version
|
||||
self.Dict[self.store] = self.setup_defaults()
|
||||
return 0
|
||||
|
||||
def translate_key(self, name):
|
||||
return self.translate_keys.get(name)
|
||||
|
||||
def verbose(self, name):
|
||||
return self.keys_verbose.get(self.translate_key(name) or name)
|
||||
|
||||
def get_title_key(self, kind, key):
|
||||
return "%s_%s" % (kind, key)
|
||||
|
||||
def add_title(self, kind, key, title):
|
||||
self["titles"][self.get_title_key(kind, key)] = title
|
||||
|
||||
def remove_title(self, kind, key):
|
||||
title_key = self.get_title_key(kind, key)
|
||||
if title_key in self.titles:
|
||||
del self.titles[title_key]
|
||||
|
||||
def get_title(self, kind, key):
|
||||
title_key = self.get_title_key(kind, key)
|
||||
if title_key in self.titles:
|
||||
return self.titles[title_key]
|
||||
|
||||
def save(self):
|
||||
Dict.Save()
|
||||
|
||||
def setup_defaults(self):
|
||||
return {"sections": [], "series": [], "videos": [], "titles": {}, "seasons": []}
|
||||
|
||||
|
||||
class IncludeDict(ExcludeDict):
|
||||
store = "include"
|
||||
|
||||
|
||||
exclude_list = ExcludeDict(Dict)
|
||||
include_list = IncludeDict(Dict)
|
||||
|
||||
|
||||
def get_decision_list():
|
||||
return include_list if config.include else exclude_list
|
||||
@@ -0,0 +1,479 @@
|
||||
# coding=utf-8
|
||||
|
||||
import logging
|
||||
import re
|
||||
import traceback
|
||||
import types
|
||||
import os
|
||||
|
||||
import time
|
||||
|
||||
import datetime
|
||||
|
||||
from ignore import get_decision_list
|
||||
from helpers import is_recent, get_plex_item_display_title, query_plex, PartUnknownException
|
||||
from lib import Plex, get_intent
|
||||
from config import config
|
||||
from subliminal_patch.subtitle import ModifiedSubtitle
|
||||
from subzero.modification import registry as mod_registry, SubtitleModifications
|
||||
from socket import timeout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MI_KIND, MI_TITLE, MI_KEY, MI_DEEPER, MI_ITEM = 0, 1, 2, 3, 4
|
||||
|
||||
container_size_re = re.compile(ur'totalSize="(\d+)"')
|
||||
|
||||
|
||||
def get_item(key):
|
||||
try:
|
||||
item_id = int(key)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
try:
|
||||
item_container = Plex["library"].metadata(item_id)
|
||||
except timeout:
|
||||
Log.Debug("PMS API timed out when querying information about item %d", item_id)
|
||||
return
|
||||
|
||||
try:
|
||||
return list(item_container)[0]
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def get_item_kind(item):
|
||||
return type(item).__name__
|
||||
|
||||
|
||||
PLEX_API_TYPE_MAP = {
|
||||
"Show": "series",
|
||||
"Season": "season",
|
||||
"Episode": "episode",
|
||||
"Movie": "movie",
|
||||
}
|
||||
|
||||
|
||||
def get_item_kind_from_rating_key(key):
|
||||
item = get_item(key)
|
||||
return PLEX_API_TYPE_MAP.get(get_item_kind(item))
|
||||
|
||||
|
||||
def get_item_kind_from_item(item):
|
||||
return PLEX_API_TYPE_MAP.get(get_item_kind(item))
|
||||
|
||||
|
||||
def get_item_title(item):
|
||||
kind = get_item_kind_from_item(item)
|
||||
if kind not in ("episode", "movie", "season", "series"):
|
||||
return
|
||||
|
||||
if kind == "episode":
|
||||
return get_plex_item_display_title(item, "show", parent=item.season, section_title=None,
|
||||
parent_title=item.show.title)
|
||||
elif kind == "season":
|
||||
return get_plex_item_display_title(item, "season", parent=item.show, section_title="Season",
|
||||
parent_title=item.show.title)
|
||||
else:
|
||||
return get_plex_item_display_title(item, kind, section_title=None)
|
||||
|
||||
|
||||
def get_item_thumb(item):
|
||||
kind = get_item_kind(item)
|
||||
if kind == "Episode":
|
||||
return item.show.thumb
|
||||
elif kind == "Section":
|
||||
return item.art
|
||||
return item.thumb
|
||||
|
||||
|
||||
def get_items_info(items):
|
||||
return items[0][MI_KIND], items[0][MI_DEEPER]
|
||||
|
||||
|
||||
def get_kind(items):
|
||||
return items[0][MI_KIND]
|
||||
|
||||
|
||||
def get_section_size(key):
|
||||
"""
|
||||
quick query to determine the section size
|
||||
:param key:
|
||||
:return:
|
||||
"""
|
||||
size = None
|
||||
url = "http://127.0.0.1:32400/library/sections/%s/all" % int(key)
|
||||
use_args = {
|
||||
"X-Plex-Container-Size": "0",
|
||||
"X-Plex-Container-Start": "0"
|
||||
}
|
||||
response = query_plex(url, use_args)
|
||||
matches = container_size_re.findall(response.content)
|
||||
if matches:
|
||||
size = int(matches[0])
|
||||
|
||||
return size
|
||||
|
||||
|
||||
def get_items(key="recently_added", base="library", value=None, flat=False, add_section_title=False):
|
||||
"""
|
||||
try to handle all return types plex throws at us and return a generalized item tuple
|
||||
"""
|
||||
items = []
|
||||
apply_value = None
|
||||
if value:
|
||||
if isinstance(value, types.ListType):
|
||||
apply_value = value
|
||||
else:
|
||||
apply_value = [value]
|
||||
result = getattr(Plex[base], key)(*(apply_value or []))
|
||||
|
||||
for item in result:
|
||||
cls = getattr(getattr(item, "__class__"), "__name__")
|
||||
if hasattr(item, "scanner"):
|
||||
kind = "section"
|
||||
elif cls == "Directory":
|
||||
kind = "directory"
|
||||
else:
|
||||
kind = item.type
|
||||
|
||||
# only return items for our enabled sections
|
||||
section_key = None
|
||||
if kind == "section":
|
||||
section_key = item.key
|
||||
else:
|
||||
if hasattr(item, "section_key"):
|
||||
section_key = getattr(item, "section_key")
|
||||
|
||||
if section_key and section_key not in config.enabled_sections:
|
||||
continue
|
||||
|
||||
if kind == "season":
|
||||
# fixme: i think this case is unused now
|
||||
if flat:
|
||||
# return episodes
|
||||
for child in item.children():
|
||||
items.append(("episode", get_plex_item_display_title(child, "show", parent=item, add_section_title=add_section_title), int(item.rating_key),
|
||||
False, child))
|
||||
else:
|
||||
# return seasons
|
||||
items.append(("season", item.title, int(item.rating_key), True, item))
|
||||
|
||||
elif kind == "directory":
|
||||
items.append(("directory", item.title, item.key, True, item))
|
||||
|
||||
elif kind == "section":
|
||||
if item.type in ['movie', 'show']:
|
||||
item.size = get_section_size(item.key)
|
||||
items.append(("section", item.title, int(item.key), True, item))
|
||||
|
||||
elif kind == "episode":
|
||||
items.append(
|
||||
(kind, get_plex_item_display_title(item, "show", parent=item.season, parent_title=item.show.title, section_title=item.section.title,
|
||||
add_section_title=add_section_title), int(item.rating_key), False, item))
|
||||
|
||||
elif kind in ("movie", "artist", "photo"):
|
||||
items.append((kind, get_plex_item_display_title(item, kind, section_title=item.section.title, add_section_title=add_section_title),
|
||||
int(item.rating_key), False, item))
|
||||
|
||||
elif kind == "show":
|
||||
items.append((
|
||||
kind, get_plex_item_display_title(item, kind, section_title=item.section.title, add_section_title=add_section_title), int(item.rating_key), True,
|
||||
item))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def get_recent_items():
|
||||
"""
|
||||
actually get the recent items, not limited like /library/recentlyAdded
|
||||
:return:
|
||||
"""
|
||||
args = {
|
||||
"sort": "addedAt:desc",
|
||||
"X-Plex-Container-Start": "0",
|
||||
"X-Plex-Container-Size": "%s" % config.max_recent_items_per_library
|
||||
}
|
||||
|
||||
episode_re = re.compile(ur'(?su)ratingKey="(?P<key>\d+)"'
|
||||
ur'.+?grandparentRatingKey="(?P<parent_key>\d+)"'
|
||||
ur'.+?title="(?P<title>.*?)"'
|
||||
ur'.+?grandparentTitle="(?P<parent_title>.*?)"'
|
||||
ur'.+?index="(?P<episode>\d+?)"'
|
||||
ur'.+?parentIndex="(?P<season>\d+?)".+?addedAt="(?P<added>\d+)"'
|
||||
ur'.+?<Part.+? file="(?P<filename>[^"]+?)"')
|
||||
movie_re = re.compile(ur'(?su)ratingKey="(?P<key>\d+)".+?title="(?P<title>.*?)'
|
||||
ur'".+?addedAt="(?P<added>\d+)"'
|
||||
ur'.+?<Part.+? file="(?P<filename>[^"]+?)"')
|
||||
available_keys = ("key", "title", "parent_key", "parent_title", "season", "episode", "added", "filename")
|
||||
recent = []
|
||||
|
||||
ref_list = get_decision_list()
|
||||
|
||||
for section in Plex["library"].sections():
|
||||
if section.type not in ("movie", "show") \
|
||||
or section.key not in config.enabled_sections \
|
||||
or ((section.key not in ref_list.sections and config.include)
|
||||
or (section.key in ref_list.sections and not config.include)):
|
||||
Log.Debug(u"Skipping section: %s" % section.title)
|
||||
continue
|
||||
|
||||
use_args = args.copy()
|
||||
plex_item_type = "Movie"
|
||||
if section.type == "show":
|
||||
use_args["type"] = "4"
|
||||
plex_item_type = "Episode"
|
||||
|
||||
url = "http://127.0.0.1:32400/library/sections/%s/all" % int(section.key)
|
||||
response = query_plex(url, use_args)
|
||||
|
||||
matcher = episode_re if section.type == "show" else movie_re
|
||||
matches = [m.groupdict() for m in matcher.finditer(response.content)]
|
||||
for match in matches:
|
||||
data = dict((key, match[key] if key in match else None) for key in available_keys)
|
||||
if section.type == "show" and ((data["parent_key"] not in ref_list.series and config.include) or
|
||||
(data["parent_key"] in ref_list.series and not config.include)):
|
||||
Log.Debug(u"Skipping series: %s" % data["parent_title"])
|
||||
continue
|
||||
if (data["key"] not in ref_list.videos and config.include) or \
|
||||
(data["key"] in ref_list.videos and not config.include):
|
||||
Log.Debug(u"Skipping item: %s" % data["title"])
|
||||
continue
|
||||
if not is_physically_wanted(data["filename"], plex_item_type):
|
||||
Log.Debug(u"Skipping item (physically not wanted): %s" % data["title"])
|
||||
continue
|
||||
|
||||
if is_recent(int(data["added"])):
|
||||
recent.append((int(data["added"]), section.type, section.title, data["key"]))
|
||||
|
||||
return recent
|
||||
|
||||
|
||||
def get_on_deck_items():
|
||||
return get_items(key="on_deck", add_section_title=True)
|
||||
|
||||
|
||||
def get_recently_added_items():
|
||||
return get_items(key="recently_added", add_section_title=True, flat=False)
|
||||
|
||||
|
||||
def get_all_items(key, base="library", value=None, flat=False):
|
||||
return get_items(key, base=base, value=value, flat=flat)
|
||||
|
||||
|
||||
def is_wanted(rating_key, item=None):
|
||||
"""
|
||||
check whether an item, its show/season/section is in the soft or the hard ignore list
|
||||
:param rating_key:
|
||||
:param item:
|
||||
:return:
|
||||
"""
|
||||
|
||||
ref_list = get_decision_list()
|
||||
ret_val = ref_list.store == "include"
|
||||
inc_exc_verbose = "exclude" if not ret_val else "include"
|
||||
|
||||
# item in soft include/exclude list
|
||||
if ref_list["videos"] and rating_key in ref_list["videos"]:
|
||||
Log.Debug("Item %s is in the soft %s list" % (rating_key, inc_exc_verbose))
|
||||
return ret_val
|
||||
|
||||
item = item or get_item(rating_key)
|
||||
kind = get_item_kind(item)
|
||||
|
||||
# show in soft include/exclude list
|
||||
if kind == "Episode" and ref_list["series"] and item.show.rating_key in ref_list["series"]:
|
||||
Log.Debug("Item %s's show is in the soft %s list" % (rating_key, inc_exc_verbose))
|
||||
return ret_val
|
||||
|
||||
# season in soft include/exclude list
|
||||
if kind == "Episode" and ref_list["seasons"] and item.season.rating_key in ref_list["seasons"]:
|
||||
Log.Debug("Item %s's season is in the soft %s list" % (rating_key, inc_exc_verbose))
|
||||
return ret_val
|
||||
|
||||
# section in soft include/exclude list
|
||||
if ref_list["sections"] and item.section.key in ref_list["sections"]:
|
||||
Log.Debug("Item %s's section is in the soft %s list" % (rating_key, inc_exc_verbose))
|
||||
return ret_val
|
||||
|
||||
# physical/path include/exclude
|
||||
if config.include_exclude_sz_files or config.include_exclude_paths:
|
||||
for media in item.media:
|
||||
for part in media.parts:
|
||||
return is_physically_wanted(part.file, kind)
|
||||
|
||||
return not ret_val
|
||||
|
||||
|
||||
def is_physically_wanted(fn, kind):
|
||||
if config.include_exclude_sz_files or config.include_exclude_paths:
|
||||
# normally check current item folder and the library
|
||||
check_paths = [".", "../"]
|
||||
if kind == "Episode":
|
||||
# series/episode, we've got a season folder here, also
|
||||
check_paths.append("../../")
|
||||
|
||||
wanted_results = []
|
||||
if config.include_exclude_sz_files:
|
||||
for sub_path in check_paths:
|
||||
wanted_results.append(config.is_physically_wanted(
|
||||
os.path.normpath(os.path.join(os.path.dirname(fn), sub_path)), fn))
|
||||
|
||||
if config.include_exclude_paths:
|
||||
wanted_results.append(config.is_path_wanted(fn))
|
||||
|
||||
if config.include and any(wanted_results):
|
||||
return True
|
||||
elif not config.include and not all(wanted_results):
|
||||
return False
|
||||
|
||||
return not config.include
|
||||
|
||||
|
||||
def refresh_item(rating_key, force=False, timeout=8000, refresh_kind=None, parent_rating_key=None):
|
||||
intent = get_intent()
|
||||
|
||||
# timeout actually is the time for which the intent will be valid
|
||||
if force:
|
||||
Log.Debug("Setting intent for force-refresh of %s to timeout: %s", rating_key, timeout)
|
||||
intent.set("force", rating_key, timeout=timeout)
|
||||
|
||||
# force Dict.Save()
|
||||
intent.store.save()
|
||||
|
||||
refresh = [rating_key]
|
||||
|
||||
if refresh_kind == "season":
|
||||
# season refresh, needs explicit per-episode refresh
|
||||
refresh = [item.rating_key for item in list(Plex["library/metadata"].children(int(rating_key)))]
|
||||
|
||||
multiple = len(refresh) > 1
|
||||
for key in refresh:
|
||||
Log.Info("%s item %s", "Refreshing" if not force else "Forced-refreshing", key)
|
||||
Plex["library/metadata"].refresh(key)
|
||||
if multiple:
|
||||
Thread.Sleep(10.0)
|
||||
|
||||
|
||||
def get_current_sub(rating_key, part_id, language, plex_item=None):
|
||||
from support.storage import get_subtitle_storage
|
||||
|
||||
item = plex_item or get_item(rating_key)
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
stored_subs = subtitle_storage.load_or_new(item)
|
||||
current_sub = stored_subs.get_any(part_id, language)
|
||||
return current_sub, stored_subs, subtitle_storage
|
||||
|
||||
|
||||
def save_stored_sub(stored_subtitle, rating_key, part_id, language, item_type, plex_item=None, storage=None,
|
||||
stored_subs=None):
|
||||
"""
|
||||
in order for this to work, if the calling supplies stored_subs and storage, it has to trigger its saving and
|
||||
destruction explicitly
|
||||
:param stored_subtitle:
|
||||
:param rating_key:
|
||||
:param part_id:
|
||||
:param language:
|
||||
:param item_type:
|
||||
:param plex_item:
|
||||
:param storage:
|
||||
:param stored_subs:
|
||||
:return:
|
||||
"""
|
||||
from support.plex_media import get_plex_metadata
|
||||
from support.scanning import scan_videos
|
||||
from support.storage import save_subtitles, get_subtitle_storage
|
||||
|
||||
plex_item = plex_item or get_item(rating_key)
|
||||
|
||||
stored_subs_was_provided = True
|
||||
if not stored_subs or not storage:
|
||||
storage = get_subtitle_storage()
|
||||
stored_subs = storage.load(plex_item.rating_key)
|
||||
stored_subs_was_provided = False
|
||||
|
||||
if not all([plex_item, stored_subs]):
|
||||
return
|
||||
|
||||
try:
|
||||
metadata = get_plex_metadata(rating_key, part_id, item_type, plex_item=plex_item)
|
||||
except PartUnknownException:
|
||||
return
|
||||
|
||||
scanned_parts = scan_videos([metadata], ignore_all=True, skip_hashing=True)
|
||||
video, plex_part = scanned_parts.items()[0]
|
||||
|
||||
subtitle = ModifiedSubtitle(language, mods=stored_subtitle.mods)
|
||||
subtitle.content = stored_subtitle.content
|
||||
if stored_subtitle.encoding:
|
||||
# thanks plex
|
||||
setattr(subtitle, "_guessed_encoding", stored_subtitle.encoding)
|
||||
|
||||
if stored_subtitle.encoding != "utf-8":
|
||||
subtitle.normalize()
|
||||
stored_subtitle.content = subtitle.content
|
||||
stored_subtitle.encoding = "utf-8"
|
||||
storage.save(stored_subs)
|
||||
|
||||
subtitle.plex_media_fps = plex_part.fps
|
||||
subtitle.page_link = stored_subtitle.id
|
||||
subtitle.language = language
|
||||
subtitle.id = stored_subtitle.id
|
||||
|
||||
try:
|
||||
save_subtitles(scanned_parts, {video: [subtitle]}, mode="m", bare_save=True)
|
||||
stored_subtitle.mods = subtitle.mods
|
||||
Log.Debug("Modified %s subtitle for: %s:%s with: %s", language.name, rating_key, part_id,
|
||||
", ".join(subtitle.mods) if subtitle.mods else "none")
|
||||
except:
|
||||
Log.Error("Something went wrong when modifying subtitle: %s", traceback.format_exc())
|
||||
|
||||
if subtitle.storage_path:
|
||||
stored_subtitle.last_mod = datetime.datetime.fromtimestamp(os.path.getmtime(subtitle.storage_path))
|
||||
|
||||
if not stored_subs_was_provided:
|
||||
storage.save(stored_subs)
|
||||
storage.destroy()
|
||||
|
||||
|
||||
def set_mods_for_part(rating_key, part_id, language, item_type, mods, mode="add"):
|
||||
plex_item = get_item(rating_key)
|
||||
|
||||
if not plex_item:
|
||||
return
|
||||
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language, plex_item=plex_item)
|
||||
if mode == "add":
|
||||
for mod in mods:
|
||||
identifier, args = SubtitleModifications.parse_identifier(mod)
|
||||
mod_class = SubtitleModifications.get_mod_class(identifier)
|
||||
|
||||
if identifier not in mod_registry.mods_available:
|
||||
raise NotImplementedError("Mod unknown or not registered")
|
||||
|
||||
# clean exclusive mods
|
||||
if mod_class.exclusive and current_sub.mods:
|
||||
for current_mod in current_sub.mods[:]:
|
||||
if current_mod.startswith(identifier):
|
||||
current_sub.mods.remove(current_mod)
|
||||
Log.Info("Removing superseded mod %s" % current_mod)
|
||||
|
||||
current_sub.add_mod(mod)
|
||||
elif mode == "clear":
|
||||
current_sub.add_mod(None)
|
||||
elif mode == "remove":
|
||||
for mod in mods:
|
||||
current_sub.mods.remove(mod)
|
||||
|
||||
elif mode == "remove_last":
|
||||
if current_sub.mods:
|
||||
current_sub.mods.pop()
|
||||
else:
|
||||
raise NotImplementedError("Wrong mode given")
|
||||
|
||||
save_stored_sub(current_sub, rating_key, part_id, language, item_type, plex_item=plex_item, storage=storage,
|
||||
stored_subs=stored_subs)
|
||||
|
||||
storage.save(stored_subs)
|
||||
storage.destroy()
|
||||
@@ -0,0 +1,56 @@
|
||||
# coding=utf-8
|
||||
|
||||
import plex
|
||||
from subzero.intent import TempIntent
|
||||
from subzero.lib.dict import DictProxy
|
||||
from subzero.lib.httpfake import PlexPyNativeResponseProxy
|
||||
from subzero.constants import DEFAULT_TIMEOUT
|
||||
|
||||
|
||||
class PlexPyNativeRequestProxy(object):
|
||||
"""
|
||||
A really dumb object that tries to mimic requests.Request in an incomplete way, so that plex.Plex
|
||||
uses native plex HTTPRequests instead of the better requests.Request class.
|
||||
|
||||
This allows us to operate freely on 127.0.0.1's PMS.
|
||||
|
||||
To be used in conjunction with subzero.lib.httpfake.PlexPyNativeResponseProxy
|
||||
"""
|
||||
url = None
|
||||
data = None
|
||||
headers = None
|
||||
method = None
|
||||
|
||||
def prepare(self):
|
||||
return self
|
||||
|
||||
def send(self):
|
||||
# fixme: add self.data to HTTP.Request
|
||||
data = None
|
||||
status_code = 200
|
||||
try:
|
||||
data = HTTP.Request(self.url, headers=self.headers, immediate=True, method=self.method,
|
||||
timeout=DEFAULT_TIMEOUT)
|
||||
except Ex.HTTPError as e:
|
||||
status_code = e.code
|
||||
return PlexPyNativeResponseProxy(data, status_code, self)
|
||||
|
||||
|
||||
plex.request.Request = PlexPyNativeRequestProxy
|
||||
|
||||
Plex = plex.Plex
|
||||
|
||||
|
||||
class IntentDictStorage(DictProxy):
|
||||
store = "intent"
|
||||
|
||||
def setup_defaults(self):
|
||||
return {"force": {}}
|
||||
|
||||
|
||||
def get_intent():
|
||||
"""
|
||||
use this to get an intent from inside a separate thread
|
||||
:return:
|
||||
"""
|
||||
return TempIntent(store=IntentDictStorage(Dict))
|
||||
Executable
+208
@@ -0,0 +1,208 @@
|
||||
# coding=utf-8
|
||||
|
||||
import os
|
||||
|
||||
import config
|
||||
import helpers
|
||||
import subtitlehelpers
|
||||
|
||||
from config import config as sz_config
|
||||
from subzero.language import ENDSWITH_LANGUAGECODE_RE
|
||||
|
||||
|
||||
SECONDARY_TAGS = ['forced', 'normal', 'default', 'embedded', 'embedded-forced', 'custom', 'hi', 'cc', 'sdh']
|
||||
|
||||
|
||||
def find_subtitles(part, ignore_parts_cleanup=None):
|
||||
lang_sub_map = {}
|
||||
ignore_parts_cleanup = ignore_parts_cleanup or []
|
||||
part_filename = helpers.unicodize(part.file)
|
||||
part_basename = os.path.splitext(os.path.basename(part_filename))[0]
|
||||
use_filesystem = helpers.cast_bool(Prefs["subtitles.save.filesystem"])
|
||||
sub_dir_custom = Prefs["subtitles.save.subFolder.Custom"].strip() \
|
||||
if Prefs["subtitles.save.subFolder.Custom"] else None
|
||||
|
||||
use_sub_subfolder = Prefs["subtitles.save.subFolder"] != "current folder" and not sub_dir_custom
|
||||
autoclean = helpers.cast_bool(Prefs["subtitles.autoclean"])
|
||||
sub_subfolder = None
|
||||
paths = [os.path.dirname(part_filename)] if use_filesystem else []
|
||||
|
||||
global_folders = []
|
||||
|
||||
if use_filesystem:
|
||||
# Check for local subtitles subdirectory
|
||||
sub_dir_base = paths[0]
|
||||
sub_dir_list = []
|
||||
|
||||
if use_sub_subfolder:
|
||||
# got selected subfolder
|
||||
sub_subfolder = os.path.join(sub_dir_base, Prefs["subtitles.save.subFolder"])
|
||||
sub_dir_list.append(sub_subfolder)
|
||||
sub_subfolder = os.path.normpath(helpers.unicodize(sub_subfolder))
|
||||
|
||||
if sub_dir_custom:
|
||||
# got custom subfolder
|
||||
sub_dir_custom = os.path.normpath(sub_dir_custom)
|
||||
if os.path.isdir(sub_dir_custom) and os.path.isabs(sub_dir_custom):
|
||||
# absolute folder
|
||||
sub_dir_list.append(sub_dir_custom)
|
||||
global_folders.append(sub_dir_custom)
|
||||
else:
|
||||
# relative folder
|
||||
fld = os.path.join(sub_dir_base, sub_dir_custom)
|
||||
sub_dir_list.append(fld)
|
||||
|
||||
for sub_dir in sub_dir_list:
|
||||
if os.path.isdir(sub_dir):
|
||||
paths.append(sub_dir)
|
||||
|
||||
# Check for a global subtitle location
|
||||
global_subtitle_folder = os.path.join(Core.app_support_path, 'Subtitles')
|
||||
if os.path.exists(global_subtitle_folder):
|
||||
paths.append(global_subtitle_folder)
|
||||
global_folders.append(global_subtitle_folder)
|
||||
|
||||
# normalize all paths
|
||||
paths = [os.path.normpath(helpers.unicodize(path)) for path in paths]
|
||||
|
||||
# We start by building a dictionary of files to their absolute paths. We also need to know
|
||||
# the number of media files that are actually present, in case the found local media asset
|
||||
# is limited to a single instance per media file.
|
||||
#
|
||||
file_paths = {}
|
||||
total_media_files = 0
|
||||
media_files = []
|
||||
for path in paths:
|
||||
for file_path_listing in os.listdir(path.encode(sz_config.fs_encoding)):
|
||||
# When using os.listdir with a unicode path, it will always return a string using the
|
||||
# NFD form. However, we internally are using the form NFC and therefore need to convert
|
||||
# it to allow correct regex / comparisons to be performed.
|
||||
#
|
||||
file_path_listing = helpers.unicodize(file_path_listing)
|
||||
if os.path.isfile(os.path.join(path, file_path_listing).encode(sz_config.fs_encoding)):
|
||||
file_paths[file_path_listing.lower()] = os.path.join(path, file_path_listing)
|
||||
|
||||
# If we've found an actual media file, we should record it.
|
||||
(root, ext) = os.path.splitext(file_path_listing)
|
||||
if ext.lower()[1:] in config.VIDEO_EXTS:
|
||||
total_media_files += 1
|
||||
|
||||
# collect found media files
|
||||
media_files.append(root)
|
||||
|
||||
# cleanup any leftover subtitle if no associated media file was found
|
||||
if autoclean and ignore_parts_cleanup:
|
||||
Log.Info("Skipping housekeeping of: %s", paths)
|
||||
|
||||
if use_filesystem and autoclean and not ignore_parts_cleanup:
|
||||
for path in paths:
|
||||
# only housekeep in sub_subfolder if sub_subfolder is used
|
||||
if use_sub_subfolder and path != sub_subfolder and not sz_config.advanced.thorough_cleaning:
|
||||
continue
|
||||
|
||||
# we can't housekeep the global subtitle folders as we don't know about *all* media files
|
||||
# in a library; skip them
|
||||
skip_path = False
|
||||
for fld in global_folders:
|
||||
if path.startswith(fld):
|
||||
Log.Info("Skipping housekeeping of folder: %s", path)
|
||||
skip_path = True
|
||||
break
|
||||
|
||||
if skip_path:
|
||||
continue
|
||||
|
||||
for file_path_listing in os.listdir(path.encode(sz_config.fs_encoding)):
|
||||
file_path_listing = helpers.unicodize(file_path_listing)
|
||||
enc_fn = os.path.join(path, file_path_listing).encode(sz_config.fs_encoding)
|
||||
|
||||
if os.path.isfile(enc_fn):
|
||||
(root, ext) = os.path.splitext(file_path_listing)
|
||||
# it's a subtitle file
|
||||
if ext.lower()[1:] in config.SUBTITLE_EXTS_BASE:
|
||||
# get fn without forced/default/normal tag
|
||||
split_tag = root.rsplit(".", 1)
|
||||
if len(split_tag) > 1 and split_tag[1].lower() in SECONDARY_TAGS:
|
||||
root = split_tag[0]
|
||||
|
||||
# get associated media file name without language
|
||||
sub_fn = ENDSWITH_LANGUAGECODE_RE.sub("", root)
|
||||
|
||||
# subtitle basename and basename without possible language tag not found in collected
|
||||
# media files? kill.
|
||||
if root not in media_files and sub_fn not in media_files:
|
||||
Log.Info("Removing leftover subtitle: %s", os.path.join(path, file_path_listing))
|
||||
try:
|
||||
os.remove(enc_fn)
|
||||
except (OSError, IOError):
|
||||
Log.Error("Removing failed")
|
||||
|
||||
Log('Looking for subtitle media in %d paths with %d media files.', len(paths), total_media_files)
|
||||
Log('Paths: %s', ", ".join([helpers.unicodize(p) for p in paths]))
|
||||
|
||||
for file_path in file_paths.values():
|
||||
local_filename = os.path.basename(file_path)
|
||||
bn, ext = os.path.splitext(local_filename)
|
||||
local_basename = helpers.unicodize(bn)
|
||||
|
||||
# get fn without forced/default/normal tag
|
||||
split_tag = local_basename.rsplit(".", 1)
|
||||
has_additional_tag = False
|
||||
if len(split_tag) > 1 and split_tag[1].lower() in SECONDARY_TAGS:
|
||||
local_basename = split_tag[0]
|
||||
has_additional_tag = True
|
||||
|
||||
# split off possible language tag
|
||||
local_basename2 = local_basename.rsplit('.', 1)[0]
|
||||
filename_matches_part = local_basename == part_basename or local_basename2 == part_basename
|
||||
filename_contains_part = part_basename in local_basename
|
||||
|
||||
if not ext.lower()[1:] in config.SUBTITLE_EXTS:
|
||||
continue
|
||||
|
||||
# if the file is located within the global subtitle folders and its name doesn't match exactly, ignore it
|
||||
if global_folders and not filename_matches_part:
|
||||
skip_path = False
|
||||
for fld in global_folders:
|
||||
if file_path.startswith(fld):
|
||||
skip_path = True
|
||||
break
|
||||
|
||||
if skip_path:
|
||||
continue
|
||||
|
||||
# determine whether to pick up the subtitle based on our match strictness
|
||||
if not filename_matches_part:
|
||||
if sz_config.ext_match_strictness == "strict" or (
|
||||
sz_config.ext_match_strictness == "loose" and not filename_contains_part):
|
||||
# Log.Debug("%s doesn't match %s, skipping" % (helpers.unicodize(local_filename),
|
||||
# helpers.unicodize(part_basename)))
|
||||
continue
|
||||
|
||||
subtitle_helper = subtitlehelpers.subtitle_helpers(file_path)
|
||||
if subtitle_helper is not None:
|
||||
local_lang_map = subtitle_helper.process_subtitles(part)
|
||||
for new_language, subtitles in local_lang_map.items():
|
||||
|
||||
# Add the possible new language along with the located subtitles so that we can validate them
|
||||
# at the end...
|
||||
#
|
||||
if not lang_sub_map.has_key(new_language):
|
||||
lang_sub_map[new_language] = []
|
||||
lang_sub_map[new_language] = lang_sub_map[new_language] + subtitles
|
||||
|
||||
# add known metadata subs to our sub list
|
||||
if not use_filesystem:
|
||||
for language, sub_list in subtitlehelpers.get_subtitles_from_metadata(part).iteritems():
|
||||
if sub_list:
|
||||
if language not in lang_sub_map:
|
||||
lang_sub_map[language] = []
|
||||
lang_sub_map[language] = lang_sub_map[language] + sub_list
|
||||
|
||||
# Now whack subtitles that don't exist anymore.
|
||||
for language in lang_sub_map.keys():
|
||||
part.subtitles[language].validate_keys(lang_sub_map[language])
|
||||
|
||||
# Now whack the languages that don't exist anymore.
|
||||
for language in list(set(part.subtitles.keys()) - set(lang_sub_map.keys())):
|
||||
part.subtitles[language].validate_keys({})
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
# coding=utf-8
|
||||
import traceback
|
||||
import time
|
||||
|
||||
import os
|
||||
|
||||
from babelfish import LanguageReverseError
|
||||
|
||||
from support.config import config, TEXT_SUBTITLE_EXTS
|
||||
from support.helpers import get_plex_item_display_title, cast_bool, get_language_from_stream
|
||||
from support.plex_media import is_stream_forced, update_stream_info
|
||||
from support.items import get_item
|
||||
from support.lib import Plex
|
||||
from support.storage import get_subtitle_storage
|
||||
from subzero.video import has_external_subtitle
|
||||
from subzero.language import Language
|
||||
|
||||
|
||||
def item_discover_missing_subs(rating_key, kind="show", added_at=None, section_title=None, internal=False, external=True, languages=()):
|
||||
item_id = int(rating_key)
|
||||
item = get_item(rating_key)
|
||||
|
||||
if kind == "show":
|
||||
item_title = get_plex_item_display_title(item, kind, parent=item.season, section_title=section_title, parent_title=item.show.title)
|
||||
else:
|
||||
item_title = get_plex_item_display_title(item, kind, section_title=section_title)
|
||||
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
stored_subs = subtitle_storage.load(rating_key)
|
||||
subtitle_storage.destroy()
|
||||
|
||||
subtitle_target_dir, tdir_is_absolute = config.subtitle_sub_dir
|
||||
|
||||
missing = set()
|
||||
languages_set = set([Language.rebuild(l) for l in languages])
|
||||
for media in item.media:
|
||||
existing_subs = {"internal": [], "external": [], "own_external": [], "count": 0}
|
||||
for part in media.parts:
|
||||
update_stream_info(part)
|
||||
# did we already download an external subtitle before?
|
||||
if subtitle_target_dir and stored_subs:
|
||||
for language in languages_set:
|
||||
if has_external_subtitle(part.id, stored_subs, language):
|
||||
# check the existence of the actual subtitle file
|
||||
|
||||
# get media filename without extension
|
||||
part_basename = os.path.splitext(os.path.basename(part.file))[0]
|
||||
|
||||
# compute target directory for subtitle
|
||||
# fixme: move to central location
|
||||
if tdir_is_absolute:
|
||||
possible_subtitle_path_base = subtitle_target_dir
|
||||
else:
|
||||
possible_subtitle_path_base = os.path.join(os.path.dirname(part.file), subtitle_target_dir)
|
||||
|
||||
possible_subtitle_path_base = os.path.realpath(possible_subtitle_path_base)
|
||||
|
||||
# folder actually exists?
|
||||
if not os.path.isdir(possible_subtitle_path_base):
|
||||
continue
|
||||
|
||||
found_any = False
|
||||
for ext in config.subtitle_formats:
|
||||
if cast_bool(Prefs['subtitles.only_one']):
|
||||
possible_subtitle_path = os.path.join(possible_subtitle_path_base,
|
||||
u"%s.%s" % (part_basename, ext))
|
||||
else:
|
||||
possible_subtitle_path = os.path.join(possible_subtitle_path_base,
|
||||
u"%s.%s.%s" % (part_basename, language, ext))
|
||||
|
||||
# check for subtitle existence
|
||||
if os.path.isfile(possible_subtitle_path):
|
||||
found_any = True
|
||||
Log.Debug(u"Found: %s", possible_subtitle_path)
|
||||
break
|
||||
|
||||
if found_any:
|
||||
existing_subs["own_external"].append(language)
|
||||
existing_subs["count"] = existing_subs["count"] + 1
|
||||
|
||||
for stream in part.streams:
|
||||
if stream.stream_type == 3:
|
||||
is_forced = is_stream_forced(stream)
|
||||
if stream.index:
|
||||
key = "internal"
|
||||
else:
|
||||
key = "external"
|
||||
|
||||
if not config.exotic_ext and stream.codec.lower() not in TEXT_SUBTITLE_EXTS:
|
||||
continue
|
||||
|
||||
# treat unknown language as lang1?
|
||||
if not stream.language_code and config.treat_und_as_first:
|
||||
lang = Language.rebuild(list(config.lang_list)[0])
|
||||
|
||||
# we can't parse empty language codes
|
||||
elif not stream.language_code or not stream.codec:
|
||||
continue
|
||||
|
||||
else:
|
||||
# parse with internal language parser first
|
||||
try:
|
||||
lang = get_language_from_stream(stream.language_code)
|
||||
if not lang:
|
||||
if config.treat_und_as_first:
|
||||
lang = Language.rebuild(list(config.lang_list)[0])
|
||||
else:
|
||||
continue
|
||||
|
||||
except (ValueError, LanguageReverseError):
|
||||
continue
|
||||
|
||||
if lang:
|
||||
# Log.Debug("Found babelfish language: %r", lang)
|
||||
lang.forced = is_forced
|
||||
existing_subs[key].append(lang)
|
||||
existing_subs["count"] = existing_subs["count"] + 1
|
||||
|
||||
missing_from_part = set([Language.rebuild(l) for l in languages])
|
||||
if existing_subs["count"]:
|
||||
|
||||
# fixme: this is actually somewhat broken with IETF, as Plex doesn't store the country portion
|
||||
# (pt instead of pt-BR) inside the database. So it might actually download pt-BR if there's a local pt-BR
|
||||
# subtitle but not our own.
|
||||
existing_flat = set((existing_subs["internal"] if internal else [])
|
||||
+ (existing_subs["external"] if external else [])
|
||||
+ existing_subs["own_external"])
|
||||
|
||||
check_languages = set([Language.rebuild(l) for l in languages])
|
||||
alpha3_map = {}
|
||||
if config.ietf_as_alpha3:
|
||||
for language in existing_flat:
|
||||
if language.country:
|
||||
alpha3_map[language.alpha3] = language.country
|
||||
language.country = None
|
||||
|
||||
for language in check_languages:
|
||||
if language.country:
|
||||
alpha3_map[language.alpha3] = language.country
|
||||
language.country = None
|
||||
|
||||
# compare sets of strings, not sets of different Language instances
|
||||
check_languages_str = set(str(l) for l in check_languages)
|
||||
existing_flat_str = set(str(l) for l in existing_flat)
|
||||
|
||||
if check_languages_str.issubset(existing_flat_str) or \
|
||||
(len(existing_flat) >= 1 and Prefs['subtitles.only_one']):
|
||||
# all subs found
|
||||
#Log.Info(u"All subtitles exist for '%s'", item_title)
|
||||
continue
|
||||
|
||||
missing_from_part = set(Language.fromietf(l) for l in check_languages_str - existing_flat_str)
|
||||
if config.ietf_as_alpha3:
|
||||
for language in missing_from_part:
|
||||
language.country = alpha3_map.get(language.alpha3, None)
|
||||
|
||||
if missing_from_part:
|
||||
Log.Info(u"Subs still missing for '%s' (%s: %s): %s", item_title, rating_key, media.id,
|
||||
missing_from_part)
|
||||
missing.update(missing_from_part)
|
||||
|
||||
if missing:
|
||||
# deduplicate
|
||||
missing = set(Language.fromietf(la) for la in set(str(l) for l in missing))
|
||||
return added_at, item_id, item_title, item, missing
|
||||
|
||||
|
||||
def items_get_all_missing_subs(items, sleep_after_request=False):
|
||||
missing = []
|
||||
for added_at, kind, section_title, key in items:
|
||||
try:
|
||||
state = item_discover_missing_subs(
|
||||
key,
|
||||
kind=kind,
|
||||
added_at=added_at,
|
||||
section_title=section_title,
|
||||
languages=config.lang_list.copy(),
|
||||
internal=cast_bool(Prefs["subtitles.scan.embedded"]),
|
||||
external=cast_bool(Prefs["subtitles.scan.external"])
|
||||
)
|
||||
if state:
|
||||
# (added_at, item_id, title, item, missing_languages)
|
||||
missing.append(state)
|
||||
except:
|
||||
Log.Error("Something went wrong when getting the state of item %s: %s", key, traceback.format_exc())
|
||||
if sleep_after_request:
|
||||
time.sleep(sleep_after_request)
|
||||
return missing
|
||||
|
||||
|
||||
def refresh_item(item):
|
||||
if not config.no_refresh:
|
||||
Plex["library/metadata"].refresh(item)
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
# coding=utf-8
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import helpers
|
||||
from items import get_item
|
||||
from subzero.language import Language
|
||||
from lib import Plex
|
||||
from support.config import TEXT_SUBTITLE_EXTS, config
|
||||
|
||||
|
||||
def get_metadata_dict(item, part, add):
|
||||
data = {
|
||||
"item": item,
|
||||
"section": item.section.title,
|
||||
"path": part.file,
|
||||
"folder": os.path.dirname(part.file),
|
||||
"filename": os.path.basename(part.file)
|
||||
}
|
||||
data.update(add)
|
||||
return data
|
||||
|
||||
|
||||
imdb_guid_identifier = "com.plexapp.agents.imdb://"
|
||||
tvdb_guid_identifier = "com.plexapp.agents.thetvdb://"
|
||||
|
||||
|
||||
def get_plexapi_stream_info(plex_item, part_id=None):
|
||||
if not plex_item:
|
||||
return
|
||||
|
||||
d = {"stream": {}}
|
||||
data = d["stream"]
|
||||
|
||||
# find current part
|
||||
current_part = None
|
||||
current_media = None
|
||||
for media in plex_item.media:
|
||||
for part in media.parts:
|
||||
if not part_id or str(part.id) == part_id:
|
||||
current_part = part
|
||||
current_media = media
|
||||
break
|
||||
if current_part:
|
||||
break
|
||||
|
||||
if not current_part:
|
||||
return d
|
||||
|
||||
data["video_codec"] = current_media.video_codec
|
||||
if current_media.audio_codec:
|
||||
data["audio_codec"] = current_media.audio_codec.upper()
|
||||
|
||||
if data["audio_codec"] == "DCA":
|
||||
data["audio_codec"] = "DTS"
|
||||
|
||||
if current_media.audio_channels == 8:
|
||||
data["audio_channels"] = "7.1"
|
||||
|
||||
elif current_media.audio_channels == 6:
|
||||
data["audio_channels"] = "5.1"
|
||||
else:
|
||||
data["audio_channels"] = "%s.0" % str(current_media.audio_channels)
|
||||
|
||||
# iter streams
|
||||
for stream in current_part.streams:
|
||||
if stream.stream_type == 1:
|
||||
# video stream
|
||||
data["resolution"] = "%s%s" % (current_media.video_resolution,
|
||||
"i" if stream.scan_type != "progressive" else "p")
|
||||
break
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def media_to_videos(media, kind="series"):
|
||||
"""
|
||||
iterates through media and returns the associated parts (videos)
|
||||
:param media:
|
||||
:param kind:
|
||||
:return:
|
||||
"""
|
||||
videos = []
|
||||
|
||||
# this is a Show or a Movie object
|
||||
plex_item = get_item(media.id)
|
||||
year = plex_item.year
|
||||
original_title = plex_item.title_original
|
||||
|
||||
if kind == "series":
|
||||
for season in media.seasons:
|
||||
season_object = media.seasons[season]
|
||||
for episode in media.seasons[season].episodes:
|
||||
ep = media.seasons[season].episodes[episode]
|
||||
|
||||
tvdb_id = None
|
||||
series_tvdb_id = None
|
||||
if tvdb_guid_identifier in ep.guid:
|
||||
tvdb_id = ep.guid[len(tvdb_guid_identifier):].split("?")[0]
|
||||
series_tvdb_id = tvdb_id.split("/")[0]
|
||||
|
||||
# get plex item via API for additional metadata
|
||||
plex_episode = get_item(ep.id)
|
||||
stream_info = get_plexapi_stream_info(plex_episode)
|
||||
|
||||
if not stream_info:
|
||||
continue
|
||||
|
||||
for item in media.seasons[season].episodes[episode].items:
|
||||
for part in item.parts:
|
||||
videos.append(
|
||||
get_metadata_dict(plex_episode, part,
|
||||
dict(stream_info, **{"plex_part": part, "type": "episode",
|
||||
"title": ep.title,
|
||||
"series": media.title, "id": ep.id, "year": year,
|
||||
"series_id": media.id,
|
||||
"super_thumb": plex_item.thumb,
|
||||
"season_id": season_object.id,
|
||||
"imdb_id": None, "series_tvdb_id": series_tvdb_id,
|
||||
"tvdb_id": tvdb_id,
|
||||
"original_title": original_title,
|
||||
"episode": plex_episode.index,
|
||||
"season": plex_episode.season.index,
|
||||
"section": plex_episode.section.title
|
||||
})
|
||||
)
|
||||
)
|
||||
else:
|
||||
stream_info = get_plexapi_stream_info(plex_item)
|
||||
|
||||
if stream_info:
|
||||
imdb_id = None
|
||||
if imdb_guid_identifier in media.guid:
|
||||
imdb_id = media.guid[len(imdb_guid_identifier):].split("?")[0]
|
||||
for item in media.items:
|
||||
for part in item.parts:
|
||||
videos.append(
|
||||
get_metadata_dict(plex_item, part, dict(stream_info, **{"plex_part": part, "type": "movie",
|
||||
"title": media.title, "id": media.id,
|
||||
"super_thumb": plex_item.thumb,
|
||||
"series_id": None, "year": year,
|
||||
"season_id": None, "imdb_id": imdb_id,
|
||||
"original_title": original_title,
|
||||
"series_tvdb_id": None, "tvdb_id": None,
|
||||
"section": plex_item.section.title})
|
||||
)
|
||||
)
|
||||
return videos
|
||||
|
||||
|
||||
IGNORE_FN = ("subzero.ignore", ".subzero.ignore", ".nosz")
|
||||
|
||||
|
||||
def get_stream_fps(streams):
|
||||
"""
|
||||
accepts a list of plex streams or a list of the plex api streams
|
||||
"""
|
||||
for stream in streams:
|
||||
# video
|
||||
stream_type = getattr(stream, "type", getattr(stream, "stream_type", None))
|
||||
if stream_type == 1:
|
||||
return getattr(stream, "frameRate", getattr(stream, "frame_rate", "25.000"))
|
||||
return "25.000"
|
||||
|
||||
|
||||
def get_media_item_ids(media, kind="series"):
|
||||
# fixme: does this work correctly for full series force-refreshes and its intents?
|
||||
ids = [media.id]
|
||||
if kind == "series":
|
||||
for season in media.seasons:
|
||||
for episode in media.seasons[season].episodes:
|
||||
ids.append(media.seasons[season].episodes[episode].id)
|
||||
|
||||
return ids
|
||||
|
||||
|
||||
def get_all_parts(plex_item):
|
||||
parts = []
|
||||
for media in plex_item.media:
|
||||
parts += media.parts
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def update_stream_info(part):
|
||||
try:
|
||||
return update_stream_info_(part)
|
||||
except:
|
||||
Log.Exception("Getting Mediainfo failed for: %s", part.file)
|
||||
|
||||
|
||||
def update_stream_info_(part):
|
||||
if config.mediainfo_bin and part.container == "mp4":
|
||||
cmdline = '%s --Inform="Text;-%%ID%%_%%Title%%" %s' % (config.mediainfo_bin, helpers.quote(part.file))
|
||||
result = subprocess.check_output(cmdline, stderr=subprocess.PIPE, shell=True)
|
||||
if result:
|
||||
try:
|
||||
stream_titles = {}
|
||||
for pair in result[1:].split("-"):
|
||||
sid, title = pair.split("_")
|
||||
stream_titles[int(sid.strip())] = title.strip()
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
filled = []
|
||||
for stream in part.streams:
|
||||
if stream.index is None:
|
||||
Log.Debug("Found stream with no index: %r", stream)
|
||||
|
||||
index = stream.index+1 if stream.index is not None else 1
|
||||
if index in stream_titles:
|
||||
stream.title = stream_titles[index]
|
||||
filled.append(index-1)
|
||||
if filled:
|
||||
Log.Debug("Filled missing MP4 stream title info for streams: %s", filled)
|
||||
|
||||
|
||||
def is_stream_forced(stream):
|
||||
stream_title = getattr(stream, "title", "") or ""
|
||||
forced = getattr(stream, "forced", False)
|
||||
if not forced and stream_title and "forced" in stream_title.strip().lower():
|
||||
forced = True
|
||||
|
||||
return forced
|
||||
|
||||
|
||||
def get_embedded_subtitle_streams(part, requested_language=None, skip_duplicate_unknown=True, skip_unknown=False):
|
||||
streams = []
|
||||
streams_unknown = []
|
||||
all_streams = []
|
||||
has_unknown = False
|
||||
found_requested_language = False
|
||||
update_stream_info(part)
|
||||
for stream in part.streams:
|
||||
# subtitle stream
|
||||
if stream.stream_type == 3 and not stream.stream_key and stream.codec in TEXT_SUBTITLE_EXTS:
|
||||
is_forced = is_stream_forced(stream)
|
||||
language = helpers.get_language_from_stream(stream.language_code)
|
||||
if language:
|
||||
language = Language.rebuild(language, forced=is_forced)
|
||||
|
||||
is_unknown = False
|
||||
found_requested_language = requested_language and requested_language == language
|
||||
stream_data = None
|
||||
|
||||
if not language:
|
||||
# only consider first unknown subtitle stream
|
||||
if config.treat_und_as_first:
|
||||
if has_unknown and skip_duplicate_unknown:
|
||||
Log.Debug("skipping duplicate unknown")
|
||||
continue
|
||||
|
||||
language = Language.rebuild(list(config.lang_list)[0], forced=is_forced)
|
||||
else:
|
||||
language = None
|
||||
is_unknown = True
|
||||
has_unknown = True
|
||||
stream_data = {"stream": stream, "is_unknown": is_unknown, "language": language,
|
||||
"is_forced": is_forced}
|
||||
streams_unknown.append(stream_data)
|
||||
|
||||
if not requested_language or found_requested_language:
|
||||
stream_data = {"stream": stream, "is_unknown": is_unknown, "language": language,
|
||||
"is_forced": is_forced}
|
||||
streams.append(stream_data)
|
||||
|
||||
if found_requested_language:
|
||||
break
|
||||
|
||||
if stream_data:
|
||||
all_streams.append(stream_data)
|
||||
|
||||
if requested_language:
|
||||
if streams_unknown and not found_requested_language and not skip_unknown:
|
||||
streams = streams_unknown
|
||||
else:
|
||||
streams = all_streams
|
||||
|
||||
return streams
|
||||
|
||||
|
||||
def get_part(plex_item, part_id):
|
||||
for media in plex_item.media:
|
||||
for part in media.parts:
|
||||
if str(part.id) == str(part_id):
|
||||
return part
|
||||
|
||||
|
||||
def get_plex_metadata(rating_key, part_id, item_type, plex_item=None):
|
||||
"""
|
||||
uses the Plex 3rd party API accessor to get metadata information
|
||||
|
||||
:param rating_key: movie or episode
|
||||
:param part_id:
|
||||
:param item_type:
|
||||
:return:
|
||||
"""
|
||||
|
||||
if not plex_item:
|
||||
plex_item = get_item(rating_key)
|
||||
|
||||
if not plex_item:
|
||||
return
|
||||
|
||||
# find current part
|
||||
current_part = get_part(plex_item, part_id)
|
||||
|
||||
if not current_part:
|
||||
raise helpers.PartUnknownException("Part unknown")
|
||||
|
||||
stream_info = get_plexapi_stream_info(plex_item, part_id)
|
||||
|
||||
if not stream_info:
|
||||
return
|
||||
|
||||
# get normalized metadata
|
||||
# fixme: duplicated logic of media_to_videos
|
||||
if item_type == "episode":
|
||||
show = list(Plex["library"].metadata(plex_item.show.rating_key))[0]
|
||||
year = show.year
|
||||
tvdb_id = None
|
||||
series_tvdb_id = None
|
||||
original_title = show.title_original
|
||||
if tvdb_guid_identifier in plex_item.guid:
|
||||
tvdb_id = plex_item.guid[len(tvdb_guid_identifier):].split("?")[0]
|
||||
series_tvdb_id = tvdb_id.split("/")[0]
|
||||
metadata = get_metadata_dict(plex_item, current_part,
|
||||
dict(stream_info,
|
||||
**{"plex_part": current_part, "type": "episode", "title": plex_item.title,
|
||||
"series": plex_item.show.title, "id": plex_item.rating_key,
|
||||
"series_id": plex_item.show.rating_key,
|
||||
"season_id": plex_item.season.rating_key,
|
||||
"imdb_id": None,
|
||||
"year": year,
|
||||
"tvdb_id": tvdb_id,
|
||||
"super_thumb": plex_item.show.thumb,
|
||||
"series_tvdb_id": series_tvdb_id,
|
||||
"original_title": original_title,
|
||||
"season": plex_item.season.index,
|
||||
"episode": plex_item.index
|
||||
})
|
||||
)
|
||||
else:
|
||||
imdb_id = None
|
||||
original_title = plex_item.title_original
|
||||
if imdb_guid_identifier in plex_item.guid:
|
||||
imdb_id = plex_item.guid[len(imdb_guid_identifier):].split("?")[0]
|
||||
metadata = get_metadata_dict(plex_item, current_part,
|
||||
dict(stream_info, **{"plex_part": current_part, "type": "movie",
|
||||
"title": plex_item.title, "id": plex_item.rating_key,
|
||||
"series_id": None,
|
||||
"season_id": None,
|
||||
"imdb_id": imdb_id,
|
||||
"year": plex_item.year,
|
||||
"tvdb_id": None,
|
||||
"super_thumb": plex_item.thumb,
|
||||
"series_tvdb_id": None,
|
||||
"original_title": original_title,
|
||||
"season": None,
|
||||
"episode": None,
|
||||
"section": plex_item.section.title})
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def get_blacklist_from_part_map(video_part_map, languages):
|
||||
from support.storage import get_subtitle_storage
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
blacklist = []
|
||||
for video, part in video_part_map.iteritems():
|
||||
stored_subs = subtitle_storage.load_or_new(video.plexapi_metadata["item"])
|
||||
for language in languages:
|
||||
current_bl, subs = stored_subs.get_blacklist(part.id, language)
|
||||
if not current_bl:
|
||||
continue
|
||||
|
||||
blacklist = blacklist + [(str(a), str(b)) for a, b in current_bl.keys()]
|
||||
|
||||
subtitle_storage.destroy()
|
||||
|
||||
return blacklist
|
||||
|
||||
|
||||
class PMSMediaProxy(object):
|
||||
"""
|
||||
Proxy object for getting data from a mediatree items "internally" via the PMS
|
||||
|
||||
note: this could be useful later on: Media.TV_Show(getattr(Metadata, "_access_point"), id=XXXXXX)
|
||||
"""
|
||||
|
||||
def __init__(self, media_id):
|
||||
self.mediatree = Media.TreeForDatabaseID(media_id)
|
||||
|
||||
def get_part(self, part_id=None):
|
||||
"""
|
||||
walk the mediatree until the given part was found; if no part was given, return the first one
|
||||
:param part_id:
|
||||
:return:
|
||||
"""
|
||||
m = self.mediatree
|
||||
while 1:
|
||||
if m.items:
|
||||
media_item = m.items[0]
|
||||
if not part_id:
|
||||
return media_item.parts[0] if media_item.parts else None
|
||||
|
||||
for part in media_item.parts:
|
||||
if str(part.id) == str(part_id):
|
||||
return part
|
||||
break
|
||||
|
||||
if not m.children:
|
||||
break
|
||||
|
||||
m = m.children[0]
|
||||
|
||||
def get_all_parts(self):
|
||||
"""
|
||||
walk the mediatree until the given part was found; if no part was given, return the first one
|
||||
:param part_id:
|
||||
:return:
|
||||
"""
|
||||
m = self.mediatree
|
||||
parts = []
|
||||
while 1:
|
||||
if m.items:
|
||||
media_item = m.items[0]
|
||||
for part in media_item.parts:
|
||||
parts.append(part)
|
||||
break
|
||||
|
||||
if not m.children:
|
||||
break
|
||||
|
||||
m = m.children[0]
|
||||
return parts
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# coding=utf-8
|
||||
import traceback
|
||||
import helpers
|
||||
from babelfish.exceptions import LanguageError
|
||||
|
||||
from support.lib import Plex, get_intent
|
||||
from support.plex_media import get_stream_fps, is_stream_forced, update_stream_info
|
||||
from support.storage import get_subtitle_storage
|
||||
from support.config import config, TEXT_SUBTITLE_EXTS
|
||||
from support.subtitlehelpers import get_subtitles_from_metadata
|
||||
from subzero.video import parse_video, set_existing_languages
|
||||
from subzero.language import language_from_stream, Language
|
||||
|
||||
|
||||
def prepare_video(pms_video_info, ignore_all=False, hints=None, rating_key=None, providers=None, skip_hashing=False):
|
||||
"""
|
||||
returnes a subliminal/guessit-refined parsed video
|
||||
:param pms_video_info:
|
||||
:param ignore_all:
|
||||
:param hints:
|
||||
:param rating_key:
|
||||
:return:
|
||||
"""
|
||||
embedded_subtitles = not ignore_all and Prefs['subtitles.scan.embedded']
|
||||
external_subtitles = not ignore_all and Prefs['subtitles.scan.external']
|
||||
|
||||
plex_part = pms_video_info["plex_part"]
|
||||
|
||||
if ignore_all:
|
||||
Log.Debug("Force refresh intended.")
|
||||
|
||||
Log.Debug("Detecting streams: %s, account_for_external_subtitles=%s, account_for_embedded_subtitles=%s" % (
|
||||
plex_part.file, external_subtitles, embedded_subtitles))
|
||||
|
||||
known_embedded = []
|
||||
parts = []
|
||||
for media in list(Plex["library"].metadata(rating_key))[0].media:
|
||||
parts += media.parts
|
||||
|
||||
plexpy_part = None
|
||||
for part in parts:
|
||||
if int(part.id) == int(plex_part.id):
|
||||
plexpy_part = part
|
||||
|
||||
# embedded subtitles
|
||||
# fixme: skip the whole scanning process if known_embedded == wanted languages?
|
||||
audio_languages = []
|
||||
if plexpy_part:
|
||||
update_stream_info(plexpy_part)
|
||||
for stream in plexpy_part.streams:
|
||||
if stream.stream_type == 2:
|
||||
lang = None
|
||||
try:
|
||||
lang = language_from_stream(stream.language_code)
|
||||
except LanguageError:
|
||||
Log.Info("Couldn't detect embedded audio stream language: %s", stream.language_code)
|
||||
|
||||
# treat unknown language as lang1?
|
||||
if not lang and config.treat_und_as_first:
|
||||
lang = Language.rebuild(list(config.lang_list)[0])
|
||||
Log.Info("Assuming language %s for audio stream: %s", lang, getattr(stream, "index", None))
|
||||
|
||||
audio_languages.append(lang)
|
||||
|
||||
# subtitle stream
|
||||
elif stream.stream_type == 3 and embedded_subtitles:
|
||||
is_forced = is_stream_forced(stream)
|
||||
|
||||
if ((config.forced_only or config.forced_also) and is_forced) or not is_forced:
|
||||
# embedded subtitle
|
||||
# fixme: tap into external subtitles here instead of scanning for ourselves later?
|
||||
if stream.codec and getattr(stream, "index", None):
|
||||
if config.exotic_ext or stream.codec.lower() in config.text_based_formats:
|
||||
lang = None
|
||||
try:
|
||||
lang = language_from_stream(stream.language_code)
|
||||
except LanguageError:
|
||||
Log.Info("Couldn't detect embedded subtitle stream language: %s", stream.language_code)
|
||||
|
||||
# treat unknown language as lang1?
|
||||
if not lang and config.treat_und_as_first:
|
||||
lang = Language.rebuild(list(config.lang_list)[0])
|
||||
Log.Info("Assuming language %s for subtitle stream: %s", lang,
|
||||
getattr(stream, "index", None))
|
||||
|
||||
if lang:
|
||||
if is_forced:
|
||||
lang.forced = True
|
||||
known_embedded.append(lang)
|
||||
else:
|
||||
Log.Warn("Part %s missing of %s, not able to scan internal streams", plex_part.id, rating_key)
|
||||
|
||||
# metadata subtitles
|
||||
known_metadata_subs = set()
|
||||
meta_subs = get_subtitles_from_metadata(plex_part)
|
||||
for language, subList in meta_subs.iteritems():
|
||||
try:
|
||||
lang = Language.fromietf(Locale.Language.Match(language))
|
||||
except LanguageError:
|
||||
if config.treat_und_as_first:
|
||||
lang = Language.rebuild(list(config.lang_list)[0])
|
||||
else:
|
||||
continue
|
||||
|
||||
if subList:
|
||||
for key in subList:
|
||||
if key.startswith("subzero_md_forced"):
|
||||
lang = Language.rebuild(lang, forced=True)
|
||||
|
||||
known_metadata_subs.add(lang)
|
||||
Log.Debug("Found metadata subtitle %r:%s for %s", lang, key, plex_part.file)
|
||||
|
||||
Log.Debug("Known metadata subtitles: %r", known_metadata_subs)
|
||||
Log.Debug("Known embedded subtitles: %r", known_embedded)
|
||||
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
stored_subs = subtitle_storage.load(rating_key)
|
||||
subtitle_storage.destroy()
|
||||
|
||||
try:
|
||||
# get basic video info scan (filename)
|
||||
video = parse_video(plex_part.file, hints, skip_hashing=config.low_impact_mode or skip_hashing,
|
||||
providers=providers)
|
||||
|
||||
# set stream languages
|
||||
if audio_languages:
|
||||
video.audio_languages = audio_languages
|
||||
Log.Info("Found audio streams: %s" % ", ".join([str(l) for l in audio_languages]))
|
||||
|
||||
if not ignore_all:
|
||||
set_existing_languages(video, pms_video_info, external_subtitles=external_subtitles,
|
||||
embedded_subtitles=embedded_subtitles, known_embedded=known_embedded,
|
||||
stored_subs=stored_subs, languages=config.lang_list,
|
||||
only_one=config.only_one, known_metadata_subs=known_metadata_subs,
|
||||
match_strictness=config.ext_match_strictness)
|
||||
|
||||
# add video fps info
|
||||
video.fps = plex_part.fps
|
||||
return video
|
||||
|
||||
except ValueError:
|
||||
Log.Warn("File could not be guessed: %s: %s", plex_part.file, traceback.format_exc())
|
||||
|
||||
|
||||
def scan_videos(videos, ignore_all=False, providers=None, skip_hashing=False):
|
||||
"""
|
||||
receives a list of videos containing dictionaries returned by media_to_videos
|
||||
:param videos:
|
||||
:param kind: series or movies
|
||||
:return: dictionary of subliminal.video.scan_video, key=subliminal scanned video, value=plex file part
|
||||
"""
|
||||
ret = {}
|
||||
for video in videos:
|
||||
intent = get_intent()
|
||||
force_refresh = intent.get("force", video["id"], video["series_id"], video["season_id"])
|
||||
Log.Debug("Determining force-refresh (video: %s, series: %s, season: %s), result: %s"
|
||||
% (video["id"], video["series_id"], video["season_id"], force_refresh))
|
||||
|
||||
hints = helpers.get_item_hints(video)
|
||||
video["plex_part"].fps = get_stream_fps(video["plex_part"].streams)
|
||||
p = providers or config.get_providers(media_type="series" if video["type"] == "episode" else "movies")
|
||||
scanned_video = prepare_video(video, ignore_all=force_refresh or ignore_all, hints=hints,
|
||||
rating_key=video["id"], providers=p,
|
||||
skip_hashing=skip_hashing)
|
||||
|
||||
if not scanned_video:
|
||||
continue
|
||||
|
||||
scanned_video.id = video["id"]
|
||||
part_metadata = video.copy()
|
||||
del part_metadata["plex_part"]
|
||||
scanned_video.plexapi_metadata = part_metadata
|
||||
scanned_video.ignore_all = force_refresh
|
||||
ret[scanned_video] = video["plex_part"]
|
||||
return ret
|
||||
@@ -0,0 +1,233 @@
|
||||
# coding=utf-8
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from config import config
|
||||
|
||||
def parse_frequency(s):
|
||||
if s == "never" or s is None:
|
||||
return None, None
|
||||
kind, num, unit = s.split()
|
||||
return int(num), unit
|
||||
|
||||
|
||||
class DefaultScheduler(object):
|
||||
queue_thread = None
|
||||
scheduler_thread = None
|
||||
running = False
|
||||
registry = None
|
||||
|
||||
def __init__(self):
|
||||
self.queue_thread = None
|
||||
self.scheduler_thread = None
|
||||
self.running = False
|
||||
self.registry = []
|
||||
|
||||
self.tasks = {}
|
||||
self.init_storage()
|
||||
|
||||
def init_storage(self):
|
||||
if "tasks" not in Dict:
|
||||
Dict["tasks"] = {"queue": []}
|
||||
Dict.Save()
|
||||
|
||||
if "queue" not in Dict["tasks"]:
|
||||
Dict["tasks"]["queue"] = []
|
||||
|
||||
def get_task_data(self, name):
|
||||
if name not in Dict["tasks"]:
|
||||
raise NotImplementedError("Task missing! %s" % name)
|
||||
|
||||
if "data" in Dict["tasks"][name]:
|
||||
return Dict["tasks"][name]["data"]
|
||||
|
||||
def clear_task_data(self, name=None):
|
||||
if name is None:
|
||||
# full clean
|
||||
Log.Debug("Clearing previous task data")
|
||||
if Dict["tasks"]:
|
||||
for task_name in Dict["tasks"].keys():
|
||||
if task_name == "queue":
|
||||
Dict["tasks"][task_name] = []
|
||||
continue
|
||||
|
||||
Dict["tasks"][task_name]["data"] = {}
|
||||
Dict["tasks"][task_name]["running"] = False
|
||||
Dict.Save()
|
||||
return
|
||||
|
||||
if name not in Dict["tasks"]:
|
||||
raise NotImplementedError("Task missing! %s" % name)
|
||||
|
||||
Dict["tasks"][name]["data"] = {}
|
||||
Dict["tasks"][name]["running"] = False
|
||||
Dict.Save()
|
||||
Log.Debug("Task data cleared: %s", name)
|
||||
|
||||
def register(self, task):
|
||||
self.registry.append(task)
|
||||
|
||||
def setup_tasks(self):
|
||||
# discover tasks;
|
||||
self.tasks = {}
|
||||
for cls in self.registry:
|
||||
task = cls()
|
||||
try:
|
||||
task_frequency = Prefs["scheduler.tasks.%s.frequency" % task.name]
|
||||
except KeyError:
|
||||
task_frequency = getattr(task, "frequency", None)
|
||||
|
||||
self.tasks[task.name] = {"task": task, "frequency": parse_frequency(task_frequency)}
|
||||
|
||||
def run(self):
|
||||
self.running = True
|
||||
self.scheduler_thread = Thread.Create(self.scheduler_worker)
|
||||
self.queue_thread = Thread.Create(self.queue_worker)
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
def task(self, name):
|
||||
if name not in self.tasks:
|
||||
return None
|
||||
return self.tasks[name]["task"]
|
||||
|
||||
def is_task_running(self, name):
|
||||
task = self.task(name)
|
||||
if task:
|
||||
return task.running
|
||||
|
||||
def last_run(self, task):
|
||||
if task not in self.tasks:
|
||||
return None
|
||||
return self.tasks[task]["task"].last_run
|
||||
|
||||
def next_run(self, task):
|
||||
if task not in self.tasks or not self.tasks[task]["task"].periodic:
|
||||
return None
|
||||
frequency_num, frequency_key = self.tasks[task]["frequency"]
|
||||
if not frequency_num:
|
||||
return None
|
||||
last = self.tasks[task]["task"].last_run
|
||||
use_date = last
|
||||
now = datetime.datetime.now()
|
||||
if not use_date:
|
||||
use_date = now
|
||||
return max(use_date + datetime.timedelta(**{frequency_key: frequency_num}), now)
|
||||
|
||||
def run_task(self, name, *args, **kwargs):
|
||||
task = self.tasks[name]["task"]
|
||||
|
||||
if task.running:
|
||||
Log.Debug("Scheduler: Not running %s, as it's currently running.", name)
|
||||
return False
|
||||
|
||||
Log.Debug("Scheduler: Running task %s", name)
|
||||
try:
|
||||
task.prepare(*args, **kwargs)
|
||||
task.run()
|
||||
except Exception, e:
|
||||
Log.Error("Scheduler: Something went wrong when running %s: %s", name, traceback.format_exc())
|
||||
finally:
|
||||
try:
|
||||
task.post_run(Dict["tasks"][name]["data"])
|
||||
except:
|
||||
Log.Error("Scheduler: task.post_run failed for %s: %s", name, traceback.format_exc())
|
||||
Dict.Save()
|
||||
config.sync_cache()
|
||||
|
||||
def dispatch_task(self, *args, **kwargs):
|
||||
if "queue" not in Dict["tasks"]:
|
||||
Dict["tasks"]["queue"] = []
|
||||
|
||||
Dict["tasks"]["queue"].append((args, kwargs))
|
||||
|
||||
def signal(self, name, *args, **kwargs):
|
||||
for task_name in self.tasks.keys():
|
||||
task = self.task(task_name)
|
||||
if not task:
|
||||
Log.Error("Scheduler: Task %s not found (?!)" % task_name)
|
||||
continue
|
||||
|
||||
if not task.periodic:
|
||||
continue
|
||||
|
||||
if task.running:
|
||||
Log.Debug("Scheduler: Sending signal %s to task %s (%s, %s)", name, task_name, args, kwargs)
|
||||
try:
|
||||
status = task.signal(name, *args, **kwargs)
|
||||
except NotImplementedError:
|
||||
Log.Debug("Scheduler: Signal ignored by %s", task_name)
|
||||
continue
|
||||
if status:
|
||||
Log.Debug("Scheduler: Signal accepted by %s", task_name)
|
||||
else:
|
||||
Log.Debug("Scheduler: Signal not accepted by %s", task_name)
|
||||
continue
|
||||
Log.Debug("Scheduler: Not sending signal %s to task %s, because: not running", name, task_name)
|
||||
|
||||
def queue_worker(self):
|
||||
Thread.Sleep(10.0)
|
||||
while 1:
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
# single dispatch requested?
|
||||
if Dict["tasks"]["queue"]:
|
||||
# work queue off
|
||||
queue = Dict["tasks"]["queue"][:]
|
||||
Dict["tasks"]["queue"] = []
|
||||
Dict.Save()
|
||||
for args, kwargs in queue:
|
||||
Log.Debug("Queue: Dispatching single task: %s, %s", args, kwargs)
|
||||
Thread.Create(self.run_task, True, *args, **kwargs)
|
||||
Thread.Sleep(5.0)
|
||||
|
||||
Thread.Sleep(1)
|
||||
|
||||
def scheduler_worker(self):
|
||||
Thread.Sleep(10.0)
|
||||
while 1:
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
# scheduled tasks
|
||||
for name in self.tasks.keys():
|
||||
now = datetime.datetime.now()
|
||||
info = self.tasks.get(name)
|
||||
if not info:
|
||||
Log.Error("Scheduler: Task %s not found (?!)" % name)
|
||||
continue
|
||||
task = info["task"]
|
||||
|
||||
if name not in Dict["tasks"] or not task.periodic:
|
||||
continue
|
||||
|
||||
if task.running:
|
||||
continue
|
||||
|
||||
frequency_num, frequency_key = info["frequency"]
|
||||
if not frequency_num:
|
||||
continue
|
||||
|
||||
# run legacy SARAM once
|
||||
if name == "SearchAllRecentlyAddedMissing" and ("hasRunLSARAM" not in Dict or not Dict["hasRunLSARAM"]):
|
||||
task = self.tasks["LegacySearchAllRecentlyAddedMissing"]["task"]
|
||||
task.last_run = None
|
||||
name = "LegacySearchAllRecentlyAddedMissing"
|
||||
Dict["hasRunLSARAM"] = True
|
||||
Dict.Save()
|
||||
|
||||
if not task.last_run or (task.last_run + datetime.timedelta(**{frequency_key: frequency_num}) <= now):
|
||||
# fixme: scheduled tasks run synchronously. is this the best idea?
|
||||
Thread.Create(self.run_task, True, name)
|
||||
#Thread.Sleep(5.0)
|
||||
#self.run_task(name)
|
||||
Thread.Sleep(5.0)
|
||||
|
||||
Thread.Sleep(1)
|
||||
|
||||
|
||||
scheduler = DefaultScheduler()
|
||||
@@ -0,0 +1,255 @@
|
||||
# coding=utf-8
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import pprint
|
||||
import copy
|
||||
import traceback
|
||||
import types
|
||||
|
||||
from subliminal_patch.core import save_subtitles as subliminal_save_subtitles
|
||||
from subzero.subtitle_storage import StoredSubtitlesManager
|
||||
from subzero.lib.io import FileIO
|
||||
|
||||
from subtitlehelpers import force_utf8
|
||||
from config import config
|
||||
from helpers import notify_executable, get_title_for_video_metadata, cast_bool, force_unicode
|
||||
from plex_media import PMSMediaProxy
|
||||
from support.items import get_item
|
||||
|
||||
|
||||
def get_subtitle_storage():
|
||||
return StoredSubtitlesManager(Data, Thread, get_item)
|
||||
|
||||
|
||||
def store_subtitle_info(scanned_video_part_map, downloaded_subtitles, storage_type, mode="a", set_current=True):
|
||||
"""
|
||||
stores information about downloaded subtitles in plex's Dict()
|
||||
"""
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
for video, video_subtitles in downloaded_subtitles.items():
|
||||
part = scanned_video_part_map[video]
|
||||
part_id = str(part.id)
|
||||
video_id = str(video.id)
|
||||
plex_item = get_item(video_id)
|
||||
if not plex_item:
|
||||
Log.Warn("Plex item not found: %s", video_id)
|
||||
continue
|
||||
|
||||
metadata = video.plexapi_metadata
|
||||
title = get_title_for_video_metadata(metadata)
|
||||
|
||||
stored_subs = subtitle_storage.load(video_id)
|
||||
is_new = False
|
||||
if not stored_subs:
|
||||
is_new = True
|
||||
Log.Debug(u"Creating new subtitle storage: %s, %s", video_id, part_id)
|
||||
stored_subs = subtitle_storage.new(plex_item)
|
||||
|
||||
for subtitle in video_subtitles:
|
||||
lang = str(subtitle.language)
|
||||
subtitle.normalize()
|
||||
Log.Debug(u"Adding subtitle to storage: %s, %s, %s, %s, %s" % (video_id, part_id, lang, title,
|
||||
subtitle.guess_encoding()))
|
||||
|
||||
last_mod = None
|
||||
if subtitle.storage_path:
|
||||
last_mod = datetime.datetime.fromtimestamp(os.path.getmtime(subtitle.storage_path))
|
||||
|
||||
ret_val = stored_subs.add(part_id, lang, subtitle, storage_type, mode=mode, last_mod=last_mod,
|
||||
set_current=set_current)
|
||||
|
||||
if ret_val:
|
||||
Log.Debug("Subtitle stored")
|
||||
|
||||
else:
|
||||
Log.Debug("Subtitle already existing in storage")
|
||||
|
||||
if is_new or video_subtitles:
|
||||
Log.Debug("Saving subtitle storage for %s" % video_id)
|
||||
subtitle_storage.save(stored_subs)
|
||||
|
||||
subtitle_storage.destroy()
|
||||
|
||||
|
||||
def reset_storage(key):
|
||||
"""
|
||||
resets the Dict[key] storage, thanks to https://docs.google.com/document/d/1hhLjV1pI-TA5y91TiJq64BdgKwdLnFt4hWgeOqpz1NA/edit#
|
||||
We can't use the nice Plex interface for this, as it calls get multiple times before set
|
||||
#Plex[":/plugins/*/prefs"].set("com.plexapp.agents.subzero", "reset_storage", False)
|
||||
"""
|
||||
|
||||
Log.Debug("resetting storage")
|
||||
Dict[key] = {}
|
||||
Dict.Save()
|
||||
|
||||
|
||||
def log_storage(key):
|
||||
if not key:
|
||||
Log.Debug(pprint.pformat(getattr(Dict, "_dict")))
|
||||
if key in Dict:
|
||||
Log.Debug(pprint.pformat(Dict[key]))
|
||||
|
||||
|
||||
def get_target_folder(file_path):
|
||||
fld = None
|
||||
fld_custom = Prefs["subtitles.save.subFolder.Custom"].strip() \
|
||||
if Prefs["subtitles.save.subFolder.Custom"] else None
|
||||
|
||||
if fld_custom or Prefs["subtitles.save.subFolder"] != "current folder":
|
||||
# specific subFolder requested, create it if it doesn't exist
|
||||
fld_base = os.path.split(file_path)[0]
|
||||
if fld_custom:
|
||||
if fld_custom.startswith("/"):
|
||||
# absolute folder
|
||||
fld = fld_custom
|
||||
else:
|
||||
fld = os.path.join(fld_base, fld_custom)
|
||||
else:
|
||||
fld = os.path.join(fld_base, Prefs["subtitles.save.subFolder"])
|
||||
fld = force_unicode(fld)
|
||||
if not os.path.exists(fld):
|
||||
os.makedirs(fld)
|
||||
return fld
|
||||
|
||||
|
||||
def save_subtitles_to_file(subtitles, tags=None):
|
||||
for video, video_subtitles in subtitles.items():
|
||||
if not video_subtitles:
|
||||
continue
|
||||
|
||||
if not isinstance(video, types.StringTypes):
|
||||
file_path = video.name
|
||||
else:
|
||||
file_path = video
|
||||
|
||||
fld = get_target_folder(file_path)
|
||||
subliminal_save_subtitles(file_path, video_subtitles, directory=fld, single=cast_bool(Prefs['subtitles.only_one']),
|
||||
chmod=config.chmod, path_decoder=force_unicode,
|
||||
debug_mods=config.debug_mods, formats=config.subtitle_formats, tags=tags)
|
||||
return True
|
||||
|
||||
|
||||
def save_subtitles_to_metadata(videos, subtitles):
|
||||
for video, video_subtitles in subtitles.items():
|
||||
mediaPart = videos[video]
|
||||
for subtitle in video_subtitles:
|
||||
content = subtitle.get_modified_content(debug=config.debug_mods)
|
||||
|
||||
if not isinstance(mediaPart, Framework.api.agentkit.MediaPart):
|
||||
# we're being handed a Plex.py model instance here, not an internal PMS MediaPart object.
|
||||
# get the correct one
|
||||
mp = PMSMediaProxy(video.id).get_part(mediaPart.id)
|
||||
else:
|
||||
mp = mediaPart
|
||||
pm = Proxy.Media(content, ext="srt", forced="1" if subtitle.language.forced else None)
|
||||
new_key = "subzero_md" + ("_forced" if subtitle.language.forced else "")
|
||||
lang = Locale.Language.Match(subtitle.language.alpha2)
|
||||
|
||||
for key, proxy in getattr(mp.subtitles[lang], "_proxies").iteritems():
|
||||
if not proxy or not len(proxy) >= 5:
|
||||
Log.Debug("Can't parse metadata: %s" % repr(proxy))
|
||||
continue
|
||||
if proxy[0] == "Media":
|
||||
if not key.startswith("subzero_"):
|
||||
if key == "subzero":
|
||||
Log.Debug("Removing legacy metadata subtitle for %s", lang)
|
||||
del mp.subtitles[lang][key]
|
||||
Log.Debug("Existing metadata subtitle for %s: %s", lang, key)
|
||||
|
||||
Log.Debug("Adding metadata sub for %s: %s", lang, subtitle)
|
||||
mp.subtitles[lang][new_key] = pm
|
||||
return True
|
||||
|
||||
|
||||
def save_subtitles(scanned_video_part_map, downloaded_subtitles, mode="a", bare_save=False, mods=None,
|
||||
set_current=True):
|
||||
"""
|
||||
|
||||
:param set_current: save the subtitle as the current one
|
||||
:param scanned_video_part_map:
|
||||
:param downloaded_subtitles:
|
||||
:param mode:
|
||||
:param bare_save: don't trigger anything; don't store information
|
||||
:param mods: enabled mods
|
||||
:return:
|
||||
"""
|
||||
meta_fallback = False
|
||||
save_successful = False
|
||||
|
||||
# big fixme: scanned_video_part_map isn't needed to the current extent. rewrite.
|
||||
|
||||
if mods:
|
||||
for video, video_subtitles in downloaded_subtitles.items():
|
||||
if not video_subtitles:
|
||||
continue
|
||||
|
||||
for subtitle in video_subtitles:
|
||||
Log.Info("Applying mods: %s to %s", mods, subtitle)
|
||||
subtitle.mods = mods
|
||||
subtitle.plex_media_fps = video.fps
|
||||
|
||||
storage = "metadata"
|
||||
save_to_fs = cast_bool(Prefs['subtitles.save.filesystem'])
|
||||
if save_to_fs:
|
||||
storage = "filesystem"
|
||||
|
||||
if set_current:
|
||||
if save_to_fs:
|
||||
try:
|
||||
Log.Debug("Using filesystem as subtitle storage")
|
||||
save_subtitles_to_file(downloaded_subtitles)
|
||||
except OSError:
|
||||
if cast_bool(Prefs["subtitles.save.metadata_fallback"]):
|
||||
meta_fallback = True
|
||||
storage = "metadata"
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
save_successful = True
|
||||
|
||||
if not save_to_fs or meta_fallback:
|
||||
if meta_fallback:
|
||||
Log.Debug("Using metadata as subtitle storage, because filesystem storage failed")
|
||||
else:
|
||||
Log.Debug("Using metadata as subtitle storage")
|
||||
save_successful = save_subtitles_to_metadata(scanned_video_part_map, downloaded_subtitles)
|
||||
|
||||
if not bare_save and save_successful and config.notify_executable:
|
||||
notify_executable(config.notify_executable, scanned_video_part_map, downloaded_subtitles, storage)
|
||||
|
||||
if (not bare_save and save_successful) or not set_current:
|
||||
store_subtitle_info(scanned_video_part_map, downloaded_subtitles, storage, mode=mode, set_current=set_current)
|
||||
|
||||
return save_successful
|
||||
|
||||
|
||||
def get_pack_id(subtitle):
|
||||
return "%s_%s" % (subtitle.provider_name, subtitle.numeric_id)
|
||||
|
||||
|
||||
def get_pack_data(subtitle):
|
||||
subtitle_id = get_pack_id(subtitle)
|
||||
|
||||
archive = os.path.join(config.pack_cache_dir, subtitle_id + ".archive")
|
||||
if os.path.isfile(archive):
|
||||
Log.Info("Loading archive from pack cache: %s", subtitle_id)
|
||||
try:
|
||||
data = FileIO.read(archive, 'rb')
|
||||
|
||||
return data
|
||||
except:
|
||||
Log.Error("Couldn't load archive from pack cache: %s: %s", subtitle_id, traceback.format_exc())
|
||||
|
||||
|
||||
def store_pack_data(subtitle, data):
|
||||
subtitle_id = get_pack_id(subtitle)
|
||||
|
||||
archive = os.path.join(config.pack_cache_dir, subtitle_id + ".archive")
|
||||
|
||||
Log.Info("Storing archive in pack cache: %s", subtitle_id)
|
||||
try:
|
||||
FileIO.write(archive, data, 'wb')
|
||||
|
||||
except:
|
||||
Log.Error("Couldn't store archive in pack cache: %s: %s", subtitle_id, traceback.format_exc())
|
||||
@@ -0,0 +1,207 @@
|
||||
# coding=utf-8
|
||||
|
||||
import re, os
|
||||
import helpers
|
||||
|
||||
from config import config, SUBTITLE_EXTS, TEXT_SUBTITLE_EXTS
|
||||
from bs4 import UnicodeDammit
|
||||
from subzero.language import match_ietf_language
|
||||
|
||||
|
||||
class SubtitleHelper(object):
|
||||
def __init__(self, filename):
|
||||
self.filename = filename
|
||||
|
||||
|
||||
def subtitle_helpers(filename):
|
||||
filename = helpers.unicodize(filename)
|
||||
helper_classes = [DefaultSubtitleHelper]
|
||||
|
||||
if helpers.cast_bool(Prefs["subtitles.scan.exotic_ext"]):
|
||||
helper_classes.insert(0, VobSubSubtitleHelper)
|
||||
|
||||
for cls in helper_classes:
|
||||
if cls.is_helper_for(filename):
|
||||
return cls(filename)
|
||||
return None
|
||||
|
||||
|
||||
#####################################################################################################################
|
||||
|
||||
class VobSubSubtitleHelper(SubtitleHelper):
|
||||
@classmethod
|
||||
def is_helper_for(cls, filename):
|
||||
(file, file_extension) = os.path.splitext(filename)
|
||||
|
||||
# We only support idx (and maybe sub)
|
||||
if not file_extension.lower() in ['.idx', '.sub']:
|
||||
return False
|
||||
|
||||
# If we've been given a sub, we only support it if there exists a matching idx file
|
||||
return os.path.exists(file + '.idx')
|
||||
|
||||
def process_subtitles(self, part):
|
||||
|
||||
lang_sub_map = {}
|
||||
|
||||
# We don't directly process the sub file, only the idx. Therefore if we are passed on of these files, we simply
|
||||
# ignore it.
|
||||
(file, ext) = os.path.splitext(self.filename)
|
||||
if ext == '.sub':
|
||||
return lang_sub_map
|
||||
|
||||
# If we have an idx file, we need to confirm there is an identically names sub file before we can proceed.
|
||||
sub_filename = file + ".sub"
|
||||
if not os.path.exists(sub_filename):
|
||||
return lang_sub_map
|
||||
|
||||
Log('Attempting to parse VobSub file: ' + self.filename)
|
||||
idx = Core.storage.load(os.path.join(self.filename))
|
||||
if idx.count('VobSub index file') == 0:
|
||||
Log('The idx file does not appear to be a VobSub, skipping...')
|
||||
return lang_sub_map
|
||||
|
||||
languages = {}
|
||||
language_index = 0
|
||||
basename = os.path.basename(self.filename)
|
||||
for language in re.findall('\nid: ([A-Za-z]{2})', idx):
|
||||
|
||||
if not languages.has_key(language):
|
||||
languages[language] = []
|
||||
|
||||
Log('Found .idx subtitle file: ' + self.filename + ' language: ' + language + ' stream index: ' + str(language_index))
|
||||
languages[language].append(Proxy.LocalFile(self.filename, index=str(language_index), format="vobsub"))
|
||||
language_index += 1
|
||||
|
||||
if not lang_sub_map.has_key(language):
|
||||
lang_sub_map[language] = []
|
||||
lang_sub_map[language].append(basename)
|
||||
|
||||
for language, subs in languages.items():
|
||||
part.subtitles[language][basename] = subs
|
||||
|
||||
return lang_sub_map
|
||||
|
||||
|
||||
#####################################################################################################################
|
||||
|
||||
|
||||
class DefaultSubtitleHelper(SubtitleHelper):
|
||||
@classmethod
|
||||
def is_helper_for(cls, filename):
|
||||
(file, file_extension) = os.path.splitext(filename)
|
||||
return file_extension.lower()[1:] in SUBTITLE_EXTS
|
||||
|
||||
def process_subtitles(self, part):
|
||||
|
||||
lang_sub_map = {}
|
||||
|
||||
if not os.path.exists(self.filename):
|
||||
return lang_sub_map
|
||||
|
||||
basename = os.path.basename(self.filename)
|
||||
(file, ext) = os.path.splitext(self.filename)
|
||||
|
||||
# Remove the initial '.' from the extension
|
||||
ext = ext[1:]
|
||||
|
||||
forced = ''
|
||||
default = ''
|
||||
split_tag = file.rsplit('.', 1)
|
||||
if len(split_tag) > 1 and split_tag[1].lower() in ['forced', 'normal', 'default', 'embedded', 'embedded-forced',
|
||||
'custom']:
|
||||
file = split_tag[0]
|
||||
sub_tag = split_tag[1].lower()
|
||||
# don't do anything with 'normal', we don't need it
|
||||
if 'forced' in sub_tag:
|
||||
forced = '1'
|
||||
elif 'default' == sub_tag:
|
||||
default = '1'
|
||||
|
||||
# Attempt to extract the language from the filename (e.g. Avatar (2009).eng)
|
||||
# IETF support thanks to
|
||||
# https://github.com/hpsbranco/LocalMedia.bundle/commit/4fad9aefedece78a1fa96401304351347f644369
|
||||
lang_part = match_ietf_language(file, ietf=helpers.cast_bool(Prefs["subtitles.language.ietf_display"]))
|
||||
if lang_part != file:
|
||||
language = Locale.Language.Match(lang_part)
|
||||
elif config.only_one:
|
||||
language = Locale.Language.Match(list(config.lang_list)[0].alpha2)
|
||||
else:
|
||||
language = Locale.Language.Match("xx")
|
||||
|
||||
# skip non-SRT if wanted
|
||||
if not config.exotic_ext and ext not in TEXT_SUBTITLE_EXTS:
|
||||
return lang_sub_map
|
||||
|
||||
codec = None
|
||||
format = None
|
||||
if ext in ['txt', 'sub']:
|
||||
try:
|
||||
|
||||
file_contents = Core.storage.load(self.filename)
|
||||
lines = [line.strip() for line in file_contents.splitlines(True)]
|
||||
if re.match('^\{[0-9]+\}\{[0-9]*\}', lines[1]):
|
||||
format = 'microdvd'
|
||||
elif re.match('^[0-9]{1,2}:[0-9]{2}:[0-9]{2}[:=,]', lines[1]):
|
||||
format = 'txt'
|
||||
elif '[SUBTITLE]' in lines[1]:
|
||||
format = 'subviewer'
|
||||
else:
|
||||
Log("The subtitle file does not have a known format, skipping... : " + self.filename)
|
||||
return lang_sub_map
|
||||
except:
|
||||
Log("An error occurred while attempting to parse the subtitle file, skipping... : " + self.filename)
|
||||
return lang_sub_map
|
||||
|
||||
# fixme: re-add vtt once Plex Inc. fixes this line in LocalMedia.bundle
|
||||
if codec is None and ext in ['ass', 'ssa', 'smi', 'srt', 'psb']:
|
||||
codec = ext.replace('ass', 'ssa')
|
||||
|
||||
if format is None:
|
||||
format = codec
|
||||
|
||||
Log('Found subtitle file: ' + self.filename + ' language: ' + language + ' codec: ' + str(
|
||||
codec) + ' format: ' + str(format) + ' default: ' + default + ' forced: ' + forced)
|
||||
key = ("subzero_ex" + "_forced" if forced else "") + basename
|
||||
part.subtitles[language][key] = Proxy.LocalFile(self.filename, codec=codec, format=format, default=default,
|
||||
forced=forced)
|
||||
|
||||
lang_sub_map[language] = [key]
|
||||
return lang_sub_map
|
||||
|
||||
|
||||
def get_subtitles_from_metadata(part):
|
||||
subs = {}
|
||||
if hasattr(part, "subtitles") and part.subtitles:
|
||||
for language in part.subtitles:
|
||||
subs[language] = []
|
||||
for key, proxy in getattr(part.subtitles[language], "_proxies").iteritems():
|
||||
if not proxy or not len(proxy) >= 5:
|
||||
Log.Debug("Can't parse metadata: %s" % repr(proxy))
|
||||
continue
|
||||
|
||||
p_type = proxy[0]
|
||||
|
||||
if p_type == "Media":
|
||||
if not key.startswith("subzero"):
|
||||
continue
|
||||
|
||||
# metadata subtitle
|
||||
#Log.Debug(u"Found metadata subtitle: %s, %s, %s" % (language, key, repr(proxy)))
|
||||
subs[language].append(key)
|
||||
return subs
|
||||
|
||||
|
||||
def force_utf8(content):
|
||||
a = UnicodeDammit(content)
|
||||
|
||||
if a.original_encoding:
|
||||
Log.Debug("detected encoding: %s (None: most likely already successfully decoded)" % a.original_encoding)
|
||||
else:
|
||||
Log.Debug("detected encoding: unicode (already decoded)")
|
||||
|
||||
# easy way out - already utf-8
|
||||
if a.original_encoding and a.original_encoding == "utf-8":
|
||||
return content
|
||||
|
||||
return (a.unicode_markup if a.unicode_markup else content.decode('ascii', 'replace')).encode("utf-8")
|
||||
Executable
+944
@@ -0,0 +1,944 @@
|
||||
# coding=utf-8
|
||||
import glob
|
||||
import os
|
||||
import datetime
|
||||
import operator
|
||||
import traceback
|
||||
from urllib2 import URLError
|
||||
|
||||
from subliminal_patch.score import compute_score
|
||||
from subliminal_patch.core import download_subtitles
|
||||
from subliminal import list_subtitles as list_all_subtitles, region as subliminal_cache_region
|
||||
from subzero.language import Language
|
||||
from subzero.video import refine_video
|
||||
|
||||
from missing_subtitles import items_get_all_missing_subs, refresh_item
|
||||
from scheduler import scheduler
|
||||
from storage import save_subtitles, get_subtitle_storage
|
||||
from support.config import config
|
||||
from support.items import get_recent_items, get_item, is_wanted, get_item_title
|
||||
from support.helpers import track_usage, get_title_for_video_metadata, cast_bool, PartUnknownException
|
||||
from support.plex_media import get_plex_metadata
|
||||
from support.extract import agent_extract_embedded
|
||||
from support.scanning import scan_videos
|
||||
from support.i18n import _
|
||||
from download import download_best_subtitles, pre_download_hook, post_download_hook, language_hook
|
||||
|
||||
|
||||
class Task(object):
|
||||
name = None
|
||||
scheduler = None
|
||||
periodic = False
|
||||
running = False
|
||||
time_start = None
|
||||
data = None
|
||||
|
||||
PROVIDER_SLACK = 30
|
||||
DL_PROVIDER_SLACK = 30
|
||||
|
||||
stored_attributes = ("last_run", "last_run_time", "running")
|
||||
default_data = {"last_run": None, "last_run_time": None, "running": False, "data": {}}
|
||||
|
||||
# task ready for being status-displayed?
|
||||
ready_for_display = False
|
||||
|
||||
def __init__(self):
|
||||
self.name = self.get_class_name()
|
||||
self.ready_for_display = False
|
||||
self.time_start = None
|
||||
self.setup_defaults()
|
||||
|
||||
self.running = False
|
||||
|
||||
def get_class_name(self):
|
||||
return getattr(getattr(self, "__class__"), "__name__")
|
||||
|
||||
def __getattribute__(self, name):
|
||||
if name in object.__getattribute__(self, "stored_attributes"):
|
||||
return Dict["tasks"].get(self.name, {}).get(name, None)
|
||||
|
||||
return object.__getattribute__(self, name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name in object.__getattribute__(self, "stored_attributes"):
|
||||
Dict["tasks"][self.name][name] = value
|
||||
Dict.Save()
|
||||
return
|
||||
|
||||
object.__setattr__(self, name, value)
|
||||
|
||||
def setup_defaults(self):
|
||||
if self.name not in Dict["tasks"]:
|
||||
Dict["tasks"][self.name] = self.default_data.copy()
|
||||
return
|
||||
|
||||
sd = Dict["tasks"][self.name]
|
||||
|
||||
# forward-migration
|
||||
for key, def_value in self.default_data.iteritems():
|
||||
hasval = key in sd
|
||||
if not hasval:
|
||||
sd[key] = def_value
|
||||
|
||||
def signal(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def prepare(self, *args, **kwargs):
|
||||
return
|
||||
|
||||
def run(self):
|
||||
Log.Info(u"Task: running: %s", self.name)
|
||||
self.time_start = datetime.datetime.now()
|
||||
|
||||
def post_run(self, data_holder):
|
||||
self.running = False
|
||||
self.last_run = datetime.datetime.now()
|
||||
if self.time_start and self.last_run:
|
||||
self.last_run_time = self.last_run - self.time_start
|
||||
self.time_start = None
|
||||
Log.Info(u"Task: ran: %s", self.name)
|
||||
|
||||
|
||||
class SubtitleListingMixin(object):
|
||||
def list_subtitles(self, rating_key, item_type, part_id, language, skip_wrong_fps=True, metadata=None,
|
||||
scanned_parts=None, air_date_cutoff=None):
|
||||
|
||||
if not metadata:
|
||||
metadata = get_plex_metadata(rating_key, part_id, item_type)
|
||||
|
||||
if not metadata:
|
||||
return
|
||||
|
||||
providers = config.get_providers(media_type="series" if item_type == "episode" else "movies")
|
||||
if not scanned_parts:
|
||||
scanned_parts = scan_videos([metadata], ignore_all=True, providers=providers)
|
||||
if not scanned_parts:
|
||||
Log.Error(u"%s: Couldn't list available subtitles for %s", self.name, rating_key)
|
||||
return
|
||||
|
||||
video, plex_part = scanned_parts.items()[0]
|
||||
refine_video(video, refiner_settings=config.refiner_settings)
|
||||
|
||||
if air_date_cutoff is not None and metadata["item"].year and \
|
||||
metadata["item"].year + air_date_cutoff < datetime.date.today().year:
|
||||
Log.Debug("Skipping searching for subtitles: %s, it aired over %s year(s) ago.", rating_key,
|
||||
air_date_cutoff)
|
||||
return
|
||||
|
||||
config.init_subliminal_patches()
|
||||
|
||||
provider_settings = config.provider_settings
|
||||
if not skip_wrong_fps:
|
||||
provider_settings["opensubtitles"]["skip_wrong_fps"] = False
|
||||
|
||||
if item_type == "episode":
|
||||
min_score = 240
|
||||
if video.is_special:
|
||||
min_score = 180
|
||||
else:
|
||||
min_score = 60
|
||||
|
||||
languages = {Language.fromietf(language)}
|
||||
|
||||
available_subs = list_all_subtitles([video], languages,
|
||||
providers=providers,
|
||||
provider_configs=provider_settings,
|
||||
pool_class=config.provider_pool,
|
||||
throttle_callback=config.provider_throttle,
|
||||
language_hook=language_hook)
|
||||
|
||||
use_hearing_impaired = Prefs['subtitles.search.hearingImpaired'] in ("prefer", "force HI")
|
||||
|
||||
# sort subtitles by score
|
||||
unsorted_subtitles = []
|
||||
for s in available_subs[video]:
|
||||
Log.Debug(u"%s: Starting score computation for %s", self.name, s)
|
||||
try:
|
||||
matches = s.get_matches(video)
|
||||
except AttributeError:
|
||||
Log.Error(u"%s: Match computation failed for %s: %s", self.name, s, traceback.format_exc())
|
||||
continue
|
||||
|
||||
# skip wrong season/episodes
|
||||
if item_type == "episode":
|
||||
can_verify_series = True
|
||||
if not s.hash_verifiable and "hash" in matches:
|
||||
can_verify_series = False
|
||||
|
||||
if can_verify_series and not {"series", "season", "episode"}.issubset(matches):
|
||||
if "series" not in matches:
|
||||
s.wrong_series = True
|
||||
else:
|
||||
s.wrong_season_ep = True
|
||||
|
||||
orig_matches = matches.copy()
|
||||
score, score_without_hash = compute_score(matches, s, video, hearing_impaired=use_hearing_impaired)
|
||||
|
||||
unsorted_subtitles.append(
|
||||
(s, score, score_without_hash, matches, orig_matches))
|
||||
scored_subtitles = sorted(unsorted_subtitles, key=operator.itemgetter(1, 2), reverse=True)
|
||||
|
||||
subtitles = []
|
||||
for subtitle, score, score_without_hash, matches, orig_matches in scored_subtitles:
|
||||
# check score
|
||||
if score < min_score and not subtitle.wrong_series:
|
||||
Log.Info(u'%s: Score %d is below min_score (%d)', self.name, score, min_score)
|
||||
continue
|
||||
subtitle.score = score
|
||||
subtitle.matches = matches
|
||||
subtitle.part_id = part_id
|
||||
subtitle.item_type = item_type
|
||||
subtitles.append(subtitle)
|
||||
return subtitles
|
||||
|
||||
|
||||
class DownloadSubtitleMixin(object):
|
||||
def download_subtitle(self, subtitle, rating_key, mode="m"):
|
||||
from interface.menu_helpers import set_refresh_menu_state
|
||||
|
||||
item_type = subtitle.item_type
|
||||
part_id = subtitle.part_id
|
||||
metadata = get_plex_metadata(rating_key, part_id, item_type)
|
||||
providers = config.get_providers(media_type="series" if item_type == "episode" else "movies")
|
||||
scanned_parts = scan_videos([metadata], ignore_all=True, providers=providers)
|
||||
video, plex_part = scanned_parts.items()[0]
|
||||
|
||||
pre_download_hook(subtitle)
|
||||
|
||||
# downloaded_subtitles = {subliminal.Video: [subtitle, subtitle, ...]}
|
||||
download_subtitles([subtitle], providers=providers,
|
||||
provider_configs=config.provider_settings,
|
||||
pool_class=config.provider_pool, throttle_callback=config.provider_throttle)
|
||||
|
||||
post_download_hook(subtitle)
|
||||
|
||||
# may be redundant
|
||||
subtitle.pack_data = None
|
||||
|
||||
download_successful = False
|
||||
|
||||
if subtitle.content:
|
||||
try:
|
||||
save_subtitles(scanned_parts, {video: [subtitle]}, mode=mode, mods=config.default_mods)
|
||||
if mode == "m":
|
||||
Log.Debug(u"%s: Manually downloaded subtitle for: %s", self.name, rating_key)
|
||||
track_usage("Subtitle", "manual", "download", 1)
|
||||
elif mode == "b":
|
||||
Log.Debug(u"%s: Downloaded better subtitle for: %s", self.name, rating_key)
|
||||
track_usage("Subtitle", "better", "download", 1)
|
||||
download_successful = True
|
||||
refresh_item(rating_key)
|
||||
|
||||
except:
|
||||
Log.Error(u"%s: Something went wrong when downloading specific subtitle: %s",
|
||||
self.name, traceback.format_exc())
|
||||
finally:
|
||||
set_refresh_menu_state(None)
|
||||
|
||||
if download_successful:
|
||||
# store item in history
|
||||
from support.history import get_history
|
||||
item_title = get_title_for_video_metadata(metadata, add_section_title=False)
|
||||
history = get_history()
|
||||
history.add(item_title, video.id, section_title=video.plexapi_metadata["section"],
|
||||
thumb=video.plexapi_metadata["super_thumb"],
|
||||
subtitle=subtitle,
|
||||
mode=mode)
|
||||
history.destroy()
|
||||
|
||||
# clear missing subtitles menu data
|
||||
if not scheduler.is_task_running("MissingSubtitles"):
|
||||
scheduler.clear_task_data("MissingSubtitles")
|
||||
else:
|
||||
set_refresh_menu_state(_(u"%(class_name)s: Subtitle download failed (%(item_id)s)",
|
||||
class_name=self.name,
|
||||
item_id=rating_key))
|
||||
return download_successful
|
||||
|
||||
|
||||
class AvailableSubsForItem(SubtitleListingMixin, Task):
|
||||
item_type = None
|
||||
part_id = None
|
||||
language = None
|
||||
rating_key = None
|
||||
|
||||
def prepare(self, *args, **kwargs):
|
||||
self.item_type = kwargs.get("item_type")
|
||||
self.part_id = kwargs.get("part_id")
|
||||
self.language = kwargs.get("language")
|
||||
self.rating_key = kwargs.get("rating_key")
|
||||
|
||||
def setup_defaults(self):
|
||||
super(AvailableSubsForItem, self).setup_defaults()
|
||||
|
||||
# reset any previous data
|
||||
Dict["tasks"][self.name]["data"] = {}
|
||||
|
||||
def run(self):
|
||||
super(AvailableSubsForItem, self).run()
|
||||
self.running = True
|
||||
try:
|
||||
track_usage("Subtitle", "manual", "list", 1)
|
||||
except:
|
||||
Log.Error("Something went wrong with track_usage: %s", traceback.format_exc())
|
||||
|
||||
Log.Debug("Listing available subtitles for: %s", self.rating_key)
|
||||
subs = self.list_subtitles(self.rating_key, self.item_type, self.part_id, self.language, skip_wrong_fps=False)
|
||||
if not subs:
|
||||
self.data = "found_none"
|
||||
return
|
||||
|
||||
# we can't have nasty unpicklable stuff like ZipFile, BytesIO etc in self.data
|
||||
self.data = [s.make_picklable() for s in subs]
|
||||
|
||||
def post_run(self, task_data):
|
||||
super(AvailableSubsForItem, self).post_run(task_data)
|
||||
# clean old data
|
||||
for key in task_data.keys():
|
||||
if key != self.rating_key:
|
||||
del task_data[key]
|
||||
task_data.update({self.rating_key: {self.language: self.data}})
|
||||
|
||||
|
||||
class DownloadSubtitleForItem(DownloadSubtitleMixin, Task):
|
||||
subtitle = None
|
||||
rating_key = None
|
||||
|
||||
def prepare(self, *args, **kwargs):
|
||||
self.subtitle = kwargs["subtitle"]
|
||||
self.rating_key = kwargs["rating_key"]
|
||||
|
||||
def run(self):
|
||||
super(DownloadSubtitleForItem, self).run()
|
||||
self.running = True
|
||||
self.download_subtitle(self.subtitle, self.rating_key)
|
||||
self.running = False
|
||||
|
||||
|
||||
class MissingSubtitles(Task):
|
||||
rating_key = None
|
||||
item_type = None
|
||||
part_id = None
|
||||
language = None
|
||||
|
||||
def run(self):
|
||||
super(MissingSubtitles, self).run()
|
||||
self.running = True
|
||||
self.data = []
|
||||
recent_items = get_recent_items()
|
||||
if recent_items:
|
||||
self.data = items_get_all_missing_subs(recent_items)
|
||||
|
||||
def post_run(self, task_data):
|
||||
super(MissingSubtitles, self).post_run(task_data)
|
||||
task_data["missing_subtitles"] = self.data
|
||||
|
||||
|
||||
class SearchAllRecentlyAddedMissing(Task):
|
||||
periodic = True
|
||||
|
||||
items_done = None
|
||||
items_searching = None
|
||||
percentage = 0
|
||||
|
||||
def __init__(self):
|
||||
super(SearchAllRecentlyAddedMissing, self).__init__()
|
||||
self.items_done = None
|
||||
self.items_searching = None
|
||||
self.percentage = 0
|
||||
|
||||
def signal_updated_metadata(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
def prepare(self):
|
||||
self.items_done = 0
|
||||
self.items_searching = 0
|
||||
self.percentage = 0
|
||||
self.ready_for_display = True
|
||||
|
||||
def run(self):
|
||||
super(SearchAllRecentlyAddedMissing, self).run()
|
||||
|
||||
self.running = True
|
||||
self.prepare()
|
||||
|
||||
from support.history import get_history
|
||||
history = get_history()
|
||||
|
||||
now = datetime.datetime.now()
|
||||
min_score_series = int(Prefs["subtitles.search.minimumTVScore2"].strip())
|
||||
min_score_movies = int(Prefs["subtitles.search.minimumMovieScore2"].strip())
|
||||
series_providers = config.get_providers(media_type="series")
|
||||
movie_providers = config.get_providers(media_type="movies")
|
||||
|
||||
is_recent_str = Prefs["scheduler.item_is_recent_age"]
|
||||
num, ident = is_recent_str.split()
|
||||
|
||||
max_search_days = 0
|
||||
if ident == "days":
|
||||
max_search_days = int(num)
|
||||
elif ident == "weeks":
|
||||
max_search_days = int(num) * 7
|
||||
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
recent_files = subtitle_storage.get_recent_files(age_days=max_search_days)
|
||||
|
||||
self.items_searching = len(recent_files)
|
||||
|
||||
download_count = 0
|
||||
videos_with_downloads = 0
|
||||
|
||||
config.init_subliminal_patches()
|
||||
|
||||
Log.Info(u"%s: Searching for subtitles for %s items", self.name, self.items_searching)
|
||||
|
||||
def skip_item():
|
||||
self.items_searching = self.items_searching - 1
|
||||
self.percentage = int(self.items_done * 100 / self.items_searching) if self.items_searching > 0 else 100
|
||||
|
||||
# search for subtitles in viable items
|
||||
try:
|
||||
for fn in recent_files:
|
||||
stored_subs = subtitle_storage.load(filename=fn)
|
||||
if not stored_subs:
|
||||
Log.Debug("Skipping item %s because storage is empty", fn)
|
||||
skip_item()
|
||||
continue
|
||||
|
||||
video_id = stored_subs.video_id
|
||||
|
||||
# added_date <= max_search_days?
|
||||
if stored_subs.added_at + datetime.timedelta(days=max_search_days) <= now:
|
||||
Log.Debug("Skipping item %s because it's too old", video_id)
|
||||
skip_item()
|
||||
continue
|
||||
|
||||
if stored_subs.item_type == "episode":
|
||||
min_score = min_score_series
|
||||
providers = series_providers
|
||||
else:
|
||||
min_score = min_score_movies
|
||||
providers = movie_providers
|
||||
|
||||
parts = []
|
||||
plex_item = get_item(video_id)
|
||||
|
||||
if not plex_item:
|
||||
Log.Info(u"%s: Item %s unknown, skipping", self.name, video_id)
|
||||
skip_item()
|
||||
continue
|
||||
|
||||
if not is_wanted(video_id, item=plex_item):
|
||||
skip_item()
|
||||
continue
|
||||
|
||||
for media in plex_item.media:
|
||||
parts += media.parts
|
||||
|
||||
downloads_per_video = 0
|
||||
hit_providers = False
|
||||
for part in parts:
|
||||
part_id = part.id
|
||||
|
||||
try:
|
||||
metadata = get_plex_metadata(video_id, part_id, stored_subs.item_type)
|
||||
except PartUnknownException:
|
||||
Log.Info(u"%s: Part %s:%s unknown, skipping", self.name, video_id, part_id)
|
||||
continue
|
||||
|
||||
if not metadata:
|
||||
Log.Info(u"%s: Part %s:%s unknown, skipping", self.name, video_id, part_id)
|
||||
continue
|
||||
|
||||
Log.Debug(u"%s: Looking for missing subtitles: %s", self.name, get_item_title(plex_item))
|
||||
scanned_parts = scan_videos([metadata], providers=providers)
|
||||
|
||||
# auto extract embedded
|
||||
if config.embedded_auto_extract:
|
||||
if config.plex_transcoder:
|
||||
ts = agent_extract_embedded(scanned_parts, set_as_existing=True)
|
||||
if ts:
|
||||
Log.Debug("Waiting for %i extraction threads to finish" % len(ts))
|
||||
for t in ts:
|
||||
t.join()
|
||||
else:
|
||||
Log.Warn("Plex Transcoder not found, can't auto extract")
|
||||
|
||||
downloaded_subtitles = download_best_subtitles(scanned_parts, min_score=min_score,
|
||||
providers=providers)
|
||||
hit_providers = downloaded_subtitles is not None
|
||||
download_successful = False
|
||||
|
||||
if downloaded_subtitles:
|
||||
downloaded_any = any(downloaded_subtitles.values())
|
||||
if not downloaded_any:
|
||||
continue
|
||||
|
||||
try:
|
||||
save_subtitles(scanned_parts, downloaded_subtitles, mode="a", mods=config.default_mods)
|
||||
Log.Debug(u"%s: Downloaded subtitle for item with missing subs: %s", self.name, video_id)
|
||||
download_successful = True
|
||||
refresh_item(video_id)
|
||||
track_usage("Subtitle", "manual", "download", 1)
|
||||
except:
|
||||
Log.Error(u"%s: Something went wrong when downloading specific subtitle: %s", self.name,
|
||||
traceback.format_exc())
|
||||
finally:
|
||||
scanned_parts = None
|
||||
try:
|
||||
item_title = get_title_for_video_metadata(metadata, add_section_title=False)
|
||||
if download_successful:
|
||||
# store item in history
|
||||
for video, video_subtitles in downloaded_subtitles.items():
|
||||
if not video_subtitles:
|
||||
continue
|
||||
|
||||
for subtitle in video_subtitles:
|
||||
downloads_per_video += 1
|
||||
history.add(item_title, video.id, section_title=metadata["section"],
|
||||
thumb=video.plexapi_metadata["super_thumb"],
|
||||
subtitle=subtitle,
|
||||
mode="a")
|
||||
|
||||
downloaded_subtitles = None
|
||||
except:
|
||||
Log.Error(u"%s: DEBUG HIT: %s", self.name, traceback.format_exc())
|
||||
|
||||
Log.Debug(u"%s: Waiting %s seconds before continuing", self.name, self.PROVIDER_SLACK)
|
||||
Thread.Sleep(self.PROVIDER_SLACK)
|
||||
|
||||
download_count += downloads_per_video
|
||||
|
||||
if downloads_per_video:
|
||||
videos_with_downloads += 1
|
||||
|
||||
self.items_done = self.items_done + 1
|
||||
self.percentage = int(self.items_done * 100 / self.items_searching) if self.items_searching > 0 else 100
|
||||
|
||||
stored_subs = None
|
||||
|
||||
if downloads_per_video:
|
||||
Log.Debug(u"%s: Subtitles have been downloaded, "
|
||||
u"waiting %s seconds before continuing", self.name, self.DL_PROVIDER_SLACK)
|
||||
Thread.Sleep(self.DL_PROVIDER_SLACK)
|
||||
else:
|
||||
if hit_providers:
|
||||
Log.Debug(u"%s: Waiting %s seconds before continuing", self.name, self.PROVIDER_SLACK)
|
||||
Thread.Sleep(self.PROVIDER_SLACK)
|
||||
finally:
|
||||
subtitle_storage.destroy()
|
||||
history.destroy()
|
||||
|
||||
if download_count:
|
||||
Log.Debug(u"%s: done. Missing subtitles found for %s/%s items (%s subs downloaded)", self.name,
|
||||
videos_with_downloads, self.items_searching, download_count)
|
||||
else:
|
||||
Log.Debug(u"%s: done. No subtitles found for %s items", self.name, self.items_searching)
|
||||
|
||||
def post_run(self, task_data):
|
||||
super(SearchAllRecentlyAddedMissing, self).post_run(task_data)
|
||||
self.ready_for_display = False
|
||||
self.percentage = 0
|
||||
self.items_done = None
|
||||
self.items_searching = None
|
||||
|
||||
|
||||
class LegacySearchAllRecentlyAddedMissing(Task):
|
||||
periodic = True
|
||||
frequency = "never"
|
||||
items_done = None
|
||||
items_searching = None
|
||||
items_searching_ids = None
|
||||
items_failed = None
|
||||
percentage = 0
|
||||
|
||||
stall_time = 30
|
||||
|
||||
def __init__(self):
|
||||
super(LegacySearchAllRecentlyAddedMissing, self).__init__()
|
||||
self.items_done = None
|
||||
self.items_searching = None
|
||||
self.items_searching_ids = None
|
||||
self.items_failed = None
|
||||
self.percentage = 0
|
||||
|
||||
def signal(self, signal_name, *args, **kwargs):
|
||||
handler = getattr(self, "signal_%s" % signal_name)
|
||||
return handler(*args, **kwargs) if handler else None
|
||||
|
||||
def signal_updated_metadata(self, *args, **kwargs):
|
||||
item_id = int(args[0])
|
||||
|
||||
if self.items_searching_ids is not None and item_id in self.items_searching_ids:
|
||||
self.items_done.append(item_id)
|
||||
return True
|
||||
|
||||
def prepare(self, *args, **kwargs):
|
||||
self.items_done = []
|
||||
recent_items = get_recent_items()
|
||||
missing = items_get_all_missing_subs(recent_items, sleep_after_request=0.2)
|
||||
ids = set([id for added_at, id, title, item, missing_languages in missing if is_wanted(id, item=item)])
|
||||
self.items_searching = missing
|
||||
self.items_searching_ids = ids
|
||||
self.items_failed = []
|
||||
self.percentage = 0
|
||||
self.ready_for_display = True
|
||||
|
||||
def run(self):
|
||||
super(LegacySearchAllRecentlyAddedMissing, self).run()
|
||||
self.running = True
|
||||
missing_count = len(self.items_searching)
|
||||
items_done_count = 0
|
||||
|
||||
for added_at, item_id, title, item, missing_languages in self.items_searching:
|
||||
Log.Debug(u"Task: %s, triggering refresh for %s (%s)", self.name, title, item_id)
|
||||
try:
|
||||
refresh_item(item_id)
|
||||
except URLError:
|
||||
# timeout
|
||||
pass
|
||||
search_started = datetime.datetime.now()
|
||||
tries = 1
|
||||
while 1:
|
||||
if item_id in self.items_done:
|
||||
items_done_count += 1
|
||||
self.percentage = int(items_done_count * 100 / missing_count) if missing_count > 0 else 100
|
||||
Log.Debug(u"Task: %s, item %s done (%s%%, %s/%s)", self.name, item_id, self.percentage,
|
||||
items_done_count, missing_count)
|
||||
break
|
||||
|
||||
# item considered stalled after self.stall_time seconds passed after last refresh
|
||||
if (datetime.datetime.now() - search_started).total_seconds() > self.stall_time:
|
||||
if tries > 3:
|
||||
self.items_failed.append(item_id)
|
||||
Log.Debug(u"Task: %s, item stalled for %s times: %s, skipping", self.name, tries, item_id)
|
||||
break
|
||||
|
||||
Log.Debug(u"Task: %s, item stalled for %s seconds: %s, retrying", self.name, self.stall_time,
|
||||
item_id)
|
||||
tries += 1
|
||||
try:
|
||||
refresh_item(item_id)
|
||||
except URLError:
|
||||
pass
|
||||
search_started = datetime.datetime.now()
|
||||
Thread.Sleep(1)
|
||||
Thread.Sleep(0.1)
|
||||
# we can't hammer the PMS, otherwise requests will be stalled
|
||||
Thread.Sleep(5)
|
||||
|
||||
Log.Debug("Task: %s, done (%s%%, %s/%s). Failed items: %s", self.name, self.percentage,
|
||||
items_done_count, missing_count, self.items_failed)
|
||||
|
||||
def post_run(self, task_data):
|
||||
super(LegacySearchAllRecentlyAddedMissing, self).post_run(task_data)
|
||||
self.ready_for_display = False
|
||||
self.percentage = 0
|
||||
self.items_done = None
|
||||
self.items_failed = None
|
||||
self.items_searching = None
|
||||
self.items_searching_ids = None
|
||||
|
||||
|
||||
class FindBetterSubtitles(DownloadSubtitleMixin, SubtitleListingMixin, Task):
|
||||
periodic = True
|
||||
|
||||
# TV: episode, format, series, year, season, video_codec, release_group, hearing_impaired, resolution
|
||||
series_cutoff = 357
|
||||
|
||||
# movies: format, title, release_group, year, video_codec, resolution, hearing_impaired
|
||||
movies_cutoff = 117
|
||||
|
||||
def signal_updated_metadata(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
def run(self):
|
||||
super(FindBetterSubtitles, self).run()
|
||||
self.running = True
|
||||
better_found = 0
|
||||
try:
|
||||
max_search_days = int(Prefs["scheduler.tasks.FindBetterSubtitles.max_days_after_added"].strip())
|
||||
except ValueError:
|
||||
Log.Error(u"Please only put numbers into the FindBetterSubtitles.max_days_after_added setting. Exiting")
|
||||
return
|
||||
else:
|
||||
if max_search_days > 30:
|
||||
Log.Error(u"%s: FindBetterSubtitles.max_days_after_added is too big. Max is 30 days.", self.name)
|
||||
return
|
||||
|
||||
now = datetime.datetime.now()
|
||||
min_score_series = int(Prefs["subtitles.search.minimumTVScore2"].strip())
|
||||
min_score_movies = int(Prefs["subtitles.search.minimumMovieScore2"].strip())
|
||||
min_score_extracted_series = config.advanced.find_better_as_extracted_tv_score or 352
|
||||
min_score_extracted_movies = config.advanced.find_better_as_extracted_movie_score or 112
|
||||
overwrite_manually_modified = cast_bool(
|
||||
Prefs["scheduler.tasks.FindBetterSubtitles.overwrite_manually_modified"])
|
||||
overwrite_manually_selected = cast_bool(
|
||||
Prefs["scheduler.tasks.FindBetterSubtitles.overwrite_manually_selected"])
|
||||
|
||||
air_date_cutoff_pref = Prefs["scheduler.tasks.FindBetterSubtitles.air_date_cutoff"]
|
||||
if air_date_cutoff_pref == "don't limit":
|
||||
air_date_cutoff = None
|
||||
else:
|
||||
air_date_cutoff = int(air_date_cutoff_pref.split()[0])
|
||||
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
viable_item_count = 0
|
||||
|
||||
try:
|
||||
for fn in subtitle_storage.get_recent_files(age_days=max_search_days):
|
||||
stored_subs = subtitle_storage.load(filename=fn)
|
||||
if not stored_subs:
|
||||
continue
|
||||
|
||||
video_id = stored_subs.video_id
|
||||
|
||||
if stored_subs.item_type == "episode":
|
||||
cutoff = self.series_cutoff
|
||||
min_score = min_score_series
|
||||
min_score_extracted = min_score_extracted_series
|
||||
else:
|
||||
cutoff = self.movies_cutoff
|
||||
min_score = min_score_movies
|
||||
min_score_extracted = min_score_extracted_movies
|
||||
|
||||
# don't search for better subtitles until at least 30 minutes have passed
|
||||
if stored_subs.added_at + datetime.timedelta(minutes=30) > now:
|
||||
Log.Debug(u"%s: Item %s too new, skipping", self.name, video_id)
|
||||
continue
|
||||
|
||||
# added_date <= max_search_days?
|
||||
if stored_subs.added_at + datetime.timedelta(days=max_search_days) <= now:
|
||||
continue
|
||||
|
||||
viable_item_count += 1
|
||||
ditch_parts = []
|
||||
|
||||
# look through all stored subtitle data
|
||||
for part_id, languages in stored_subs.parts.iteritems():
|
||||
part_id = str(part_id)
|
||||
|
||||
# all languages
|
||||
for language, current_subs in languages.iteritems():
|
||||
current_key = current_subs.get("current")
|
||||
current = current_subs.get(current_key)
|
||||
|
||||
# currently got subtitle?
|
||||
# fixme: check for existence
|
||||
if not current:
|
||||
continue
|
||||
current_score = current.score
|
||||
current_mode = current.mode
|
||||
|
||||
# late cutoff met? skip
|
||||
if current_score >= cutoff:
|
||||
Log.Debug(u"%s: Skipping finding better subs, "
|
||||
u"cutoff met (current: %s, cutoff: %s): %s (%s)",
|
||||
self.name, current_score, cutoff, stored_subs.title, video_id)
|
||||
continue
|
||||
|
||||
# got manual subtitle but don't want to touch those?
|
||||
if current_mode == "m" and not overwrite_manually_selected:
|
||||
Log.Debug(u"%s: Skipping finding better subs, "
|
||||
u"had manual: %s (%s)", self.name, stored_subs.title, video_id)
|
||||
continue
|
||||
|
||||
# subtitle modifications different from default
|
||||
if not overwrite_manually_modified and current.mods \
|
||||
and set(current.mods).difference(set(config.default_mods)):
|
||||
Log.Debug(u"%s: Skipping finding better subs, it has manual modifications: %s (%s)",
|
||||
self.name, stored_subs.title, video_id)
|
||||
continue
|
||||
|
||||
try:
|
||||
subs = self.list_subtitles(video_id, stored_subs.item_type, part_id, language,
|
||||
air_date_cutoff=air_date_cutoff)
|
||||
except PartUnknownException:
|
||||
Log.Info(u"%s: Part %s unknown/gone; ditching subtitle info", self.name, part_id)
|
||||
ditch_parts.append(part_id)
|
||||
continue
|
||||
|
||||
hit_providers = subs is not None
|
||||
|
||||
if subs:
|
||||
# subs are already sorted by score
|
||||
better_downloaded = False
|
||||
better_tried_download = 0
|
||||
better_visited = 0
|
||||
for sub in subs:
|
||||
if sub.score > current_score and sub.score > min_score:
|
||||
if current.provider_name == "embedded" and sub.score < min_score_extracted:
|
||||
Log.Debug(u"%s: Not downloading subtitle for %s, we've got an active extracted "
|
||||
u"embedded sub and the min score %s isn't met (%s).",
|
||||
self.name, video_id, min_score_extracted, sub.score)
|
||||
better_visited += 1
|
||||
break
|
||||
|
||||
Log.Debug(u"%s: Better subtitle found for %s, downloading", self.name, video_id)
|
||||
better_tried_download += 1
|
||||
ret = self.download_subtitle(sub, video_id, mode="b")
|
||||
if ret:
|
||||
better_found += 1
|
||||
better_downloaded = True
|
||||
break
|
||||
else:
|
||||
Log.Debug(u"%s: Couldn't download/save subtitle. "
|
||||
u"Continuing to the next one", self.name)
|
||||
Log.Debug(u"%s: Waiting %s seconds before continuing",
|
||||
self.name, self.DL_PROVIDER_SLACK)
|
||||
Thread.Sleep(self.DL_PROVIDER_SLACK)
|
||||
better_visited += 1
|
||||
|
||||
if better_tried_download and not better_downloaded:
|
||||
Log.Debug(u"%s: Tried downloading better subtitle for %s, "
|
||||
u"but every try failed.", self.name, video_id)
|
||||
|
||||
elif better_downloaded:
|
||||
Log.Debug(u"%s: Better subtitle downloaded for %s", self.name, video_id)
|
||||
|
||||
if better_tried_download or better_downloaded:
|
||||
Log.Debug(u"%s: Waiting %s seconds before continuing", self.name, self.DL_PROVIDER_SLACK)
|
||||
Thread.Sleep(self.DL_PROVIDER_SLACK)
|
||||
|
||||
elif better_visited:
|
||||
Log.Debug(u"%s: Waiting %s seconds before continuing", self.name, self.PROVIDER_SLACK)
|
||||
Thread.Sleep(self.PROVIDER_SLACK)
|
||||
|
||||
subs = None
|
||||
|
||||
elif hit_providers:
|
||||
# hit the providers but didn't try downloading? wait.
|
||||
Log.Debug(u"%s: Waiting %s seconds before continuing", self.name, self.PROVIDER_SLACK)
|
||||
Thread.Sleep(self.PROVIDER_SLACK)
|
||||
|
||||
if ditch_parts:
|
||||
for part_id in ditch_parts:
|
||||
try:
|
||||
del stored_subs.parts[part_id]
|
||||
except KeyError:
|
||||
pass
|
||||
subtitle_storage.save(stored_subs)
|
||||
ditch_parts = None
|
||||
|
||||
stored_subs = None
|
||||
|
||||
Thread.Sleep(1)
|
||||
finally:
|
||||
subtitle_storage.destroy()
|
||||
|
||||
if better_found:
|
||||
Log.Debug(u"%s: done. Better subtitles found for %s/%s items", self.name, better_found,
|
||||
viable_item_count)
|
||||
else:
|
||||
Log.Debug(u"%s: done. No better subtitles found for %s items", self.name, viable_item_count)
|
||||
|
||||
|
||||
class SubtitleStorageMaintenance(Task):
|
||||
periodic = True
|
||||
frequency = "every 7 days"
|
||||
|
||||
def run(self):
|
||||
super(SubtitleStorageMaintenance, self).run()
|
||||
self.running = True
|
||||
Log.Info(u"%s: Running subtitle storage maintenance", self.name)
|
||||
storage = get_subtitle_storage()
|
||||
try:
|
||||
deleted_items = storage.delete_missing(wanted_languages=set(str(l) for l in config.lang_list))
|
||||
except OSError:
|
||||
deleted_items = storage.delete_missing(wanted_languages=set(str(l) for l in config.lang_list),
|
||||
scandir_generic=True)
|
||||
|
||||
if deleted_items:
|
||||
Log.Info(u"%s: Subtitle information for %d non-existant videos have been cleaned up",
|
||||
self.name, len(deleted_items))
|
||||
Log.Debug(u"%s: Videos: %s", self.name, deleted_items)
|
||||
else:
|
||||
Log.Info(u"%s: Nothing to do", self.name)
|
||||
|
||||
storage.destroy()
|
||||
|
||||
|
||||
class MenuHistoryMaintenance(Task):
|
||||
periodic = True
|
||||
frequency = "every 7 days"
|
||||
|
||||
def run(self):
|
||||
super(MenuHistoryMaintenance, self).run()
|
||||
self.running = True
|
||||
Log.Info(u"%s: Running menu history maintenance", self.name)
|
||||
now = datetime.datetime.now()
|
||||
if "menu_history" in Dict:
|
||||
for key, timeout in Dict["menu_history"].copy().items():
|
||||
if now > timeout:
|
||||
try:
|
||||
del Dict["menu_history"][key]
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
class MigrateSubtitleStorage(Task):
|
||||
periodic = False
|
||||
frequency = None
|
||||
|
||||
def run(self):
|
||||
super(MigrateSubtitleStorage, self).run()
|
||||
self.running = True
|
||||
Log.Info(u"%s: Running subtitle storage migration", self.name)
|
||||
storage = get_subtitle_storage()
|
||||
|
||||
def migrate(scandir_generic=False):
|
||||
for fn in storage.get_all_files(scandir_generic=scandir_generic):
|
||||
if fn.endswith(".json.gz"):
|
||||
continue
|
||||
Log.Debug(u"%s: Migrating %s", self.name, fn)
|
||||
storage.load(None, fn)
|
||||
|
||||
try:
|
||||
migrate()
|
||||
except OSError:
|
||||
migrate(scandir_generic=True)
|
||||
|
||||
storage.destroy()
|
||||
|
||||
|
||||
class CacheMaintenance(Task):
|
||||
periodic = True
|
||||
frequency = "every 1 days"
|
||||
|
||||
main_cache_validity = 14 # days
|
||||
pack_cache_validity = 4 # days
|
||||
|
||||
def run(self):
|
||||
super(CacheMaintenance, self).run()
|
||||
self.running = True
|
||||
Log.Info(u"%s: Running cache maintenance", self.name)
|
||||
now = datetime.datetime.now()
|
||||
|
||||
def remove_expired(path, expiry):
|
||||
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(path))
|
||||
if mtime + datetime.timedelta(days=expiry) < now:
|
||||
try:
|
||||
os.remove(path)
|
||||
except (IOError, OSError):
|
||||
Log.Debug("Couldn't remove cache file: %s", os.path.basename(path))
|
||||
|
||||
# main cache
|
||||
if config.new_style_cache:
|
||||
for fn in subliminal_cache_region.backend.all_filenames:
|
||||
remove_expired(fn, self.main_cache_validity)
|
||||
|
||||
# archive cache
|
||||
for fn in glob.iglob(os.path.join(config.pack_cache_dir, "*.archive")):
|
||||
remove_expired(fn, self.pack_cache_validity)
|
||||
|
||||
|
||||
scheduler.register(LegacySearchAllRecentlyAddedMissing)
|
||||
scheduler.register(SearchAllRecentlyAddedMissing)
|
||||
scheduler.register(AvailableSubsForItem)
|
||||
scheduler.register(DownloadSubtitleForItem)
|
||||
scheduler.register(MissingSubtitles)
|
||||
scheduler.register(FindBetterSubtitles)
|
||||
scheduler.register(SubtitleStorageMaintenance)
|
||||
scheduler.register(MigrateSubtitleStorage)
|
||||
scheduler.register(MenuHistoryMaintenance)
|
||||
scheduler.register(CacheMaintenance)
|
||||
+1004
-115
File diff suppressed because it is too large
Load Diff
Regular → Executable
+28
-8
@@ -4,30 +4,50 @@
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Test Plug-in</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.plexapp.agents.subliminal</string>
|
||||
<string>com.plexapp.agents.subzero</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>2.6.5</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<string>2.6.5.3247</string>
|
||||
<key>PlexFrameworkVersion</key>
|
||||
<string>2</string>
|
||||
<key>PlexPluginClass</key>
|
||||
<string>Agent</string>
|
||||
<key>PlexPluginMode</key>
|
||||
<string>AlwaysOn</string>
|
||||
<string>Daemon</string>
|
||||
<key>PlexPluginConsoleLogging</key>
|
||||
<string>1</string>
|
||||
<string>0</string>
|
||||
<key>PlexPluginDevMode</key>
|
||||
<string>1</string>
|
||||
<key>PlexPluginCodePolicy</key>
|
||||
<!-- this allows channels to access some python methods which are otherwise blocked, as well as import external code libraries, and interact with the PMS HTTP API -->
|
||||
<!-- this allows channels to access some python methods which are otherwise blocked, as well as import external code libraries, and interact with the PMS HTTP API -->
|
||||
<string>Elevated</string>
|
||||
<key>PlexAgentAttributionText</key>
|
||||
<string><div style="white-space: pre;"><img src="https://raw.githubusercontent.com/pannal/Sub-Zero.bundle/master/Contents/Resources/subzero.gif" />
|
||||
|
||||
<h1>Sub-Zero for Plex</h1><i>Subtitles done right</i>
|
||||
|
||||
Version 2.6.5.3247 DEV
|
||||
|
||||
Originally based on @bramwalet's awesome <a href="https://github.com/bramwalet/Subliminal.bundle">Subliminal.bundle</a>
|
||||
|
||||
If you like this, buy me a beer: <a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=G9VKR2B8PMNKG" target="_blank" title="donate"><img src="https://www.paypalobjects.com/en_US/i/btn/btn_donate_LG.gif" alt="donate" title="donate" /></a>
|
||||
|
||||
<strong>Need help?</strong>
|
||||
Wiki: <a href="http://v.ht/szwiki">http://v.ht/szwiki</a>
|
||||
Score info: <a href="http://v.ht/szscores">http://v.ht/szscores</a>
|
||||
Plex thread: <a href="https://forums.plex.tv/discussion/186575">https://forums.plex.tv/discussion/186575</a>
|
||||
Github: <a href="https://github.com/pannal/Sub-Zero.bundle">https://github.com/pannal/Sub-Zero</a>
|
||||
|
||||
3rd party licenses: <a href="https://github.com/pannal/Sub-Zero.bundle/tree/master/Licenses">https://github.com/pannal/Sub-Zero.bundle/tree/master/Licenses</a>
|
||||
|
||||
panni, 2019
|
||||
</div>
|
||||
</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
try:
|
||||
import ast
|
||||
from _markerlib.markers import default_environment, compile, interpret
|
||||
except ImportError:
|
||||
if 'ast' in globals():
|
||||
raise
|
||||
def default_environment():
|
||||
return {}
|
||||
def compile(marker):
|
||||
def marker_fn(environment=None, override=None):
|
||||
# 'empty markers are True' heuristic won't install extra deps.
|
||||
return not marker.strip()
|
||||
marker_fn.__doc__ = marker
|
||||
return marker_fn
|
||||
def interpret(marker, environment=None, override=None):
|
||||
return compile(marker)()
|
||||
@@ -0,0 +1,119 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Interpret PEP 345 environment markers.
|
||||
|
||||
EXPR [in|==|!=|not in] EXPR [or|and] ...
|
||||
|
||||
where EXPR belongs to any of those:
|
||||
|
||||
python_version = '%s.%s' % (sys.version_info[0], sys.version_info[1])
|
||||
python_full_version = sys.version.split()[0]
|
||||
os.name = os.name
|
||||
sys.platform = sys.platform
|
||||
platform.version = platform.version()
|
||||
platform.machine = platform.machine()
|
||||
platform.python_implementation = platform.python_implementation()
|
||||
a free string, like '2.6', or 'win32'
|
||||
"""
|
||||
|
||||
__all__ = ['default_environment', 'compile', 'interpret']
|
||||
|
||||
import ast
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import weakref
|
||||
|
||||
_builtin_compile = compile
|
||||
|
||||
try:
|
||||
from platform import python_implementation
|
||||
except ImportError:
|
||||
if os.name == "java":
|
||||
# Jython 2.5 has ast module, but not platform.python_implementation() function.
|
||||
def python_implementation():
|
||||
return "Jython"
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
# restricted set of variables
|
||||
_VARS = {'sys.platform': sys.platform,
|
||||
'python_version': '%s.%s' % sys.version_info[:2],
|
||||
# FIXME parsing sys.platform is not reliable, but there is no other
|
||||
# way to get e.g. 2.7.2+, and the PEP is defined with sys.version
|
||||
'python_full_version': sys.version.split(' ', 1)[0],
|
||||
'os.name': os.name,
|
||||
'platform.version': platform.version(),
|
||||
'platform.machine': platform.machine(),
|
||||
'platform.python_implementation': python_implementation(),
|
||||
'extra': None # wheel extension
|
||||
}
|
||||
|
||||
for var in list(_VARS.keys()):
|
||||
if '.' in var:
|
||||
_VARS[var.replace('.', '_')] = _VARS[var]
|
||||
|
||||
def default_environment():
|
||||
"""Return copy of default PEP 385 globals dictionary."""
|
||||
return dict(_VARS)
|
||||
|
||||
class ASTWhitelist(ast.NodeTransformer):
|
||||
def __init__(self, statement):
|
||||
self.statement = statement # for error messages
|
||||
|
||||
ALLOWED = (ast.Compare, ast.BoolOp, ast.Attribute, ast.Name, ast.Load, ast.Str)
|
||||
# Bool operations
|
||||
ALLOWED += (ast.And, ast.Or)
|
||||
# Comparison operations
|
||||
ALLOWED += (ast.Eq, ast.Gt, ast.GtE, ast.In, ast.Is, ast.IsNot, ast.Lt, ast.LtE, ast.NotEq, ast.NotIn)
|
||||
|
||||
def visit(self, node):
|
||||
"""Ensure statement only contains allowed nodes."""
|
||||
if not isinstance(node, self.ALLOWED):
|
||||
raise SyntaxError('Not allowed in environment markers.\n%s\n%s' %
|
||||
(self.statement,
|
||||
(' ' * node.col_offset) + '^'))
|
||||
return ast.NodeTransformer.visit(self, node)
|
||||
|
||||
def visit_Attribute(self, node):
|
||||
"""Flatten one level of attribute access."""
|
||||
new_node = ast.Name("%s.%s" % (node.value.id, node.attr), node.ctx)
|
||||
return ast.copy_location(new_node, node)
|
||||
|
||||
def parse_marker(marker):
|
||||
tree = ast.parse(marker, mode='eval')
|
||||
new_tree = ASTWhitelist(marker).generic_visit(tree)
|
||||
return new_tree
|
||||
|
||||
def compile_marker(parsed_marker):
|
||||
return _builtin_compile(parsed_marker, '<environment marker>', 'eval',
|
||||
dont_inherit=True)
|
||||
|
||||
_cache = weakref.WeakValueDictionary()
|
||||
|
||||
def compile(marker):
|
||||
"""Return compiled marker as a function accepting an environment dict."""
|
||||
try:
|
||||
return _cache[marker]
|
||||
except KeyError:
|
||||
pass
|
||||
if not marker.strip():
|
||||
def marker_fn(environment=None, override=None):
|
||||
""""""
|
||||
return True
|
||||
else:
|
||||
compiled_marker = compile_marker(parse_marker(marker))
|
||||
def marker_fn(environment=None, override=None):
|
||||
"""override updates environment"""
|
||||
if override is None:
|
||||
override = {}
|
||||
if environment is None:
|
||||
environment = default_environment()
|
||||
environment.update(override)
|
||||
return eval(compiled_marker, environment)
|
||||
marker_fn.__doc__ = marker
|
||||
_cache[marker] = marker_fn
|
||||
return _cache[marker]
|
||||
|
||||
def interpret(marker, environment=None):
|
||||
return compile(marker)(environment)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
"""Generic interface to all dbm clones.
|
||||
|
||||
Instead of
|
||||
|
||||
import dbm
|
||||
d = dbm.open(file, 'w', 0666)
|
||||
|
||||
use
|
||||
|
||||
import anydbm
|
||||
d = anydbm.open(file, 'w')
|
||||
|
||||
The returned object is a dbhash, gdbm, dbm or dumbdbm object,
|
||||
dependent on the type of database being opened (determined by whichdb
|
||||
module) in the case of an existing dbm. If the dbm does not exist and
|
||||
the create or new flag ('c' or 'n') was specified, the dbm type will
|
||||
be determined by the availability of the modules (tested in the above
|
||||
order).
|
||||
|
||||
It has the following interface (key and data are strings):
|
||||
|
||||
d[key] = data # store data at key (may override data at
|
||||
# existing key)
|
||||
data = d[key] # retrieve data at key (raise KeyError if no
|
||||
# such key)
|
||||
del d[key] # delete data stored at key (raises KeyError
|
||||
# if no such key)
|
||||
flag = key in d # true if the key exists
|
||||
list = d.keys() # return a list of all existing keys (slow!)
|
||||
|
||||
Future versions may change the order in which implementations are
|
||||
tested for existence, and add interfaces to other dbm-like
|
||||
implementations.
|
||||
"""
|
||||
|
||||
class error(Exception):
|
||||
pass
|
||||
|
||||
_names = ['dbhash', 'gdbm', 'dbm', 'dumbdbm']
|
||||
_errors = [error]
|
||||
_defaultmod = None
|
||||
|
||||
for _name in _names:
|
||||
try:
|
||||
_mod = __import__(_name)
|
||||
except ImportError:
|
||||
continue
|
||||
if not _defaultmod:
|
||||
_defaultmod = _mod
|
||||
_errors.append(_mod.error)
|
||||
|
||||
if not _defaultmod:
|
||||
raise ImportError, "no dbm clone found; tried %s" % _names
|
||||
|
||||
error = tuple(_errors)
|
||||
|
||||
def open(file, flag='r', mode=0666):
|
||||
"""Open or create database at path given by *file*.
|
||||
|
||||
Optional argument *flag* can be 'r' (default) for read-only access, 'w'
|
||||
for read-write access of an existing database, 'c' for read-write access
|
||||
to a new or existing database, and 'n' for read-write access to a new
|
||||
database.
|
||||
|
||||
Note: 'r' and 'w' fail if the database doesn't exist; 'c' creates it
|
||||
only if it doesn't exist; and 'n' always creates a new database.
|
||||
"""
|
||||
|
||||
# guess the type of an existing database
|
||||
from whichdb import whichdb
|
||||
result=whichdb(file)
|
||||
if result is None:
|
||||
# db doesn't exist
|
||||
if 'c' in flag or 'n' in flag:
|
||||
# file doesn't exist and the new
|
||||
# flag was used so use default type
|
||||
mod = _defaultmod
|
||||
else:
|
||||
raise error, "need 'c' or 'n' flag to open new db"
|
||||
elif result == "":
|
||||
# db type cannot be determined
|
||||
raise error, "db type could not be determined"
|
||||
else:
|
||||
mod = __import__(result)
|
||||
return mod.open(file, flag, mode)
|
||||
@@ -0,0 +1,552 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2005-2010 ActiveState Software Inc.
|
||||
# Copyright (c) 2013 Eddy Petrișor
|
||||
|
||||
"""Utilities for determining application-specific dirs.
|
||||
|
||||
See <http://github.com/ActiveState/appdirs> for details and usage.
|
||||
"""
|
||||
# Dev Notes:
|
||||
# - MSDN on where to store app data files:
|
||||
# http://support.microsoft.com/default.aspx?scid=kb;en-us;310294#XSLTH3194121123120121120120
|
||||
# - Mac OS X: http://developer.apple.com/documentation/MacOSX/Conceptual/BPFileSystem/index.html
|
||||
# - XDG spec for Un*x: http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
|
||||
__version_info__ = (1, 4, 0)
|
||||
__version__ = '.'.join(map(str, __version_info__))
|
||||
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
PY3 = sys.version_info[0] == 3
|
||||
|
||||
if PY3:
|
||||
unicode = str
|
||||
|
||||
if sys.platform.startswith('java'):
|
||||
import platform
|
||||
os_name = platform.java_ver()[3][0]
|
||||
if os_name.startswith('Windows'): # "Windows XP", "Windows 7", etc.
|
||||
system = 'win32'
|
||||
elif os_name.startswith('Mac'): # "Mac OS X", etc.
|
||||
system = 'darwin'
|
||||
else: # "Linux", "SunOS", "FreeBSD", etc.
|
||||
# Setting this to "linux2" is not ideal, but only Windows or Mac
|
||||
# are actually checked for and the rest of the module expects
|
||||
# *sys.platform* style strings.
|
||||
system = 'linux2'
|
||||
else:
|
||||
system = sys.platform
|
||||
|
||||
|
||||
|
||||
def user_data_dir(appname=None, appauthor=None, version=None, roaming=False):
|
||||
r"""Return full path to the user-specific data dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"roaming" (boolean, default False) can be set True to use the Windows
|
||||
roaming appdata directory. That means that for users on a Windows
|
||||
network setup for roaming profiles, this user data will be
|
||||
sync'd on login. See
|
||||
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
|
||||
for a discussion of issues.
|
||||
|
||||
Typical user data directories are:
|
||||
Mac OS X: ~/Library/Application Support/<AppName>
|
||||
Unix: ~/.local/share/<AppName> # or in $XDG_DATA_HOME, if defined
|
||||
Win XP (not roaming): C:\Documents and Settings\<username>\Application Data\<AppAuthor>\<AppName>
|
||||
Win XP (roaming): C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>
|
||||
Win 7 (not roaming): C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>
|
||||
Win 7 (roaming): C:\Users\<username>\AppData\Roaming\<AppAuthor>\<AppName>
|
||||
|
||||
For Unix, we follow the XDG spec and support $XDG_DATA_HOME.
|
||||
That means, by default "~/.local/share/<AppName>".
|
||||
"""
|
||||
if system == "win32":
|
||||
if appauthor is None:
|
||||
appauthor = appname
|
||||
const = roaming and "CSIDL_APPDATA" or "CSIDL_LOCAL_APPDATA"
|
||||
path = os.path.normpath(_get_win_folder(const))
|
||||
if appname:
|
||||
if appauthor is not False:
|
||||
path = os.path.join(path, appauthor, appname)
|
||||
else:
|
||||
path = os.path.join(path, appname)
|
||||
elif system == 'darwin':
|
||||
path = os.path.expanduser('~/Library/Application Support/')
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
else:
|
||||
path = os.getenv('XDG_DATA_HOME', os.path.expanduser("~/.local/share"))
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
return path
|
||||
|
||||
|
||||
def site_data_dir(appname=None, appauthor=None, version=None, multipath=False):
|
||||
"""Return full path to the user-shared data dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"multipath" is an optional parameter only applicable to *nix
|
||||
which indicates that the entire list of data dirs should be
|
||||
returned. By default, the first item from XDG_DATA_DIRS is
|
||||
returned, or '/usr/local/share/<AppName>',
|
||||
if XDG_DATA_DIRS is not set
|
||||
|
||||
Typical user data directories are:
|
||||
Mac OS X: /Library/Application Support/<AppName>
|
||||
Unix: /usr/local/share/<AppName> or /usr/share/<AppName>
|
||||
Win XP: C:\Documents and Settings\All Users\Application Data\<AppAuthor>\<AppName>
|
||||
Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
|
||||
Win 7: C:\ProgramData\<AppAuthor>\<AppName> # Hidden, but writeable on Win 7.
|
||||
|
||||
For Unix, this is using the $XDG_DATA_DIRS[0] default.
|
||||
|
||||
WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
|
||||
"""
|
||||
if system == "win32":
|
||||
if appauthor is None:
|
||||
appauthor = appname
|
||||
path = os.path.normpath(_get_win_folder("CSIDL_COMMON_APPDATA"))
|
||||
if appname:
|
||||
if appauthor is not False:
|
||||
path = os.path.join(path, appauthor, appname)
|
||||
else:
|
||||
path = os.path.join(path, appname)
|
||||
elif system == 'darwin':
|
||||
path = os.path.expanduser('/Library/Application Support')
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
else:
|
||||
# XDG default for $XDG_DATA_DIRS
|
||||
# only first, if multipath is False
|
||||
path = os.getenv('XDG_DATA_DIRS',
|
||||
os.pathsep.join(['/usr/local/share', '/usr/share']))
|
||||
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
|
||||
if appname:
|
||||
if version:
|
||||
appname = os.path.join(appname, version)
|
||||
pathlist = [os.sep.join([x, appname]) for x in pathlist]
|
||||
|
||||
if multipath:
|
||||
path = os.pathsep.join(pathlist)
|
||||
else:
|
||||
path = pathlist[0]
|
||||
return path
|
||||
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
return path
|
||||
|
||||
|
||||
def user_config_dir(appname=None, appauthor=None, version=None, roaming=False):
|
||||
r"""Return full path to the user-specific config dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"roaming" (boolean, default False) can be set True to use the Windows
|
||||
roaming appdata directory. That means that for users on a Windows
|
||||
network setup for roaming profiles, this user data will be
|
||||
sync'd on login. See
|
||||
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
|
||||
for a discussion of issues.
|
||||
|
||||
Typical user data directories are:
|
||||
Mac OS X: same as user_data_dir
|
||||
Unix: ~/.config/<AppName> # or in $XDG_CONFIG_HOME, if defined
|
||||
Win *: same as user_data_dir
|
||||
|
||||
For Unix, we follow the XDG spec and support $XDG_CONFIG_HOME.
|
||||
That means, by deafult "~/.config/<AppName>".
|
||||
"""
|
||||
if system in ["win32", "darwin"]:
|
||||
path = user_data_dir(appname, appauthor, None, roaming)
|
||||
else:
|
||||
path = os.getenv('XDG_CONFIG_HOME', os.path.expanduser("~/.config"))
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
return path
|
||||
|
||||
|
||||
def site_config_dir(appname=None, appauthor=None, version=None, multipath=False):
|
||||
"""Return full path to the user-shared data dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"multipath" is an optional parameter only applicable to *nix
|
||||
which indicates that the entire list of config dirs should be
|
||||
returned. By default, the first item from XDG_CONFIG_DIRS is
|
||||
returned, or '/etc/xdg/<AppName>', if XDG_CONFIG_DIRS is not set
|
||||
|
||||
Typical user data directories are:
|
||||
Mac OS X: same as site_data_dir
|
||||
Unix: /etc/xdg/<AppName> or $XDG_CONFIG_DIRS[i]/<AppName> for each value in
|
||||
$XDG_CONFIG_DIRS
|
||||
Win *: same as site_data_dir
|
||||
Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
|
||||
|
||||
For Unix, this is using the $XDG_CONFIG_DIRS[0] default, if multipath=False
|
||||
|
||||
WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
|
||||
"""
|
||||
if system in ["win32", "darwin"]:
|
||||
path = site_data_dir(appname, appauthor)
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
else:
|
||||
# XDG default for $XDG_CONFIG_DIRS
|
||||
# only first, if multipath is False
|
||||
path = os.getenv('XDG_CONFIG_DIRS', '/etc/xdg')
|
||||
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
|
||||
if appname:
|
||||
if version:
|
||||
appname = os.path.join(appname, version)
|
||||
pathlist = [os.sep.join([x, appname]) for x in pathlist]
|
||||
|
||||
if multipath:
|
||||
path = os.pathsep.join(pathlist)
|
||||
else:
|
||||
path = pathlist[0]
|
||||
return path
|
||||
|
||||
|
||||
def user_cache_dir(appname=None, appauthor=None, version=None, opinion=True):
|
||||
r"""Return full path to the user-specific cache dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"opinion" (boolean) can be False to disable the appending of
|
||||
"Cache" to the base app data dir for Windows. See
|
||||
discussion below.
|
||||
|
||||
Typical user cache directories are:
|
||||
Mac OS X: ~/Library/Caches/<AppName>
|
||||
Unix: ~/.cache/<AppName> (XDG default)
|
||||
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>\Cache
|
||||
Vista: C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>\Cache
|
||||
|
||||
On Windows the only suggestion in the MSDN docs is that local settings go in
|
||||
the `CSIDL_LOCAL_APPDATA` directory. This is identical to the non-roaming
|
||||
app data dir (the default returned by `user_data_dir` above). Apps typically
|
||||
put cache data somewhere *under* the given dir here. Some examples:
|
||||
...\Mozilla\Firefox\Profiles\<ProfileName>\Cache
|
||||
...\Acme\SuperApp\Cache\1.0
|
||||
OPINION: This function appends "Cache" to the `CSIDL_LOCAL_APPDATA` value.
|
||||
This can be disabled with the `opinion=False` option.
|
||||
"""
|
||||
if system == "win32":
|
||||
if appauthor is None:
|
||||
appauthor = appname
|
||||
path = os.path.normpath(_get_win_folder("CSIDL_LOCAL_APPDATA"))
|
||||
if appname:
|
||||
if appauthor is not False:
|
||||
path = os.path.join(path, appauthor, appname)
|
||||
else:
|
||||
path = os.path.join(path, appname)
|
||||
if opinion:
|
||||
path = os.path.join(path, "Cache")
|
||||
elif system == 'darwin':
|
||||
path = os.path.expanduser('~/Library/Caches')
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
else:
|
||||
path = os.getenv('XDG_CACHE_HOME', os.path.expanduser('~/.cache'))
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
return path
|
||||
|
||||
|
||||
def user_log_dir(appname=None, appauthor=None, version=None, opinion=True):
|
||||
r"""Return full path to the user-specific log dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"opinion" (boolean) can be False to disable the appending of
|
||||
"Logs" to the base app data dir for Windows, and "log" to the
|
||||
base cache dir for Unix. See discussion below.
|
||||
|
||||
Typical user cache directories are:
|
||||
Mac OS X: ~/Library/Logs/<AppName>
|
||||
Unix: ~/.cache/<AppName>/log # or under $XDG_CACHE_HOME if defined
|
||||
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>\Logs
|
||||
Vista: C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>\Logs
|
||||
|
||||
On Windows the only suggestion in the MSDN docs is that local settings
|
||||
go in the `CSIDL_LOCAL_APPDATA` directory. (Note: I'm interested in
|
||||
examples of what some windows apps use for a logs dir.)
|
||||
|
||||
OPINION: This function appends "Logs" to the `CSIDL_LOCAL_APPDATA`
|
||||
value for Windows and appends "log" to the user cache dir for Unix.
|
||||
This can be disabled with the `opinion=False` option.
|
||||
"""
|
||||
if system == "darwin":
|
||||
path = os.path.join(
|
||||
os.path.expanduser('~/Library/Logs'),
|
||||
appname)
|
||||
elif system == "win32":
|
||||
path = user_data_dir(appname, appauthor, version)
|
||||
version = False
|
||||
if opinion:
|
||||
path = os.path.join(path, "Logs")
|
||||
else:
|
||||
path = user_cache_dir(appname, appauthor, version)
|
||||
version = False
|
||||
if opinion:
|
||||
path = os.path.join(path, "log")
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
return path
|
||||
|
||||
|
||||
class AppDirs(object):
|
||||
"""Convenience wrapper for getting application dirs."""
|
||||
def __init__(self, appname, appauthor=None, version=None, roaming=False,
|
||||
multipath=False):
|
||||
self.appname = appname
|
||||
self.appauthor = appauthor
|
||||
self.version = version
|
||||
self.roaming = roaming
|
||||
self.multipath = multipath
|
||||
|
||||
@property
|
||||
def user_data_dir(self):
|
||||
return user_data_dir(self.appname, self.appauthor,
|
||||
version=self.version, roaming=self.roaming)
|
||||
|
||||
@property
|
||||
def site_data_dir(self):
|
||||
return site_data_dir(self.appname, self.appauthor,
|
||||
version=self.version, multipath=self.multipath)
|
||||
|
||||
@property
|
||||
def user_config_dir(self):
|
||||
return user_config_dir(self.appname, self.appauthor,
|
||||
version=self.version, roaming=self.roaming)
|
||||
|
||||
@property
|
||||
def site_config_dir(self):
|
||||
return site_config_dir(self.appname, self.appauthor,
|
||||
version=self.version, multipath=self.multipath)
|
||||
|
||||
@property
|
||||
def user_cache_dir(self):
|
||||
return user_cache_dir(self.appname, self.appauthor,
|
||||
version=self.version)
|
||||
|
||||
@property
|
||||
def user_log_dir(self):
|
||||
return user_log_dir(self.appname, self.appauthor,
|
||||
version=self.version)
|
||||
|
||||
|
||||
#---- internal support stuff
|
||||
|
||||
def _get_win_folder_from_registry(csidl_name):
|
||||
"""This is a fallback technique at best. I'm not sure if using the
|
||||
registry for this guarantees us the correct answer for all CSIDL_*
|
||||
names.
|
||||
"""
|
||||
import _winreg
|
||||
|
||||
shell_folder_name = {
|
||||
"CSIDL_APPDATA": "AppData",
|
||||
"CSIDL_COMMON_APPDATA": "Common AppData",
|
||||
"CSIDL_LOCAL_APPDATA": "Local AppData",
|
||||
}[csidl_name]
|
||||
|
||||
key = _winreg.OpenKey(
|
||||
_winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
|
||||
)
|
||||
dir, type = _winreg.QueryValueEx(key, shell_folder_name)
|
||||
return dir
|
||||
|
||||
|
||||
def _get_win_folder_with_pywin32(csidl_name):
|
||||
from win32com.shell import shellcon, shell
|
||||
dir = shell.SHGetFolderPath(0, getattr(shellcon, csidl_name), 0, 0)
|
||||
# Try to make this a unicode path because SHGetFolderPath does
|
||||
# not return unicode strings when there is unicode data in the
|
||||
# path.
|
||||
try:
|
||||
dir = unicode(dir)
|
||||
|
||||
# Downgrade to short path name if have highbit chars. See
|
||||
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
|
||||
has_high_char = False
|
||||
for c in dir:
|
||||
if ord(c) > 255:
|
||||
has_high_char = True
|
||||
break
|
||||
if has_high_char:
|
||||
try:
|
||||
import win32api
|
||||
dir = win32api.GetShortPathName(dir)
|
||||
except ImportError:
|
||||
pass
|
||||
except UnicodeError:
|
||||
pass
|
||||
return dir
|
||||
|
||||
|
||||
def _get_win_folder_with_ctypes(csidl_name):
|
||||
import ctypes
|
||||
|
||||
csidl_const = {
|
||||
"CSIDL_APPDATA": 26,
|
||||
"CSIDL_COMMON_APPDATA": 35,
|
||||
"CSIDL_LOCAL_APPDATA": 28,
|
||||
}[csidl_name]
|
||||
|
||||
buf = ctypes.create_unicode_buffer(1024)
|
||||
ctypes.windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf)
|
||||
|
||||
# Downgrade to short path name if have highbit chars. See
|
||||
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
|
||||
has_high_char = False
|
||||
for c in buf:
|
||||
if ord(c) > 255:
|
||||
has_high_char = True
|
||||
break
|
||||
if has_high_char:
|
||||
buf2 = ctypes.create_unicode_buffer(1024)
|
||||
if ctypes.windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024):
|
||||
buf = buf2
|
||||
|
||||
return buf.value
|
||||
|
||||
def _get_win_folder_with_jna(csidl_name):
|
||||
import array
|
||||
from com.sun import jna
|
||||
from com.sun.jna.platform import win32
|
||||
|
||||
buf_size = win32.WinDef.MAX_PATH * 2
|
||||
buf = array.zeros('c', buf_size)
|
||||
shell = win32.Shell32.INSTANCE
|
||||
shell.SHGetFolderPath(None, getattr(win32.ShlObj, csidl_name), None, win32.ShlObj.SHGFP_TYPE_CURRENT, buf)
|
||||
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
|
||||
|
||||
# Downgrade to short path name if have highbit chars. See
|
||||
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
|
||||
has_high_char = False
|
||||
for c in dir:
|
||||
if ord(c) > 255:
|
||||
has_high_char = True
|
||||
break
|
||||
if has_high_char:
|
||||
buf = array.zeros('c', buf_size)
|
||||
kernel = win32.Kernel32.INSTANCE
|
||||
if kernal.GetShortPathName(dir, buf, buf_size):
|
||||
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
|
||||
|
||||
return dir
|
||||
|
||||
if system == "win32":
|
||||
try:
|
||||
import win32com.shell
|
||||
_get_win_folder = _get_win_folder_with_pywin32
|
||||
except ImportError:
|
||||
try:
|
||||
from ctypes import windll
|
||||
_get_win_folder = _get_win_folder_with_ctypes
|
||||
except ImportError:
|
||||
try:
|
||||
import com.sun.jna
|
||||
_get_win_folder = _get_win_folder_with_jna
|
||||
except ImportError:
|
||||
_get_win_folder = _get_win_folder_from_registry
|
||||
|
||||
|
||||
#---- self test code
|
||||
|
||||
if __name__ == "__main__":
|
||||
appname = "MyApp"
|
||||
appauthor = "MyCompany"
|
||||
|
||||
props = ("user_data_dir", "site_data_dir",
|
||||
"user_config_dir", "site_config_dir",
|
||||
"user_cache_dir", "user_log_dir")
|
||||
|
||||
print("-- app dirs (with optional 'version')")
|
||||
dirs = AppDirs(appname, appauthor, version="1.0")
|
||||
for prop in props:
|
||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
||||
|
||||
print("\n-- app dirs (without optional 'version')")
|
||||
dirs = AppDirs(appname, appauthor)
|
||||
for prop in props:
|
||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
||||
|
||||
print("\n-- app dirs (without optional 'appauthor')")
|
||||
dirs = AppDirs(appname)
|
||||
for prop in props:
|
||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
||||
|
||||
print("\n-- app dirs (with disabled 'appauthor')")
|
||||
dirs = AppDirs(appname, appauthor=False)
|
||||
for prop in props:
|
||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
# Copyright 2013 Dean Gardiner <gardiner91@gmail.com>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from asio.file import SEEK_ORIGIN_CURRENT
|
||||
from asio.file_opener import FileOpener
|
||||
from asio.open_parameters import OpenParameters
|
||||
from asio.interfaces.posix import PosixInterface
|
||||
from asio.interfaces.windows import WindowsInterface
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class ASIO(object):
|
||||
platform_handler = None
|
||||
|
||||
@classmethod
|
||||
def get_handler(cls):
|
||||
if cls.platform_handler:
|
||||
return cls.platform_handler
|
||||
|
||||
if os.name == 'nt':
|
||||
cls.platform_handler = WindowsInterface
|
||||
elif os.name == 'posix':
|
||||
cls.platform_handler = PosixInterface
|
||||
else:
|
||||
raise NotImplementedError()
|
||||
|
||||
return cls.platform_handler
|
||||
|
||||
@classmethod
|
||||
def open(cls, file_path, opener=True, parameters=None):
|
||||
"""Open file
|
||||
|
||||
:type file_path: str
|
||||
|
||||
:param opener: Use FileOpener, for use with the 'with' statement
|
||||
:type opener: bool
|
||||
|
||||
:rtype: asio.file.File
|
||||
"""
|
||||
if not parameters:
|
||||
parameters = OpenParameters()
|
||||
|
||||
if opener:
|
||||
return FileOpener(file_path, parameters)
|
||||
|
||||
return ASIO.get_handler().open(
|
||||
file_path,
|
||||
parameters=parameters.handlers.get(ASIO.get_handler())
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
# Copyright 2013 Dean Gardiner <gardiner91@gmail.com>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from io import RawIOBase
|
||||
import time
|
||||
|
||||
DEFAULT_BUFFER_SIZE = 4096
|
||||
|
||||
SEEK_ORIGIN_BEGIN = 0
|
||||
SEEK_ORIGIN_CURRENT = 1
|
||||
SEEK_ORIGIN_END = 2
|
||||
|
||||
|
||||
class ReadTimeoutError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class File(RawIOBase):
|
||||
platform_handler = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(File, self).__init__(*args, **kwargs)
|
||||
|
||||
def get_handler(self):
|
||||
"""
|
||||
:rtype: asio.interfaces.base.Interface
|
||||
"""
|
||||
if not self.platform_handler:
|
||||
raise ValueError()
|
||||
|
||||
return self.platform_handler
|
||||
|
||||
def get_size(self):
|
||||
"""Get the current file size
|
||||
|
||||
:rtype: int
|
||||
"""
|
||||
return self.get_handler().get_size(self)
|
||||
|
||||
def get_path(self):
|
||||
"""Get the path of this file
|
||||
|
||||
:rtype: str
|
||||
"""
|
||||
return self.get_handler().get_path(self)
|
||||
|
||||
def seek(self, offset, origin):
|
||||
"""Sets a reference point of a file to the given value.
|
||||
|
||||
:param offset: The point relative to origin to move
|
||||
:type offset: int
|
||||
|
||||
:param origin: Reference point to seek (SEEK_ORIGIN_BEGIN, SEEK_ORIGIN_CURRENT, SEEK_ORIGIN_END)
|
||||
:type origin: int
|
||||
"""
|
||||
return self.get_handler().seek(self, offset, origin)
|
||||
|
||||
def read(self, n=-1):
|
||||
"""Read up to n bytes from the object and return them.
|
||||
|
||||
:type n: int
|
||||
:rtype: str
|
||||
"""
|
||||
return self.get_handler().read(self, n)
|
||||
|
||||
def readinto(self, b):
|
||||
"""Read up to len(b) bytes into bytearray b and return the number of bytes read."""
|
||||
data = self.read(len(b))
|
||||
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
b[:len(data)] = data
|
||||
return len(data)
|
||||
|
||||
def close(self):
|
||||
"""Close the file handle"""
|
||||
return self.get_handler().close(self)
|
||||
|
||||
def readable(self, *args, **kwargs):
|
||||
return True
|
||||
@@ -0,0 +1,21 @@
|
||||
class FileOpener(object):
|
||||
def __init__(self, file_path, parameters=None):
|
||||
self.file_path = file_path
|
||||
self.parameters = parameters
|
||||
|
||||
self.file = None
|
||||
|
||||
def __enter__(self):
|
||||
self.file = ASIO.get_handler().open(
|
||||
self.file_path,
|
||||
self.parameters.handlers.get(ASIO.get_handler())
|
||||
)
|
||||
|
||||
return self.file
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if not self.file:
|
||||
return
|
||||
|
||||
self.file.close()
|
||||
self.file = None
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright 2013 Dean Gardiner <gardiner91@gmail.com>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from asio.file import DEFAULT_BUFFER_SIZE
|
||||
|
||||
|
||||
class Interface(object):
|
||||
@classmethod
|
||||
def open(cls, file_path, parameters=None):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def get_size(cls, fp):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def get_path(cls, fp):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def seek(cls, fp, pointer, distance):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def read(cls, fp, n=DEFAULT_BUFFER_SIZE):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def close(cls, fp):
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,123 @@
|
||||
# Copyright 2013 Dean Gardiner <gardiner91@gmail.com>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from asio.file import File, DEFAULT_BUFFER_SIZE
|
||||
from asio.interfaces.base import Interface
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
if os.name == 'posix':
|
||||
import select
|
||||
|
||||
# fcntl is only required on darwin
|
||||
if sys.platform == 'darwin':
|
||||
import fcntl
|
||||
|
||||
F_GETPATH = 50
|
||||
|
||||
|
||||
class PosixInterface(Interface):
|
||||
@classmethod
|
||||
def open(cls, file_path, parameters=None):
|
||||
"""
|
||||
:type file_path: str
|
||||
:rtype: asio.interfaces.posix.PosixFile
|
||||
"""
|
||||
if not parameters:
|
||||
parameters = {}
|
||||
|
||||
if not parameters.get('mode'):
|
||||
parameters.pop('mode')
|
||||
|
||||
if not parameters.get('buffering'):
|
||||
parameters.pop('buffering')
|
||||
|
||||
fd = os.open(file_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
|
||||
return PosixFile(fd)
|
||||
|
||||
@classmethod
|
||||
def get_size(cls, fp):
|
||||
"""
|
||||
:type fp: asio.interfaces.posix.PosixFile
|
||||
:rtype: int
|
||||
"""
|
||||
return os.fstat(fp.fd).st_size
|
||||
|
||||
@classmethod
|
||||
def get_path(cls, fp):
|
||||
"""
|
||||
:type fp: asio.interfaces.posix.PosixFile
|
||||
:rtype: int
|
||||
"""
|
||||
|
||||
# readlink /dev/fd fails on darwin, so instead use fcntl F_GETPATH
|
||||
if sys.platform == 'darwin':
|
||||
return fcntl.fcntl(fp.fd, F_GETPATH, '\0' * 1024).rstrip('\0')
|
||||
|
||||
# Use /proc/self/fd if available
|
||||
if os.path.lexists("/proc/self/fd/"):
|
||||
return os.readlink("/proc/self/fd/%s" % fp.fd)
|
||||
|
||||
# Fallback to /dev/fd
|
||||
if os.path.lexists("/dev/fd/"):
|
||||
return os.readlink("/dev/fd/%s" % fp.fd)
|
||||
|
||||
raise NotImplementedError('Environment not supported (fdescfs not mounted?)')
|
||||
|
||||
@classmethod
|
||||
def seek(cls, fp, offset, origin):
|
||||
"""
|
||||
:type fp: asio.interfaces.posix.PosixFile
|
||||
:type offset: int
|
||||
:type origin: int
|
||||
"""
|
||||
os.lseek(fp.fd, offset, origin)
|
||||
|
||||
@classmethod
|
||||
def read(cls, fp, n=DEFAULT_BUFFER_SIZE):
|
||||
"""
|
||||
:type fp: asio.interfaces.posix.PosixFile
|
||||
:type n: int
|
||||
:rtype: str
|
||||
"""
|
||||
r, w, x = select.select([fp.fd], [], [], 5)
|
||||
|
||||
if r:
|
||||
return os.read(fp.fd, n)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def close(cls, fp):
|
||||
"""
|
||||
:type fp: asio.interfaces.posix.PosixFile
|
||||
"""
|
||||
os.close(fp.fd)
|
||||
|
||||
|
||||
class PosixFile(File):
|
||||
platform_handler = PosixInterface
|
||||
|
||||
def __init__(self, fd, *args, **kwargs):
|
||||
"""
|
||||
:type fd: asio.file.File
|
||||
"""
|
||||
super(PosixFile, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fd = fd
|
||||
|
||||
def __str__(self):
|
||||
return "<asio_posix.PosixFile file: %s>" % self.fd
|
||||
@@ -0,0 +1,201 @@
|
||||
# Copyright 2013 Dean Gardiner <gardiner91@gmail.com>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from asio.file import File, DEFAULT_BUFFER_SIZE
|
||||
from asio.interfaces.base import Interface
|
||||
|
||||
import os
|
||||
|
||||
|
||||
NULL = 0
|
||||
|
||||
if os.name == 'nt':
|
||||
from asio.interfaces.windows.interop import WindowsInterop
|
||||
|
||||
|
||||
class WindowsInterface(Interface):
|
||||
@classmethod
|
||||
def open(cls, file_path, parameters=None):
|
||||
"""
|
||||
:type file_path: str
|
||||
:rtype: asio.interfaces.windows.WindowsFile
|
||||
"""
|
||||
if not parameters:
|
||||
parameters = {}
|
||||
|
||||
return WindowsFile(WindowsInterop.create_file(
|
||||
file_path,
|
||||
parameters.get('desired_access', WindowsInterface.GenericAccess.READ),
|
||||
parameters.get('share_mode', WindowsInterface.ShareMode.ALL),
|
||||
parameters.get('creation_disposition', WindowsInterface.CreationDisposition.OPEN_EXISTING),
|
||||
parameters.get('flags_and_attributes', NULL)
|
||||
))
|
||||
|
||||
@classmethod
|
||||
def get_size(cls, fp):
|
||||
"""
|
||||
:type fp: asio.interfaces.windows.WindowsFile
|
||||
:rtype: int
|
||||
"""
|
||||
return WindowsInterop.get_file_size(fp.handle)
|
||||
|
||||
@classmethod
|
||||
def get_path(cls, fp):
|
||||
"""
|
||||
:type fp: asio.interfaces.windows.WindowsFile
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
if not fp.file_map:
|
||||
fp.file_map = WindowsInterop.create_file_mapping(fp.handle, WindowsInterface.Protection.READONLY)
|
||||
|
||||
if not fp.map_view:
|
||||
fp.map_view = WindowsInterop.map_view_of_file(fp.file_map, WindowsInterface.FileMapAccess.READ, 1)
|
||||
|
||||
file_name = WindowsInterop.get_mapped_file_name(fp.map_view)
|
||||
|
||||
return file_name
|
||||
|
||||
@classmethod
|
||||
def seek(cls, fp, offset, origin):
|
||||
"""
|
||||
:type fp: asio.interfaces.windows.WindowsFile
|
||||
:type offset: int
|
||||
:type origin: int
|
||||
:rtype: int
|
||||
"""
|
||||
|
||||
return WindowsInterop.set_file_pointer(
|
||||
fp.handle,
|
||||
offset,
|
||||
origin
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def read(cls, fp, n=DEFAULT_BUFFER_SIZE):
|
||||
"""
|
||||
:type fp: asio.interfaces.windows.WindowsFile
|
||||
:type n: int
|
||||
:rtype: str
|
||||
"""
|
||||
return WindowsInterop.read(fp.handle, n)
|
||||
|
||||
@classmethod
|
||||
def read_into(cls, fp, b):
|
||||
"""
|
||||
:type fp: asio.interfaces.windows.WindowsFile
|
||||
:type b: str
|
||||
:rtype: int
|
||||
"""
|
||||
return WindowsInterop.read_into(fp.handle, b)
|
||||
|
||||
@classmethod
|
||||
def close(cls, fp):
|
||||
"""
|
||||
:type fp: asio.interfaces.windows.WindowsFile
|
||||
:rtype: bool
|
||||
"""
|
||||
if fp.map_view:
|
||||
WindowsInterop.unmap_view_of_file(fp.map_view)
|
||||
|
||||
if fp.file_map:
|
||||
WindowsInterop.close_handle(fp.file_map)
|
||||
|
||||
return bool(WindowsInterop.close_handle(fp.handle))
|
||||
|
||||
class GenericAccess(object):
|
||||
READ = 0x80000000
|
||||
WRITE = 0x40000000
|
||||
EXECUTE = 0x20000000
|
||||
ALL = 0x10000000
|
||||
|
||||
class ShareMode(object):
|
||||
READ = 0x00000001
|
||||
WRITE = 0x00000002
|
||||
DELETE = 0x00000004
|
||||
ALL = READ | WRITE | DELETE
|
||||
|
||||
class CreationDisposition(object):
|
||||
CREATE_NEW = 1
|
||||
CREATE_ALWAYS = 2
|
||||
OPEN_EXISTING = 3
|
||||
OPEN_ALWAYS = 4
|
||||
TRUNCATE_EXISTING = 5
|
||||
|
||||
class Attribute(object):
|
||||
READONLY = 0x00000001
|
||||
HIDDEN = 0x00000002
|
||||
SYSTEM = 0x00000004
|
||||
DIRECTORY = 0x00000010
|
||||
ARCHIVE = 0x00000020
|
||||
DEVICE = 0x00000040
|
||||
NORMAL = 0x00000080
|
||||
TEMPORARY = 0x00000100
|
||||
SPARSE_FILE = 0x00000200
|
||||
REPARSE_POINT = 0x00000400
|
||||
COMPRESSED = 0x00000800
|
||||
OFFLINE = 0x00001000
|
||||
NOT_CONTENT_INDEXED = 0x00002000
|
||||
ENCRYPTED = 0x00004000
|
||||
|
||||
class Flag(object):
|
||||
WRITE_THROUGH = 0x80000000
|
||||
OVERLAPPED = 0x40000000
|
||||
NO_BUFFERING = 0x20000000
|
||||
RANDOM_ACCESS = 0x10000000
|
||||
SEQUENTIAL_SCAN = 0x08000000
|
||||
DELETE_ON_CLOSE = 0x04000000
|
||||
BACKUP_SEMANTICS = 0x02000000
|
||||
POSIX_SEMANTICS = 0x01000000
|
||||
OPEN_REPARSE_POINT = 0x00200000
|
||||
OPEN_NO_RECALL = 0x00100000
|
||||
FIRST_PIPE_INSTANCE = 0x00080000
|
||||
|
||||
class Protection(object):
|
||||
NOACCESS = 0x01
|
||||
READONLY = 0x02
|
||||
READWRITE = 0x04
|
||||
WRITECOPY = 0x08
|
||||
EXECUTE = 0x10
|
||||
EXECUTE_READ = 0x20,
|
||||
EXECUTE_READWRITE = 0x40
|
||||
EXECUTE_WRITECOPY = 0x80
|
||||
GUARD = 0x100
|
||||
NOCACHE = 0x200
|
||||
WRITECOMBINE = 0x400
|
||||
|
||||
class FileMapAccess(object):
|
||||
COPY = 0x0001
|
||||
WRITE = 0x0002
|
||||
READ = 0x0004
|
||||
ALL_ACCESS = 0x001f
|
||||
EXECUTE = 0x0020
|
||||
|
||||
|
||||
class WindowsFile(File):
|
||||
platform_handler = WindowsInterface
|
||||
|
||||
def __init__(self, handle, *args, **kwargs):
|
||||
super(WindowsFile, self).__init__(*args, **kwargs)
|
||||
|
||||
self.handle = handle
|
||||
|
||||
self.file_map = None
|
||||
self.map_view = None
|
||||
|
||||
def readinto(self, b):
|
||||
return self.get_handler().read_into(self, b)
|
||||
|
||||
def __str__(self):
|
||||
return "<asio_windows.WindowsFile file: %s>" % self.handle
|
||||
@@ -0,0 +1,230 @@
|
||||
# Copyright 2013 Dean Gardiner <gardiner91@gmail.com>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from ctypes.wintypes import *
|
||||
from ctypes import *
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CreateFileW = windll.kernel32.CreateFileW
|
||||
CreateFileW.argtypes = (LPCWSTR, DWORD, DWORD, c_void_p, DWORD, DWORD, HANDLE)
|
||||
CreateFileW.restype = HANDLE
|
||||
|
||||
ReadFile = windll.kernel32.ReadFile
|
||||
ReadFile.argtypes = (HANDLE, c_void_p, DWORD, POINTER(DWORD), HANDLE)
|
||||
ReadFile.restype = BOOL
|
||||
|
||||
|
||||
NULL = 0
|
||||
MAX_PATH = 260
|
||||
DEFAULT_BUFFER_SIZE = 4096
|
||||
LPSECURITY_ATTRIBUTES = c_void_p
|
||||
|
||||
|
||||
class WindowsInterop(object):
|
||||
ri_buffer = None
|
||||
|
||||
@classmethod
|
||||
def create_file(cls, path, desired_access, share_mode, creation_disposition, flags_and_attributes):
|
||||
h = CreateFileW(
|
||||
path,
|
||||
desired_access,
|
||||
share_mode,
|
||||
NULL,
|
||||
creation_disposition,
|
||||
flags_and_attributes,
|
||||
NULL
|
||||
)
|
||||
|
||||
error = GetLastError()
|
||||
if error != 0:
|
||||
raise Exception('[WindowsASIO.open] "%s"' % FormatError(error))
|
||||
|
||||
return h
|
||||
|
||||
@classmethod
|
||||
def read(cls, handle, buf_size=DEFAULT_BUFFER_SIZE):
|
||||
buf = create_string_buffer(buf_size)
|
||||
bytes_read = c_ulong(0)
|
||||
|
||||
success = ReadFile(handle, buf, buf_size, byref(bytes_read), NULL)
|
||||
|
||||
error = GetLastError()
|
||||
if error:
|
||||
log.debug('read_file - error: (%s) "%s"', error, FormatError(error))
|
||||
|
||||
if not success and error:
|
||||
raise Exception('[WindowsInterop.read_file] (%s) "%s"' % (error, FormatError(error)))
|
||||
|
||||
# Return if we have a valid buffer
|
||||
if success and bytes_read.value:
|
||||
return buf.value
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def read_into(cls, handle, b):
|
||||
if cls.ri_buffer is None or len(cls.ri_buffer) < len(b):
|
||||
cls.ri_buffer = create_string_buffer(len(b))
|
||||
|
||||
bytes_read = c_ulong(0)
|
||||
|
||||
success = ReadFile(handle, cls.ri_buffer, len(b), byref(bytes_read), NULL)
|
||||
bytes_read = int(bytes_read.value)
|
||||
|
||||
b[:bytes_read] = cls.ri_buffer[:bytes_read]
|
||||
|
||||
error = GetLastError()
|
||||
|
||||
if not success and error:
|
||||
raise Exception('[WindowsInterop.read_file] (%s) "%s"' % (error, FormatError(error)))
|
||||
|
||||
# Return if we have a valid buffer
|
||||
if success and bytes_read:
|
||||
return bytes_read
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def set_file_pointer(cls, handle, distance, method):
|
||||
pos_high = DWORD(NULL)
|
||||
|
||||
result = windll.kernel32.SetFilePointer(
|
||||
handle,
|
||||
c_ulong(distance),
|
||||
byref(pos_high),
|
||||
DWORD(method)
|
||||
)
|
||||
|
||||
if result == -1:
|
||||
raise Exception('[WindowsASIO.seek] INVALID_SET_FILE_POINTER: "%s"' % FormatError(GetLastError()))
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_file_size(cls, handle):
|
||||
return windll.kernel32.GetFileSize(
|
||||
handle,
|
||||
DWORD(NULL)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def close_handle(cls, handle):
|
||||
return windll.kernel32.CloseHandle(handle)
|
||||
|
||||
@classmethod
|
||||
def create_file_mapping(cls, handle, protect, maximum_size_high=0, maximum_size_low=1):
|
||||
return HANDLE(windll.kernel32.CreateFileMappingW(
|
||||
handle,
|
||||
LPSECURITY_ATTRIBUTES(NULL),
|
||||
DWORD(protect),
|
||||
DWORD(maximum_size_high),
|
||||
DWORD(maximum_size_low),
|
||||
LPCSTR(NULL)
|
||||
))
|
||||
|
||||
@classmethod
|
||||
def map_view_of_file(cls, map_handle, desired_access, num_bytes, file_offset_high=0, file_offset_low=0):
|
||||
return HANDLE(windll.kernel32.MapViewOfFile(
|
||||
map_handle,
|
||||
DWORD(desired_access),
|
||||
DWORD(file_offset_high),
|
||||
DWORD(file_offset_low),
|
||||
num_bytes
|
||||
))
|
||||
|
||||
@classmethod
|
||||
def unmap_view_of_file(cls, view_handle):
|
||||
return windll.kernel32.UnmapViewOfFile(view_handle)
|
||||
|
||||
@classmethod
|
||||
def get_mapped_file_name(cls, view_handle, translate_device_name=True):
|
||||
buf = create_string_buffer(MAX_PATH + 1)
|
||||
|
||||
result = windll.psapi.GetMappedFileNameW(
|
||||
cls.get_current_process(),
|
||||
view_handle,
|
||||
buf,
|
||||
MAX_PATH
|
||||
)
|
||||
|
||||
# Raise exception on error
|
||||
error = GetLastError()
|
||||
if result == 0:
|
||||
raise Exception(FormatError(error))
|
||||
|
||||
# Retrieve a clean file name (skipping over NUL bytes)
|
||||
file_name = cls.clean_buffer_value(buf)
|
||||
|
||||
# If we are not translating the device name return here
|
||||
if not translate_device_name:
|
||||
return file_name
|
||||
|
||||
drives = cls.get_logical_drive_strings()
|
||||
|
||||
# Find the drive matching the file_name device name
|
||||
translated = False
|
||||
for drive in drives:
|
||||
device_name = cls.query_dos_device(drive)
|
||||
|
||||
if file_name.startswith(device_name):
|
||||
file_name = drive + file_name[len(device_name):]
|
||||
translated = True
|
||||
break
|
||||
|
||||
if not translated:
|
||||
raise Exception('Unable to translate device name')
|
||||
|
||||
return file_name
|
||||
|
||||
@classmethod
|
||||
def get_logical_drive_strings(cls, buf_size=512):
|
||||
buf = create_string_buffer(buf_size)
|
||||
|
||||
result = windll.kernel32.GetLogicalDriveStringsW(buf_size, buf)
|
||||
|
||||
error = GetLastError()
|
||||
if result == 0:
|
||||
raise Exception(FormatError(error))
|
||||
|
||||
drive_strings = cls.clean_buffer_value(buf)
|
||||
return [dr for dr in drive_strings.split('\\') if dr != '']
|
||||
|
||||
@classmethod
|
||||
def query_dos_device(cls, drive, buf_size=MAX_PATH):
|
||||
buf = create_string_buffer(buf_size)
|
||||
|
||||
result = windll.kernel32.QueryDosDeviceA(
|
||||
drive,
|
||||
buf,
|
||||
buf_size
|
||||
)
|
||||
|
||||
return cls.clean_buffer_value(buf)
|
||||
|
||||
@classmethod
|
||||
def get_current_process(cls):
|
||||
return HANDLE(windll.kernel32.GetCurrentProcess())
|
||||
|
||||
@classmethod
|
||||
def clean_buffer_value(cls, buf):
|
||||
value = ""
|
||||
|
||||
for ch in buf.raw:
|
||||
if ord(ch) != 0:
|
||||
value += ch
|
||||
|
||||
return value
|
||||
@@ -0,0 +1,47 @@
|
||||
from asio.interfaces.posix import PosixInterface
|
||||
from asio.interfaces.windows import WindowsInterface
|
||||
|
||||
|
||||
class OpenParameters(object):
|
||||
def __init__(self):
|
||||
self.handlers = {}
|
||||
|
||||
# Update handler_parameters with defaults
|
||||
self.posix()
|
||||
self.windows()
|
||||
|
||||
def posix(self, mode=None, buffering=None):
|
||||
"""
|
||||
:type mode: str
|
||||
:type buffering: int
|
||||
"""
|
||||
self.handlers.update({PosixInterface: {
|
||||
'mode': mode,
|
||||
'buffering': buffering
|
||||
}})
|
||||
|
||||
def windows(self, desired_access=WindowsInterface.GenericAccess.READ,
|
||||
share_mode=WindowsInterface.ShareMode.ALL,
|
||||
creation_disposition=WindowsInterface.CreationDisposition.OPEN_EXISTING,
|
||||
flags_and_attributes=0):
|
||||
|
||||
"""
|
||||
:param desired_access: WindowsInterface.DesiredAccess
|
||||
:type desired_access: int
|
||||
|
||||
:param share_mode: WindowsInterface.ShareMode
|
||||
:type share_mode: int
|
||||
|
||||
:param creation_disposition: WindowsInterface.CreationDisposition
|
||||
:type creation_disposition: int
|
||||
|
||||
:param flags_and_attributes: WindowsInterface.Attribute, WindowsInterface.Flag
|
||||
:type flags_and_attributes: int
|
||||
"""
|
||||
|
||||
self.handlers.update({WindowsInterface: {
|
||||
'desired_access': desired_access,
|
||||
'share_mode': share_mode,
|
||||
'creation_disposition': creation_disposition,
|
||||
'flags_and_attributes': flags_and_attributes
|
||||
}})
|
||||
@@ -17,7 +17,7 @@ class OpenSubtitlesConverter(LanguageReverseConverter):
|
||||
self.to_opensubtitles = {('por', 'BR'): 'pob', ('gre', None): 'ell', ('srp', None): 'scc', ('srp', 'ME'): 'mne'}
|
||||
self.from_opensubtitles = CaseInsensitiveDict({'pob': ('por', 'BR'), 'pb': ('por', 'BR'), 'ell': ('ell', None),
|
||||
'scc': ('srp', None), 'mne': ('srp', 'ME')})
|
||||
self.codes = (self.alpha2_converter.codes | self.alpha3b_converter.codes | set(['pob', 'pb', 'scc', 'mne']))
|
||||
self.codes = (self.alpha2_converter.codes | self.alpha3b_converter.codes | set(self.from_opensubtitles.keys()))
|
||||
|
||||
def convert(self, alpha3, country=None, script=None):
|
||||
alpha3b = self.alpha3b_converter.convert(alpha3, country, script)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
# Use of this source code is governed by the 3-clause BSD license
|
||||
# that can be found in the LICENSE file.
|
||||
#
|
||||
from __future__ import unicode_literals
|
||||
from collections import namedtuple
|
||||
from functools import partial
|
||||
from pkg_resources import resource_stream # @UnresolvedImport
|
||||
@@ -82,7 +81,10 @@ class Country(CountryMeta(str('CountryBase'), (object,), {})):
|
||||
self.alpha2 = state
|
||||
|
||||
def __getattr__(self, name):
|
||||
return country_converters[name].convert(self.alpha2)
|
||||
try:
|
||||
return country_converters[name].convert(self.alpha2)
|
||||
except KeyError:
|
||||
raise AttributeError(name)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.alpha2)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
# Use of this source code is governed by the 3-clause BSD license
|
||||
# that can be found in the LICENSE file.
|
||||
#
|
||||
from __future__ import unicode_literals
|
||||
from collections import namedtuple
|
||||
from functools import partial
|
||||
from pkg_resources import resource_stream # @UnresolvedImport
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
# Use of this source code is governed by the 3-clause BSD license
|
||||
# that can be found in the LICENSE file.
|
||||
#
|
||||
from __future__ import unicode_literals
|
||||
from collections import namedtuple
|
||||
from pkg_resources import resource_stream # @UnresolvedImport
|
||||
from . import basestr
|
||||
|
||||
@@ -212,7 +212,7 @@ class TestLanguage(TestCase, _Py26FixTestCase):
|
||||
self.assertEqual(Language.fromcode('pob', 'opensubtitles'), Language('por', 'BR'))
|
||||
self.assertRaises(LanguageReverseError, lambda: Language.fromopensubtitles('zzz'))
|
||||
self.assertRaises(LanguageConvertError, lambda: Language('aaa').opensubtitles)
|
||||
self.assertEqual(len(language_converters['opensubtitles'].codes), 606)
|
||||
self.assertEqual(len(language_converters['opensubtitles'].codes), 607)
|
||||
|
||||
# test with all the LANGUAGES from the opensubtitles api
|
||||
# downloaded from: http://www.opensubtitles.org/addons/export_languages.php
|
||||
@@ -228,6 +228,10 @@ class TestLanguage(TestCase, _Py26FixTestCase):
|
||||
self.assertEqual(Language.fromopensubtitles(idlang), Language.fromopensubtitles(alpha2))
|
||||
f.close()
|
||||
|
||||
def test_converter_opensubtitles_codes(self):
|
||||
for code in language_converters['opensubtitles'].from_opensubtitles.keys():
|
||||
self.assertIn(code, language_converters['opensubtitles'].codes)
|
||||
|
||||
def test_fromietf_country_script(self):
|
||||
language = Language.fromietf('fra-FR-Latn')
|
||||
self.assertEqual(language.alpha3, 'fra')
|
||||
@@ -283,6 +287,11 @@ class TestLanguage(TestCase, _Py26FixTestCase):
|
||||
self.assertTrue(hasattr(Language('fra'), 'alpha2'))
|
||||
self.assertFalse(hasattr(Language('bej'), 'alpha2'))
|
||||
|
||||
def test_country_hasattr(self):
|
||||
self.assertTrue(hasattr(Country('US'), 'name'))
|
||||
self.assertTrue(hasattr(Country('FR'), 'alpha2'))
|
||||
self.assertFalse(hasattr(Country('BE'), 'none'))
|
||||
|
||||
def test_country(self):
|
||||
self.assertEqual(Language('por', 'BR').country, Country('BR'))
|
||||
self.assertEqual(Language('eng', Country('US')).country, Country('US'))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
__path__ = __import__('pkgutil').extend_path(__path__, __name__)
|
||||
@@ -0,0 +1,196 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import functools
|
||||
from collections import namedtuple
|
||||
from threading import RLock
|
||||
|
||||
_CacheInfo = namedtuple("CacheInfo", ["hits", "misses", "maxsize", "currsize"])
|
||||
|
||||
|
||||
@functools.wraps(functools.update_wrapper)
|
||||
def update_wrapper(
|
||||
wrapper,
|
||||
wrapped,
|
||||
assigned=functools.WRAPPER_ASSIGNMENTS,
|
||||
updated=functools.WRAPPER_UPDATES,
|
||||
):
|
||||
"""
|
||||
Patch two bugs in functools.update_wrapper.
|
||||
"""
|
||||
# workaround for http://bugs.python.org/issue3445
|
||||
assigned = tuple(attr for attr in assigned if hasattr(wrapped, attr))
|
||||
wrapper = functools.update_wrapper(wrapper, wrapped, assigned, updated)
|
||||
# workaround for https://bugs.python.org/issue17482
|
||||
wrapper.__wrapped__ = wrapped
|
||||
return wrapper
|
||||
|
||||
|
||||
class _HashedSeq(list):
|
||||
__slots__ = 'hashvalue'
|
||||
|
||||
def __init__(self, tup, hash=hash):
|
||||
self[:] = tup
|
||||
self.hashvalue = hash(tup)
|
||||
|
||||
def __hash__(self):
|
||||
return self.hashvalue
|
||||
|
||||
|
||||
def _make_key(
|
||||
args,
|
||||
kwds,
|
||||
typed,
|
||||
kwd_mark=(object(),),
|
||||
fasttypes=set([int, str, frozenset, type(None)]),
|
||||
sorted=sorted,
|
||||
tuple=tuple,
|
||||
type=type,
|
||||
len=len,
|
||||
):
|
||||
'Make a cache key from optionally typed positional and keyword arguments'
|
||||
key = args
|
||||
if kwds:
|
||||
sorted_items = sorted(kwds.items())
|
||||
key += kwd_mark
|
||||
for item in sorted_items:
|
||||
key += item
|
||||
if typed:
|
||||
key += tuple(type(v) for v in args)
|
||||
if kwds:
|
||||
key += tuple(type(v) for k, v in sorted_items)
|
||||
elif len(key) == 1 and type(key[0]) in fasttypes:
|
||||
return key[0]
|
||||
return _HashedSeq(key)
|
||||
|
||||
|
||||
def lru_cache(maxsize=100, typed=False):
|
||||
"""Least-recently-used cache decorator.
|
||||
|
||||
If *maxsize* is set to None, the LRU features are disabled and the cache
|
||||
can grow without bound.
|
||||
|
||||
If *typed* is True, arguments of different types will be cached separately.
|
||||
For example, f(3.0) and f(3) will be treated as distinct calls with
|
||||
distinct results.
|
||||
|
||||
Arguments to the cached function must be hashable.
|
||||
|
||||
View the cache statistics named tuple (hits, misses, maxsize, currsize) with
|
||||
f.cache_info(). Clear the cache and statistics with f.cache_clear().
|
||||
Access the underlying function with f.__wrapped__.
|
||||
|
||||
See: http://en.wikipedia.org/wiki/Cache_algorithms#Least_Recently_Used
|
||||
|
||||
"""
|
||||
|
||||
# Users should only access the lru_cache through its public API:
|
||||
# cache_info, cache_clear, and f.__wrapped__
|
||||
# The internals of the lru_cache are encapsulated for thread safety and
|
||||
# to allow the implementation to change (including a possible C version).
|
||||
|
||||
def decorating_function(user_function):
|
||||
|
||||
cache = dict()
|
||||
stats = [0, 0] # make statistics updateable non-locally
|
||||
HITS, MISSES = 0, 1 # names for the stats fields
|
||||
make_key = _make_key
|
||||
cache_get = cache.get # bound method to lookup key or return None
|
||||
_len = len # localize the global len() function
|
||||
lock = RLock() # because linkedlist updates aren't threadsafe
|
||||
root = [] # root of the circular doubly linked list
|
||||
root[:] = [root, root, None, None] # initialize by pointing to self
|
||||
nonlocal_root = [root] # make updateable non-locally
|
||||
PREV, NEXT, KEY, RESULT = 0, 1, 2, 3 # names for the link fields
|
||||
|
||||
if maxsize == 0:
|
||||
|
||||
def wrapper(*args, **kwds):
|
||||
# no caching, just do a statistics update after a successful call
|
||||
result = user_function(*args, **kwds)
|
||||
stats[MISSES] += 1
|
||||
return result
|
||||
|
||||
elif maxsize is None:
|
||||
|
||||
def wrapper(*args, **kwds):
|
||||
# simple caching without ordering or size limit
|
||||
key = make_key(args, kwds, typed)
|
||||
result = cache_get(
|
||||
key, root
|
||||
) # root used here as a unique not-found sentinel
|
||||
if result is not root:
|
||||
stats[HITS] += 1
|
||||
return result
|
||||
result = user_function(*args, **kwds)
|
||||
cache[key] = result
|
||||
stats[MISSES] += 1
|
||||
return result
|
||||
|
||||
else:
|
||||
|
||||
def wrapper(*args, **kwds):
|
||||
# size limited caching that tracks accesses by recency
|
||||
key = make_key(args, kwds, typed) if kwds or typed else args
|
||||
with lock:
|
||||
link = cache_get(key)
|
||||
if link is not None:
|
||||
# record recent use of the key by moving it
|
||||
# to the front of the list
|
||||
root, = nonlocal_root
|
||||
link_prev, link_next, key, result = link
|
||||
link_prev[NEXT] = link_next
|
||||
link_next[PREV] = link_prev
|
||||
last = root[PREV]
|
||||
last[NEXT] = root[PREV] = link
|
||||
link[PREV] = last
|
||||
link[NEXT] = root
|
||||
stats[HITS] += 1
|
||||
return result
|
||||
result = user_function(*args, **kwds)
|
||||
with lock:
|
||||
root, = nonlocal_root
|
||||
if key in cache:
|
||||
# getting here means that this same key was added to the
|
||||
# cache while the lock was released. since the link
|
||||
# update is already done, we need only return the
|
||||
# computed result and update the count of misses.
|
||||
pass
|
||||
elif _len(cache) >= maxsize:
|
||||
# use the old root to store the new key and result
|
||||
oldroot = root
|
||||
oldroot[KEY] = key
|
||||
oldroot[RESULT] = result
|
||||
# empty the oldest link and make it the new root
|
||||
root = nonlocal_root[0] = oldroot[NEXT]
|
||||
oldkey = root[KEY]
|
||||
root[KEY] = root[RESULT] = None
|
||||
# now update the cache dictionary for the new links
|
||||
del cache[oldkey]
|
||||
cache[key] = oldroot
|
||||
else:
|
||||
# put result in a new link at the front of the list
|
||||
last = root[PREV]
|
||||
link = [last, root, key, result]
|
||||
last[NEXT] = root[PREV] = cache[key] = link
|
||||
stats[MISSES] += 1
|
||||
return result
|
||||
|
||||
def cache_info():
|
||||
"""Report cache statistics"""
|
||||
with lock:
|
||||
return _CacheInfo(stats[HITS], stats[MISSES], maxsize, len(cache))
|
||||
|
||||
def cache_clear():
|
||||
"""Clear the cache and cache statistics"""
|
||||
with lock:
|
||||
cache.clear()
|
||||
root = nonlocal_root[0]
|
||||
root[:] = [root, root, None, None]
|
||||
stats[:] = [0, 0]
|
||||
|
||||
wrapper.__wrapped__ = user_function
|
||||
wrapper.cache_info = cache_info
|
||||
wrapper.cache_clear = cache_clear
|
||||
return update_wrapper(wrapper, user_function)
|
||||
|
||||
return decorating_function
|
||||
@@ -1,6 +1,6 @@
|
||||
Beautiful Soup is made available under the MIT license:
|
||||
|
||||
Copyright (c) 2004-2012 Leonard Richardson
|
||||
Copyright (c) 2004-2015 Leonard Richardson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
@@ -20,7 +20,8 @@ Beautiful Soup is made available under the MIT license:
|
||||
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
||||
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE, DAMMIT.
|
||||
SOFTWARE.
|
||||
|
||||
Beautiful Soup incorporates code from the html5lib library, which is
|
||||
also made available under the MIT license.
|
||||
also made available under the MIT license. Copyright (c) 2006-2013
|
||||
James Graham and other contributors
|
||||
|
||||
@@ -1,3 +1,127 @@
|
||||
= 4.4.1 (20150928) =
|
||||
|
||||
* Fixed a bug that deranged the tree when part of it was
|
||||
removed. Thanks to Eric Weiser for the patch and John Wiseman for a
|
||||
test. [bug=1481520]
|
||||
|
||||
* Fixed a parse bug with the html5lib tree-builder. Thanks to Roel
|
||||
Kramer for the patch. [bug=1483781]
|
||||
|
||||
* Improved the implementation of CSS selector grouping. Thanks to
|
||||
Orangain for the patch. [bug=1484543]
|
||||
|
||||
* Fixed the test_detect_utf8 test so that it works when chardet is
|
||||
installed. [bug=1471359]
|
||||
|
||||
* Corrected the output of Declaration objects. [bug=1477847]
|
||||
|
||||
|
||||
= 4.4.0 (20150703) =
|
||||
|
||||
Especially important changes:
|
||||
|
||||
* Added a warning when you instantiate a BeautifulSoup object without
|
||||
explicitly naming a parser. [bug=1398866]
|
||||
|
||||
* __repr__ now returns an ASCII bytestring in Python 2, and a Unicode
|
||||
string in Python 3, instead of a UTF8-encoded bytestring in both
|
||||
versions. In Python 3, __str__ now returns a Unicode string instead
|
||||
of a bytestring. [bug=1420131]
|
||||
|
||||
* The `text` argument to the find_* methods is now called `string`,
|
||||
which is more accurate. `text` still works, but `string` is the
|
||||
argument described in the documentation. `text` may eventually
|
||||
change its meaning, but not for a very long time. [bug=1366856]
|
||||
|
||||
* Changed the way soup objects work under copy.copy(). Copying a
|
||||
NavigableString or a Tag will give you a new NavigableString that's
|
||||
equal to the old one but not connected to the parse tree. Patch by
|
||||
Martijn Peters. [bug=1307490]
|
||||
|
||||
* Started using a standard MIT license. [bug=1294662]
|
||||
|
||||
* Added a Chinese translation of the documentation by Delong .w.
|
||||
|
||||
New features:
|
||||
|
||||
* Introduced the select_one() method, which uses a CSS selector but
|
||||
only returns the first match, instead of a list of
|
||||
matches. [bug=1349367]
|
||||
|
||||
* You can now create a Tag object without specifying a
|
||||
TreeBuilder. Patch by Martijn Pieters. [bug=1307471]
|
||||
|
||||
* You can now create a NavigableString or a subclass just by invoking
|
||||
the constructor. [bug=1294315]
|
||||
|
||||
* Added an `exclude_encodings` argument to UnicodeDammit and to the
|
||||
Beautiful Soup constructor, which lets you prohibit the detection of
|
||||
an encoding that you know is wrong. [bug=1469408]
|
||||
|
||||
* The select() method now supports selector grouping. Patch by
|
||||
Francisco Canas [bug=1191917]
|
||||
|
||||
Bug fixes:
|
||||
|
||||
* Fixed yet another problem that caused the html5lib tree builder to
|
||||
create a disconnected parse tree. [bug=1237763]
|
||||
|
||||
* Force object_was_parsed() to keep the tree intact even when an element
|
||||
from later in the document is moved into place. [bug=1430633]
|
||||
|
||||
* Fixed yet another bug that caused a disconnected tree when html5lib
|
||||
copied an element from one part of the tree to another. [bug=1270611]
|
||||
|
||||
* Fixed a bug where Element.extract() could create an infinite loop in
|
||||
the remaining tree.
|
||||
|
||||
* The select() method can now find tags whose names contain
|
||||
dashes. Patch by Francisco Canas. [bug=1276211]
|
||||
|
||||
* The select() method can now find tags with attributes whose names
|
||||
contain dashes. Patch by Marek Kapolka. [bug=1304007]
|
||||
|
||||
* Improved the lxml tree builder's handling of processing
|
||||
instructions. [bug=1294645]
|
||||
|
||||
* Restored the helpful syntax error that happens when you try to
|
||||
import the Python 2 edition of Beautiful Soup under Python
|
||||
3. [bug=1213387]
|
||||
|
||||
* In Python 3.4 and above, set the new convert_charrefs argument to
|
||||
the html.parser constructor to avoid a warning and future
|
||||
failures. Patch by Stefano Revera. [bug=1375721]
|
||||
|
||||
* The warning when you pass in a filename or URL as markup will now be
|
||||
displayed correctly even if the filename or URL is a Unicode
|
||||
string. [bug=1268888]
|
||||
|
||||
* If the initial <html> tag contains a CDATA list attribute such as
|
||||
'class', the html5lib tree builder will now turn its value into a
|
||||
list, as it would with any other tag. [bug=1296481]
|
||||
|
||||
* Fixed an import error in Python 3.5 caused by the removal of the
|
||||
HTMLParseError class. [bug=1420063]
|
||||
|
||||
* Improved docstring for encode_contents() and
|
||||
decode_contents(). [bug=1441543]
|
||||
|
||||
* Fixed a crash in Unicode, Dammit's encoding detector when the name
|
||||
of the encoding itself contained invalid bytes. [bug=1360913]
|
||||
|
||||
* Improved the exception raised when you call .unwrap() or
|
||||
.replace_with() on an element that's not attached to a tree.
|
||||
|
||||
* Raise a NotImplementedError whenever an unsupported CSS pseudoclass
|
||||
is used in select(). Previously some cases did not result in a
|
||||
NotImplementedError.
|
||||
|
||||
* It's now possible to pickle a BeautifulSoup object no matter which
|
||||
tree builder was used to create it. However, the only tree builder
|
||||
that survives the pickling process is the HTMLParserTreeBuilder
|
||||
('html.parser'). If you unpickle a BeautifulSoup object created with
|
||||
some other tree builder, soup.builder will be None. [bug=1231545]
|
||||
|
||||
= 4.3.2 (20131002) =
|
||||
|
||||
* Fixed a bug in which short Unicode input was improperly encoded to
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
Additions
|
||||
---------
|
||||
|
||||
More of the jQuery API: nextUntil?
|
||||
|
||||
Optimizations
|
||||
-------------
|
||||
|
||||
The html5lib tree builder doesn't use the standard tree-building API,
|
||||
which worries me and has resulted in a number of bugs.
|
||||
|
||||
markup_attr_map can be optimized since it's always a map now.
|
||||
|
||||
Upon encountering UTF-16LE data or some other uncommon serialization
|
||||
of Unicode, UnicodeDammit will convert the data to Unicode, then
|
||||
encode it at UTF-8. This is wasteful because it will just get decoded
|
||||
back to Unicode.
|
||||
|
||||
CDATA
|
||||
-----
|
||||
|
||||
The elementtree XMLParser has a strip_cdata argument that, when set to
|
||||
False, should allow Beautiful Soup to preserve CDATA sections instead
|
||||
of treating them as text. Except it doesn't. (This argument is also
|
||||
present for HTMLParser, and also does nothing there.)
|
||||
|
||||
Currently, htm5lib converts CDATA sections into comments. An
|
||||
as-yet-unreleased version of html5lib changes the parser's handling of
|
||||
CDATA sections to allow CDATA sections in tags like <svg> and
|
||||
<math>. The HTML5TreeBuilder will need to be updated to create CData
|
||||
objects instead of Comment objects in this situation.
|
||||
@@ -5,26 +5,31 @@ http://www.crummy.com/software/BeautifulSoup/
|
||||
|
||||
Beautiful Soup uses a pluggable XML or HTML parser to parse a
|
||||
(possibly invalid) document into a tree representation. Beautiful Soup
|
||||
provides provides methods and Pythonic idioms that make it easy to
|
||||
navigate, search, and modify the parse tree.
|
||||
provides methods and Pythonic idioms that make it easy to navigate,
|
||||
search, and modify the parse tree.
|
||||
|
||||
Beautiful Soup works with Python 2.6 and up. It works better if lxml
|
||||
Beautiful Soup works with Python 2.7 and up. It works better if lxml
|
||||
and/or html5lib is installed.
|
||||
|
||||
For more than you ever wanted to know about Beautiful Soup, see the
|
||||
documentation:
|
||||
http://www.crummy.com/software/BeautifulSoup/bs4/doc/
|
||||
|
||||
"""
|
||||
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
__author__ = "Leonard Richardson (leonardr@segfault.org)"
|
||||
__version__ = "4.3.2"
|
||||
__copyright__ = "Copyright (c) 2004-2013 Leonard Richardson"
|
||||
__version__ = "4.6.0"
|
||||
__copyright__ = "Copyright (c) 2004-2017 Leonard Richardson"
|
||||
__license__ = "MIT"
|
||||
|
||||
__all__ = ['BeautifulSoup']
|
||||
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
import warnings
|
||||
|
||||
from .builder import builder_registry, ParserRejectedMarkup
|
||||
@@ -45,7 +50,7 @@ from .element import (
|
||||
|
||||
# The very first thing we do is give a useful error if someone is
|
||||
# running this code under Python 3 without converting it.
|
||||
syntax_error = u'You are trying to run the Python 2 version of Beautiful Soup under Python 3. This will not work. You need to convert the code, either by installing it (`python setup.py install`) or by running 2to3 (`2to3 -w bs4`).'
|
||||
'You are trying to run the Python 2 version of Beautiful Soup under Python 3. This will not work.'<>'You need to convert the code, either by installing it (`python setup.py install`) or by running 2to3 (`2to3 -w bs4`).'
|
||||
|
||||
class BeautifulSoup(Tag):
|
||||
"""
|
||||
@@ -77,8 +82,11 @@ class BeautifulSoup(Tag):
|
||||
|
||||
ASCII_SPACES = '\x20\x0a\x09\x0c\x0d'
|
||||
|
||||
NO_PARSER_SPECIFIED_WARNING = "No parser was explicitly specified, so I'm using the best available %(markup_type)s parser for this system (\"%(parser)s\"). This usually isn't a problem, but if you run this code on another system, or in a different virtual environment, it may use a different parser and behave differently.\n\nThe code that caused this warning is on line %(line_number)s of the file %(filename)s. To get rid of this warning, change code that looks like this:\n\n BeautifulSoup(YOUR_MARKUP})\n\nto this:\n\n BeautifulSoup(YOUR_MARKUP, \"%(parser)s\")\n"
|
||||
|
||||
def __init__(self, markup="", features=None, builder=None,
|
||||
parse_only=None, from_encoding=None, **kwargs):
|
||||
parse_only=None, from_encoding=None, exclude_encodings=None,
|
||||
**kwargs):
|
||||
"""The Soup object is initialized as the 'root tag', and the
|
||||
provided markup (which can be a string or a file-like object)
|
||||
is fed into the underlying parser."""
|
||||
@@ -114,9 +122,9 @@ class BeautifulSoup(Tag):
|
||||
del kwargs['isHTML']
|
||||
warnings.warn(
|
||||
"BS4 does not respect the isHTML argument to the "
|
||||
"BeautifulSoup constructor. You can pass in features='html' "
|
||||
"or features='xml' to get a builder capable of handling "
|
||||
"one or the other.")
|
||||
"BeautifulSoup constructor. Suggest you use "
|
||||
"features='lxml' for HTML and features='lxml-xml' for "
|
||||
"XML.")
|
||||
|
||||
def deprecated_argument(old_name, new_name):
|
||||
if old_name in kwargs:
|
||||
@@ -134,12 +142,17 @@ class BeautifulSoup(Tag):
|
||||
from_encoding = from_encoding or deprecated_argument(
|
||||
"fromEncoding", "from_encoding")
|
||||
|
||||
if from_encoding and isinstance(markup, unicode):
|
||||
warnings.warn("You provided Unicode markup but also provided a value for from_encoding. Your from_encoding will be ignored.")
|
||||
from_encoding = None
|
||||
|
||||
if len(kwargs) > 0:
|
||||
arg = kwargs.keys().pop()
|
||||
raise TypeError(
|
||||
"__init__() got an unexpected keyword argument '%s'" % arg)
|
||||
|
||||
if builder is None:
|
||||
original_features = features
|
||||
if isinstance(features, basestring):
|
||||
features = [features]
|
||||
if features is None or len(features) == 0:
|
||||
@@ -151,15 +164,35 @@ class BeautifulSoup(Tag):
|
||||
"requested: %s. Do you need to install a parser library?"
|
||||
% ",".join(features))
|
||||
builder = builder_class()
|
||||
if not (original_features == builder.NAME or
|
||||
original_features in builder.ALTERNATE_NAMES):
|
||||
if builder.is_xml:
|
||||
markup_type = "XML"
|
||||
else:
|
||||
markup_type = "HTML"
|
||||
|
||||
caller = traceback.extract_stack()[0]
|
||||
filename = caller[0]
|
||||
line_number = caller[1]
|
||||
warnings.warn(self.NO_PARSER_SPECIFIED_WARNING % dict(
|
||||
filename=filename,
|
||||
line_number=line_number,
|
||||
parser=builder.NAME,
|
||||
markup_type=markup_type))
|
||||
|
||||
self.builder = builder
|
||||
self.is_xml = builder.is_xml
|
||||
self.known_xml = self.is_xml
|
||||
self.builder.soup = self
|
||||
|
||||
self.parse_only = parse_only
|
||||
|
||||
if hasattr(markup, 'read'): # It's a file-type object.
|
||||
markup = markup.read()
|
||||
elif len(markup) <= 256:
|
||||
elif len(markup) <= 256 and (
|
||||
(isinstance(markup, bytes) and not b'<' in markup)
|
||||
or (isinstance(markup, unicode) and not u'<' in markup)
|
||||
):
|
||||
# Print out warnings for a couple beginner problems
|
||||
# involving passing non-markup to Beautiful Soup.
|
||||
# Beautiful Soup will still parse the input as markup,
|
||||
@@ -178,19 +211,18 @@ class BeautifulSoup(Tag):
|
||||
# system. Just let it go.
|
||||
pass
|
||||
if is_file:
|
||||
if isinstance(markup, unicode):
|
||||
markup = markup.encode("utf8")
|
||||
warnings.warn(
|
||||
'"%s" looks like a filename, not markup. You should probably open this file and pass the filehandle into Beautiful Soup.' % markup)
|
||||
if markup[:5] == "http:" or markup[:6] == "https:":
|
||||
# TODO: This is ugly but I couldn't get it to work in
|
||||
# Python 3 otherwise.
|
||||
if ((isinstance(markup, bytes) and not b' ' in markup)
|
||||
or (isinstance(markup, unicode) and not u' ' in markup)):
|
||||
warnings.warn(
|
||||
'"%s" looks like a URL. Beautiful Soup is not an HTTP client. You should probably use an HTTP client to get the document behind the URL, and feed that document to Beautiful Soup.' % markup)
|
||||
'"%s" looks like a filename, not markup. You should'
|
||||
' probably open this file and pass the filehandle into'
|
||||
' Beautiful Soup.' % markup)
|
||||
self._check_markup_is_url(markup)
|
||||
|
||||
for (self.markup, self.original_encoding, self.declared_html_encoding,
|
||||
self.contains_replacement_characters) in (
|
||||
self.builder.prepare_markup(markup, from_encoding)):
|
||||
self.builder.prepare_markup(
|
||||
markup, from_encoding, exclude_encodings=exclude_encodings)):
|
||||
self.reset()
|
||||
try:
|
||||
self._feed()
|
||||
@@ -203,6 +235,53 @@ class BeautifulSoup(Tag):
|
||||
self.markup = None
|
||||
self.builder.soup = None
|
||||
|
||||
def __copy__(self):
|
||||
copy = type(self)(
|
||||
self.encode('utf-8'), builder=self.builder, from_encoding='utf-8'
|
||||
)
|
||||
|
||||
# Although we encoded the tree to UTF-8, that may not have
|
||||
# been the encoding of the original markup. Set the copy's
|
||||
# .original_encoding to reflect the original object's
|
||||
# .original_encoding.
|
||||
copy.original_encoding = self.original_encoding
|
||||
return copy
|
||||
|
||||
def __getstate__(self):
|
||||
# Frequently a tree builder can't be pickled.
|
||||
d = dict(self.__dict__)
|
||||
if 'builder' in d and not self.builder.picklable:
|
||||
d['builder'] = None
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def _check_markup_is_url(markup):
|
||||
"""
|
||||
Check if markup looks like it's actually a url and raise a warning
|
||||
if so. Markup can be unicode or str (py2) / bytes (py3).
|
||||
"""
|
||||
if isinstance(markup, bytes):
|
||||
space = b' '
|
||||
cant_start_with = (b"http:", b"https:")
|
||||
elif isinstance(markup, unicode):
|
||||
space = u' '
|
||||
cant_start_with = (u"http:", u"https:")
|
||||
else:
|
||||
return
|
||||
|
||||
if any(markup.startswith(prefix) for prefix in cant_start_with):
|
||||
if not space in markup:
|
||||
if isinstance(markup, bytes):
|
||||
decoded_markup = markup.decode('utf-8', 'replace')
|
||||
else:
|
||||
decoded_markup = markup
|
||||
warnings.warn(
|
||||
'"%s" looks like a URL. Beautiful Soup is not an'
|
||||
' HTTP client. You should probably use an HTTP client like'
|
||||
' requests to get the document behind the URL, and feed'
|
||||
' that document to Beautiful Soup.' % decoded_markup
|
||||
)
|
||||
|
||||
def _feed(self):
|
||||
# Convert the document to Unicode.
|
||||
self.builder.reset()
|
||||
@@ -229,9 +308,7 @@ class BeautifulSoup(Tag):
|
||||
|
||||
def new_string(self, s, subclass=NavigableString):
|
||||
"""Create a new NavigableString associated with this soup."""
|
||||
navigable = subclass(s)
|
||||
navigable.setup()
|
||||
return navigable
|
||||
return subclass(s)
|
||||
|
||||
def insert_before(self, successor):
|
||||
raise NotImplementedError("BeautifulSoup objects don't support insert_before().")
|
||||
@@ -290,14 +367,60 @@ class BeautifulSoup(Tag):
|
||||
def object_was_parsed(self, o, parent=None, most_recent_element=None):
|
||||
"""Add an object to the parse tree."""
|
||||
parent = parent or self.currentTag
|
||||
most_recent_element = most_recent_element or self._most_recent_element
|
||||
o.setup(parent, most_recent_element)
|
||||
previous_element = most_recent_element or self._most_recent_element
|
||||
|
||||
next_element = previous_sibling = next_sibling = None
|
||||
if isinstance(o, Tag):
|
||||
next_element = o.next_element
|
||||
next_sibling = o.next_sibling
|
||||
previous_sibling = o.previous_sibling
|
||||
if not previous_element:
|
||||
previous_element = o.previous_element
|
||||
|
||||
o.setup(parent, previous_element, next_element, previous_sibling, next_sibling)
|
||||
|
||||
if most_recent_element is not None:
|
||||
most_recent_element.next_element = o
|
||||
self._most_recent_element = o
|
||||
parent.contents.append(o)
|
||||
|
||||
if parent.next_sibling:
|
||||
# This node is being inserted into an element that has
|
||||
# already been parsed. Deal with any dangling references.
|
||||
index = len(parent.contents)-1
|
||||
while index >= 0:
|
||||
if parent.contents[index] is o:
|
||||
break
|
||||
index -= 1
|
||||
else:
|
||||
raise ValueError(
|
||||
"Error building tree: supposedly %r was inserted "
|
||||
"into %r after the fact, but I don't see it!" % (
|
||||
o, parent
|
||||
)
|
||||
)
|
||||
if index == 0:
|
||||
previous_element = parent
|
||||
previous_sibling = None
|
||||
else:
|
||||
previous_element = previous_sibling = parent.contents[index-1]
|
||||
if index == len(parent.contents)-1:
|
||||
next_element = parent.next_sibling
|
||||
next_sibling = None
|
||||
else:
|
||||
next_element = next_sibling = parent.contents[index+1]
|
||||
|
||||
o.previous_element = previous_element
|
||||
if previous_element:
|
||||
previous_element.next_element = o
|
||||
o.next_element = next_element
|
||||
if next_element:
|
||||
next_element.previous_element = o
|
||||
o.next_sibling = next_sibling
|
||||
if next_sibling:
|
||||
next_sibling.previous_sibling = o
|
||||
o.previous_sibling = previous_sibling
|
||||
if previous_sibling:
|
||||
previous_sibling.next_sibling = o
|
||||
|
||||
def _popToTag(self, name, nsprefix=None, inclusivePop=True):
|
||||
"""Pops the tag stack up to and including the most recent
|
||||
instance of the given tag. If inclusivePop is false, pops the tag
|
||||
@@ -325,7 +448,7 @@ class BeautifulSoup(Tag):
|
||||
"""Push a start tag on to the stack.
|
||||
|
||||
If this method returns None, the tag was rejected by the
|
||||
SoupStrainer. You should proceed as if the tag had not occured
|
||||
SoupStrainer. You should proceed as if the tag had not occurred
|
||||
in the document. For instance, if this was a self-closing tag,
|
||||
don't call handle_endtag.
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
from collections import defaultdict
|
||||
import itertools
|
||||
import sys
|
||||
from bs4.element import (
|
||||
CharsetMetaAttributeValue,
|
||||
ContentMetaAttributeValue,
|
||||
HTMLAwareEntitySubstitution,
|
||||
whitespace_re
|
||||
)
|
||||
|
||||
@@ -80,9 +84,12 @@ builder_registry = TreeBuilderRegistry()
|
||||
class TreeBuilder(object):
|
||||
"""Turn a document into a Beautiful Soup object tree."""
|
||||
|
||||
NAME = "[Unknown tree builder]"
|
||||
ALTERNATE_NAMES = []
|
||||
features = []
|
||||
|
||||
is_xml = False
|
||||
picklable = False
|
||||
preserve_whitespace_tags = set()
|
||||
empty_element_tags = None # A tag will be considered an empty-element
|
||||
# tag when and only when it has no contents.
|
||||
@@ -224,9 +231,14 @@ class HTMLTreeBuilder(TreeBuilder):
|
||||
Such as which tags are empty-element tags.
|
||||
"""
|
||||
|
||||
preserve_whitespace_tags = set(['pre', 'textarea'])
|
||||
empty_element_tags = set(['br' , 'hr', 'input', 'img', 'meta',
|
||||
'spacer', 'link', 'frame', 'base'])
|
||||
preserve_whitespace_tags = HTMLAwareEntitySubstitution.preserve_whitespace_tags
|
||||
empty_element_tags = set([
|
||||
# These are from HTML5.
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr',
|
||||
|
||||
# These are from HTML4, removed in HTML5.
|
||||
'spacer', 'frame'
|
||||
])
|
||||
|
||||
# The HTML standard defines these attributes as containing a
|
||||
# space-separated list of values, not a single value. That is,
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
__all__ = [
|
||||
'HTML5TreeBuilder',
|
||||
]
|
||||
|
||||
import warnings
|
||||
import re
|
||||
from bs4.builder import (
|
||||
PERMISSIVE,
|
||||
HTML,
|
||||
HTML_5,
|
||||
HTMLTreeBuilder,
|
||||
)
|
||||
from bs4.element import NamespacedAttribute
|
||||
from bs4.element import (
|
||||
NamespacedAttribute,
|
||||
whitespace_re,
|
||||
)
|
||||
import html5lib
|
||||
from html5lib.constants import namespaces
|
||||
from html5lib.constants import (
|
||||
namespaces,
|
||||
prefixes,
|
||||
)
|
||||
from bs4.element import (
|
||||
Comment,
|
||||
Doctype,
|
||||
@@ -19,14 +29,32 @@ from bs4.element import (
|
||||
Tag,
|
||||
)
|
||||
|
||||
try:
|
||||
# Pre-0.99999999
|
||||
from html5lib.treebuilders import _base as treebuilder_base
|
||||
new_html5lib = False
|
||||
except ImportError, e:
|
||||
# 0.99999999 and up
|
||||
from html5lib.treebuilders import base as treebuilder_base
|
||||
new_html5lib = True
|
||||
|
||||
class HTML5TreeBuilder(HTMLTreeBuilder):
|
||||
"""Use html5lib to build a tree."""
|
||||
|
||||
features = ['html5lib', PERMISSIVE, HTML_5, HTML]
|
||||
NAME = "html5lib"
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding):
|
||||
features = [NAME, PERMISSIVE, HTML_5, HTML]
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding,
|
||||
document_declared_encoding=None, exclude_encodings=None):
|
||||
# Store the user-specified encoding for use later on.
|
||||
self.user_specified_encoding = user_specified_encoding
|
||||
|
||||
# document_declared_encoding and exclude_encodings aren't used
|
||||
# ATM because the html5lib TreeBuilder doesn't use
|
||||
# UnicodeDammit.
|
||||
if exclude_encodings:
|
||||
warnings.warn("You provided a value for exclude_encoding, but the html5lib tree builder doesn't support exclude_encoding.")
|
||||
yield (markup, None, None, False)
|
||||
|
||||
# These methods are defined by Beautiful Soup.
|
||||
@@ -34,7 +62,14 @@ class HTML5TreeBuilder(HTMLTreeBuilder):
|
||||
if self.soup.parse_only is not None:
|
||||
warnings.warn("You provided a value for parse_only, but the html5lib tree builder doesn't support parse_only. The entire document will be parsed.")
|
||||
parser = html5lib.HTMLParser(tree=self.create_treebuilder)
|
||||
doc = parser.parse(markup, encoding=self.user_specified_encoding)
|
||||
|
||||
extra_kwargs = dict()
|
||||
if not isinstance(markup, unicode):
|
||||
if new_html5lib:
|
||||
extra_kwargs['override_encoding'] = self.user_specified_encoding
|
||||
else:
|
||||
extra_kwargs['encoding'] = self.user_specified_encoding
|
||||
doc = parser.parse(markup, **extra_kwargs)
|
||||
|
||||
# Set the character encoding detected by the tokenizer.
|
||||
if isinstance(markup, unicode):
|
||||
@@ -42,11 +77,17 @@ class HTML5TreeBuilder(HTMLTreeBuilder):
|
||||
# charEncoding to UTF-8 if it gets Unicode input.
|
||||
doc.original_encoding = None
|
||||
else:
|
||||
doc.original_encoding = parser.tokenizer.stream.charEncoding[0]
|
||||
original_encoding = parser.tokenizer.stream.charEncoding[0]
|
||||
if not isinstance(original_encoding, basestring):
|
||||
# In 0.99999999 and up, the encoding is an html5lib
|
||||
# Encoding object. We want to use a string for compatibility
|
||||
# with other tree builders.
|
||||
original_encoding = original_encoding.name
|
||||
doc.original_encoding = original_encoding
|
||||
|
||||
def create_treebuilder(self, namespaceHTMLElements):
|
||||
self.underlying_builder = TreeBuilderForHtml5lib(
|
||||
self.soup, namespaceHTMLElements)
|
||||
namespaceHTMLElements, self.soup)
|
||||
return self.underlying_builder
|
||||
|
||||
def test_fragment_to_document(self, fragment):
|
||||
@@ -54,10 +95,14 @@ class HTML5TreeBuilder(HTMLTreeBuilder):
|
||||
return u'<html><head></head><body>%s</body></html>' % fragment
|
||||
|
||||
|
||||
class TreeBuilderForHtml5lib(html5lib.treebuilders._base.TreeBuilder):
|
||||
class TreeBuilderForHtml5lib(treebuilder_base.TreeBuilder):
|
||||
|
||||
def __init__(self, soup, namespaceHTMLElements):
|
||||
self.soup = soup
|
||||
def __init__(self, namespaceHTMLElements, soup=None):
|
||||
if soup:
|
||||
self.soup = soup
|
||||
else:
|
||||
from bs4 import BeautifulSoup
|
||||
self.soup = BeautifulSoup("", "html.parser")
|
||||
super(TreeBuilderForHtml5lib, self).__init__(namespaceHTMLElements)
|
||||
|
||||
def documentClass(self):
|
||||
@@ -80,7 +125,8 @@ class TreeBuilderForHtml5lib(html5lib.treebuilders._base.TreeBuilder):
|
||||
return TextNode(Comment(data), self.soup)
|
||||
|
||||
def fragmentClass(self):
|
||||
self.soup = BeautifulSoup("")
|
||||
from bs4 import BeautifulSoup
|
||||
self.soup = BeautifulSoup("", "html.parser")
|
||||
self.soup.name = "[document_fragment]"
|
||||
return Element(self.soup, self.soup, None)
|
||||
|
||||
@@ -92,7 +138,57 @@ class TreeBuilderForHtml5lib(html5lib.treebuilders._base.TreeBuilder):
|
||||
return self.soup
|
||||
|
||||
def getFragment(self):
|
||||
return html5lib.treebuilders._base.TreeBuilder.getFragment(self).element
|
||||
return treebuilder_base.TreeBuilder.getFragment(self).element
|
||||
|
||||
def testSerializer(self, element):
|
||||
from bs4 import BeautifulSoup
|
||||
rv = []
|
||||
doctype_re = re.compile(r'^(.*?)(?: PUBLIC "(.*?)"(?: "(.*?)")?| SYSTEM "(.*?)")?$')
|
||||
|
||||
def serializeElement(element, indent=0):
|
||||
if isinstance(element, BeautifulSoup):
|
||||
pass
|
||||
if isinstance(element, Doctype):
|
||||
m = doctype_re.match(element)
|
||||
if m:
|
||||
name = m.group(1)
|
||||
if m.lastindex > 1:
|
||||
publicId = m.group(2) or ""
|
||||
systemId = m.group(3) or m.group(4) or ""
|
||||
rv.append("""|%s<!DOCTYPE %s "%s" "%s">""" %
|
||||
(' ' * indent, name, publicId, systemId))
|
||||
else:
|
||||
rv.append("|%s<!DOCTYPE %s>" % (' ' * indent, name))
|
||||
else:
|
||||
rv.append("|%s<!DOCTYPE >" % (' ' * indent,))
|
||||
elif isinstance(element, Comment):
|
||||
rv.append("|%s<!-- %s -->" % (' ' * indent, element))
|
||||
elif isinstance(element, NavigableString):
|
||||
rv.append("|%s\"%s\"" % (' ' * indent, element))
|
||||
else:
|
||||
if element.namespace:
|
||||
name = "%s %s" % (prefixes[element.namespace],
|
||||
element.name)
|
||||
else:
|
||||
name = element.name
|
||||
rv.append("|%s<%s>" % (' ' * indent, name))
|
||||
if element.attrs:
|
||||
attributes = []
|
||||
for name, value in element.attrs.items():
|
||||
if isinstance(name, NamespacedAttribute):
|
||||
name = "%s %s" % (prefixes[name.namespace], name.name)
|
||||
if isinstance(value, list):
|
||||
value = " ".join(value)
|
||||
attributes.append((name, value))
|
||||
|
||||
for name, value in sorted(attributes):
|
||||
rv.append('|%s%s="%s"' % (' ' * (indent + 2), name, value))
|
||||
indent += 2
|
||||
for child in element.children:
|
||||
serializeElement(child, indent)
|
||||
serializeElement(element, 0)
|
||||
|
||||
return "\n".join(rv)
|
||||
|
||||
class AttrList(object):
|
||||
def __init__(self, element):
|
||||
@@ -101,7 +197,16 @@ class AttrList(object):
|
||||
def __iter__(self):
|
||||
return list(self.attrs.items()).__iter__()
|
||||
def __setitem__(self, name, value):
|
||||
"set attr", name, value
|
||||
# If this attribute is a multi-valued attribute for this element,
|
||||
# turn its value into a list.
|
||||
list_attr = HTML5TreeBuilder.cdata_list_attributes
|
||||
if (name in list_attr['*']
|
||||
or (self.element.name in list_attr
|
||||
and name in list_attr[self.element.name])):
|
||||
# A node that is being cloned may have already undergone
|
||||
# this procedure.
|
||||
if not isinstance(value, list):
|
||||
value = whitespace_re.split(value)
|
||||
self.element[name] = value
|
||||
def items(self):
|
||||
return list(self.attrs.items())
|
||||
@@ -115,9 +220,9 @@ class AttrList(object):
|
||||
return name in list(self.attrs.keys())
|
||||
|
||||
|
||||
class Element(html5lib.treebuilders._base.Node):
|
||||
class Element(treebuilder_base.Node):
|
||||
def __init__(self, element, soup, namespace):
|
||||
html5lib.treebuilders._base.Node.__init__(self, element.name)
|
||||
treebuilder_base.Node.__init__(self, element.name)
|
||||
self.element = element
|
||||
self.soup = soup
|
||||
self.namespace = namespace
|
||||
@@ -136,8 +241,10 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
child = node
|
||||
elif node.element.__class__ == NavigableString:
|
||||
string_child = child = node.element
|
||||
node.parent = self
|
||||
else:
|
||||
child = node.element
|
||||
node.parent = self
|
||||
|
||||
if not isinstance(child, basestring) and child.parent is not None:
|
||||
node.element.extract()
|
||||
@@ -161,6 +268,12 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
# immediately after the parent, if it has no children.)
|
||||
if self.element.contents:
|
||||
most_recent_element = self.element._last_descendant(False)
|
||||
elif self.element.next_element is not None:
|
||||
# Something from further ahead in the parse tree is
|
||||
# being inserted into this earlier element. This is
|
||||
# very annoying because it means an expensive search
|
||||
# for the last element in the tree.
|
||||
most_recent_element = self.soup._last_descendant()
|
||||
else:
|
||||
most_recent_element = self.element
|
||||
|
||||
@@ -169,9 +282,12 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
most_recent_element=most_recent_element)
|
||||
|
||||
def getAttributes(self):
|
||||
if isinstance(self.element, Comment):
|
||||
return {}
|
||||
return AttrList(self.element)
|
||||
|
||||
def setAttributes(self, attributes):
|
||||
|
||||
if attributes is not None and len(attributes) > 0:
|
||||
|
||||
converted_attributes = []
|
||||
@@ -195,11 +311,11 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
attributes = property(getAttributes, setAttributes)
|
||||
|
||||
def insertText(self, data, insertBefore=None):
|
||||
text = TextNode(self.soup.new_string(data), self.soup)
|
||||
if insertBefore:
|
||||
text = TextNode(self.soup.new_string(data), self.soup)
|
||||
self.insertBefore(data, insertBefore)
|
||||
self.insertBefore(text, insertBefore)
|
||||
else:
|
||||
self.appendChild(data)
|
||||
self.appendChild(text)
|
||||
|
||||
def insertBefore(self, node, refNode):
|
||||
index = self.element.index(refNode.element)
|
||||
@@ -218,6 +334,10 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
|
||||
def reparentChildren(self, new_parent):
|
||||
"""Move all of this tag's children into another tag."""
|
||||
# print "MOVE", self.element.contents
|
||||
# print "FROM", self.element
|
||||
# print "TO", new_parent.element
|
||||
|
||||
element = self.element
|
||||
new_parent_element = new_parent.element
|
||||
# Determine what this tag's next_element will be once all the children
|
||||
@@ -236,18 +356,35 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
new_parents_last_descendant_next_element = new_parent_element.next_element
|
||||
|
||||
to_append = element.contents
|
||||
append_after = new_parent.element.contents
|
||||
if len(to_append) > 0:
|
||||
# Set the first child's previous_element and previous_sibling
|
||||
# to elements within the new parent
|
||||
first_child = to_append[0]
|
||||
first_child.previous_element = new_parents_last_descendant
|
||||
if new_parents_last_descendant:
|
||||
first_child.previous_element = new_parents_last_descendant
|
||||
else:
|
||||
first_child.previous_element = new_parent_element
|
||||
first_child.previous_sibling = new_parents_last_child
|
||||
if new_parents_last_descendant:
|
||||
new_parents_last_descendant.next_element = first_child
|
||||
else:
|
||||
new_parent_element.next_element = first_child
|
||||
if new_parents_last_child:
|
||||
new_parents_last_child.next_sibling = first_child
|
||||
|
||||
# Fix the last child's next_element and next_sibling
|
||||
last_child = to_append[-1]
|
||||
last_child.next_element = new_parents_last_descendant_next_element
|
||||
last_child.next_sibling = None
|
||||
# Find the very last element being moved. It is now the
|
||||
# parent's last descendant. It has no .next_sibling and
|
||||
# its .next_element is whatever the previous last
|
||||
# descendant had.
|
||||
last_childs_last_descendant = to_append[-1]._last_descendant(False, True)
|
||||
|
||||
last_childs_last_descendant.next_element = new_parents_last_descendant_next_element
|
||||
if new_parents_last_descendant_next_element:
|
||||
# TODO: This code has no test coverage and I'm not sure
|
||||
# how to get html5lib to go through this path, but it's
|
||||
# just the other side of the previous line.
|
||||
new_parents_last_descendant_next_element.previous_element = last_childs_last_descendant
|
||||
last_childs_last_descendant.next_sibling = None
|
||||
|
||||
for child in to_append:
|
||||
child.parent = new_parent_element
|
||||
@@ -257,6 +394,10 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
element.contents = []
|
||||
element.next_element = final_next_element
|
||||
|
||||
# print "DONE WITH MOVE"
|
||||
# print "FROM", self.element
|
||||
# print "TO", new_parent_element
|
||||
|
||||
def cloneNode(self):
|
||||
tag = self.soup.new_tag(self.element.name, self.namespace)
|
||||
node = Element(tag, self.soup, self.namespace)
|
||||
@@ -277,7 +418,7 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
|
||||
class TextNode(Element):
|
||||
def __init__(self, element, soup):
|
||||
html5lib.treebuilders._base.Node.__init__(self, None)
|
||||
treebuilder_base.Node.__init__(self, None)
|
||||
self.element = element
|
||||
self.soup = soup
|
||||
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
"""Use the HTMLParser library to parse HTML files that aren't too bad."""
|
||||
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
__all__ = [
|
||||
'HTMLParserTreeBuilder',
|
||||
]
|
||||
|
||||
from HTMLParser import (
|
||||
HTMLParser,
|
||||
HTMLParseError,
|
||||
)
|
||||
from HTMLParser import HTMLParser
|
||||
|
||||
try:
|
||||
from HTMLParser import HTMLParseError
|
||||
except ImportError, e:
|
||||
# HTMLParseError is removed in Python 3.5. Since it can never be
|
||||
# thrown in 3.5, we can just define our own class as a placeholder.
|
||||
class HTMLParseError(Exception):
|
||||
pass
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
@@ -19,10 +28,10 @@ import warnings
|
||||
# At the end of this file, we monkeypatch HTMLParser so that
|
||||
# strict=True works well on Python 3.2.2.
|
||||
major, minor, release = sys.version_info[:3]
|
||||
CONSTRUCTOR_TAKES_STRICT = (
|
||||
major > 3
|
||||
or (major == 3 and minor > 2)
|
||||
or (major == 3 and minor == 2 and release >= 3))
|
||||
CONSTRUCTOR_TAKES_STRICT = major == 3 and minor == 2 and release >= 3
|
||||
CONSTRUCTOR_STRICT_IS_DEPRECATED = major == 3 and minor == 3
|
||||
CONSTRUCTOR_TAKES_CONVERT_CHARREFS = major == 3 and minor >= 4
|
||||
|
||||
|
||||
from bs4.element import (
|
||||
CData,
|
||||
@@ -43,7 +52,31 @@ from bs4.builder import (
|
||||
HTMLPARSER = 'html.parser'
|
||||
|
||||
class BeautifulSoupHTMLParser(HTMLParser):
|
||||
def handle_starttag(self, name, attrs):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
HTMLParser.__init__(self, *args, **kwargs)
|
||||
|
||||
# Keep a list of empty-element tags that were encountered
|
||||
# without an explicit closing tag. If we encounter a closing tag
|
||||
# of this type, we'll associate it with one of those entries.
|
||||
#
|
||||
# This isn't a stack because we don't care about the
|
||||
# order. It's a list of closing tags we've already handled and
|
||||
# will ignore, assuming they ever show up.
|
||||
self.already_closed_empty_element = []
|
||||
|
||||
def handle_startendtag(self, name, attrs):
|
||||
# This is only called when the markup looks like
|
||||
# <tag/>.
|
||||
|
||||
# is_startend() tells handle_starttag not to close the tag
|
||||
# just because its name matches a known empty-element tag. We
|
||||
# know that this is an empty-element tag and we want to call
|
||||
# handle_endtag ourselves.
|
||||
tag = self.handle_starttag(name, attrs, handle_empty_element=False)
|
||||
self.handle_endtag(name)
|
||||
|
||||
def handle_starttag(self, name, attrs, handle_empty_element=True):
|
||||
# XXX namespace
|
||||
attr_dict = {}
|
||||
for key, value in attrs:
|
||||
@@ -53,17 +86,42 @@ class BeautifulSoupHTMLParser(HTMLParser):
|
||||
value = ''
|
||||
attr_dict[key] = value
|
||||
attrvalue = '""'
|
||||
self.soup.handle_starttag(name, None, None, attr_dict)
|
||||
#print "START", name
|
||||
tag = self.soup.handle_starttag(name, None, None, attr_dict)
|
||||
if tag and tag.is_empty_element and handle_empty_element:
|
||||
# Unlike other parsers, html.parser doesn't send separate end tag
|
||||
# events for empty-element tags. (It's handled in
|
||||
# handle_startendtag, but only if the original markup looked like
|
||||
# <tag/>.)
|
||||
#
|
||||
# So we need to call handle_endtag() ourselves. Since we
|
||||
# know the start event is identical to the end event, we
|
||||
# don't want handle_endtag() to cross off any previous end
|
||||
# events for tags of this name.
|
||||
self.handle_endtag(name, check_already_closed=False)
|
||||
|
||||
def handle_endtag(self, name):
|
||||
self.soup.handle_endtag(name)
|
||||
# But we might encounter an explicit closing tag for this tag
|
||||
# later on. If so, we want to ignore it.
|
||||
self.already_closed_empty_element.append(name)
|
||||
|
||||
def handle_endtag(self, name, check_already_closed=True):
|
||||
#print "END", name
|
||||
if check_already_closed and name in self.already_closed_empty_element:
|
||||
# This is a redundant end tag for an empty-element tag.
|
||||
# We've already called handle_endtag() for it, so just
|
||||
# check it off the list.
|
||||
# print "ALREADY CLOSED", name
|
||||
self.already_closed_empty_element.remove(name)
|
||||
else:
|
||||
self.soup.handle_endtag(name)
|
||||
|
||||
def handle_data(self, data):
|
||||
self.soup.handle_data(data)
|
||||
|
||||
def handle_charref(self, name):
|
||||
# XXX workaround for a bug in HTMLParser. Remove this once
|
||||
# it's fixed.
|
||||
# it's fixed in all supported versions.
|
||||
# http://bugs.python.org/issue13633
|
||||
if name.startswith('x'):
|
||||
real_name = int(name.lstrip('x'), 16)
|
||||
elif name.startswith('X'):
|
||||
@@ -113,14 +171,6 @@ class BeautifulSoupHTMLParser(HTMLParser):
|
||||
|
||||
def handle_pi(self, data):
|
||||
self.soup.endData()
|
||||
if data.endswith("?") and data.lower().startswith("xml"):
|
||||
# "An XHTML processing instruction using the trailing '?'
|
||||
# will cause the '?' to be included in data." - HTMLParser
|
||||
# docs.
|
||||
#
|
||||
# Strip the question mark so we don't end up with two
|
||||
# question marks.
|
||||
data = data[:-1]
|
||||
self.soup.handle_data(data)
|
||||
self.soup.endData(ProcessingInstruction)
|
||||
|
||||
@@ -128,15 +178,19 @@ class BeautifulSoupHTMLParser(HTMLParser):
|
||||
class HTMLParserTreeBuilder(HTMLTreeBuilder):
|
||||
|
||||
is_xml = False
|
||||
features = [HTML, STRICT, HTMLPARSER]
|
||||
picklable = True
|
||||
NAME = HTMLPARSER
|
||||
features = [NAME, HTML, STRICT]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if CONSTRUCTOR_TAKES_STRICT:
|
||||
if CONSTRUCTOR_TAKES_STRICT and not CONSTRUCTOR_STRICT_IS_DEPRECATED:
|
||||
kwargs['strict'] = False
|
||||
if CONSTRUCTOR_TAKES_CONVERT_CHARREFS:
|
||||
kwargs['convert_charrefs'] = False
|
||||
self.parser_args = (args, kwargs)
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding=None,
|
||||
document_declared_encoding=None):
|
||||
document_declared_encoding=None, exclude_encodings=None):
|
||||
"""
|
||||
:return: A 4-tuple (markup, original encoding, encoding
|
||||
declared within markup, whether any characters had to be
|
||||
@@ -147,7 +201,8 @@ class HTMLParserTreeBuilder(HTMLTreeBuilder):
|
||||
return
|
||||
|
||||
try_encodings = [user_specified_encoding, document_declared_encoding]
|
||||
dammit = UnicodeDammit(markup, try_encodings, is_html=True)
|
||||
dammit = UnicodeDammit(markup, try_encodings, is_html=True,
|
||||
exclude_encodings=exclude_encodings)
|
||||
yield (dammit.markup, dammit.original_encoding,
|
||||
dammit.declared_html_encoding,
|
||||
dammit.contains_replacement_characters)
|
||||
@@ -162,6 +217,7 @@ class HTMLParserTreeBuilder(HTMLTreeBuilder):
|
||||
warnings.warn(RuntimeWarning(
|
||||
"Python's built-in HTMLParser cannot parse the given document. This is not a bug in Beautiful Soup. The best solution is to install an external parser (lxml or html5lib), and use Beautiful Soup with that parser. See http://www.crummy.com/software/BeautifulSoup/bs4/doc/#installing-a-parser for help."))
|
||||
raise e
|
||||
parser.already_closed_empty_element = []
|
||||
|
||||
# Patch 3.2 versions of HTMLParser earlier than 3.2.3 to use some
|
||||
# 3.2.3 code. This ensures they don't treat markup like <p></p> as a
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
__all__ = [
|
||||
'LXMLTreeBuilderForXML',
|
||||
'LXMLTreeBuilder',
|
||||
@@ -7,7 +9,13 @@ from io import BytesIO
|
||||
from StringIO import StringIO
|
||||
import collections
|
||||
from lxml import etree
|
||||
from bs4.element import Comment, Doctype, NamespacedAttribute
|
||||
from bs4.element import (
|
||||
Comment,
|
||||
Doctype,
|
||||
NamespacedAttribute,
|
||||
ProcessingInstruction,
|
||||
XMLProcessingInstruction,
|
||||
)
|
||||
from bs4.builder import (
|
||||
FAST,
|
||||
HTML,
|
||||
@@ -24,9 +32,13 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
DEFAULT_PARSER_CLASS = etree.XMLParser
|
||||
|
||||
is_xml = True
|
||||
processing_instruction_class = XMLProcessingInstruction
|
||||
|
||||
NAME = "lxml-xml"
|
||||
ALTERNATE_NAMES = ["xml"]
|
||||
|
||||
# Well, it's permissive by XML parser standards.
|
||||
features = [LXML, XML, FAST, PERMISSIVE]
|
||||
features = [NAME, LXML, XML, FAST, PERMISSIVE]
|
||||
|
||||
CHUNK_SIZE = 512
|
||||
|
||||
@@ -70,6 +82,7 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
return (None, tag)
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding=None,
|
||||
exclude_encodings=None,
|
||||
document_declared_encoding=None):
|
||||
"""
|
||||
:yield: A series of 4-tuples.
|
||||
@@ -78,6 +91,16 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
|
||||
Each 4-tuple represents a strategy for parsing the document.
|
||||
"""
|
||||
# Instead of using UnicodeDammit to convert the bytestring to
|
||||
# Unicode using different encodings, use EncodingDetector to
|
||||
# iterate over the encodings, and tell lxml to try to parse
|
||||
# the document as each one in turn.
|
||||
is_html = not self.is_xml
|
||||
if is_html:
|
||||
self.processing_instruction_class = ProcessingInstruction
|
||||
else:
|
||||
self.processing_instruction_class = XMLProcessingInstruction
|
||||
|
||||
if isinstance(markup, unicode):
|
||||
# We were given Unicode. Maybe lxml can parse Unicode on
|
||||
# this system?
|
||||
@@ -89,13 +112,9 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
yield (markup.encode("utf8"), "utf8",
|
||||
document_declared_encoding, False)
|
||||
|
||||
# Instead of using UnicodeDammit to convert the bytestring to
|
||||
# Unicode using different encodings, use EncodingDetector to
|
||||
# iterate over the encodings, and tell lxml to try to parse
|
||||
# the document as each one in turn.
|
||||
is_html = not self.is_xml
|
||||
try_encodings = [user_specified_encoding, document_declared_encoding]
|
||||
detector = EncodingDetector(markup, try_encodings, is_html)
|
||||
detector = EncodingDetector(
|
||||
markup, try_encodings, is_html, exclude_encodings)
|
||||
for encoding in detector.encodings:
|
||||
yield (detector.markup, encoding, document_declared_encoding, False)
|
||||
|
||||
@@ -189,7 +208,9 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
self.nsmaps.pop()
|
||||
|
||||
def pi(self, target, data):
|
||||
pass
|
||||
self.soup.endData()
|
||||
self.soup.handle_data(target + ' ' + data)
|
||||
self.soup.endData(self.processing_instruction_class)
|
||||
|
||||
def data(self, content):
|
||||
self.soup.handle_data(content)
|
||||
@@ -212,8 +233,12 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
|
||||
class LXMLTreeBuilder(HTMLTreeBuilder, LXMLTreeBuilderForXML):
|
||||
|
||||
features = [LXML, HTML, FAST, PERMISSIVE]
|
||||
NAME = LXML
|
||||
ALTERNATE_NAMES = ["lxml-html"]
|
||||
|
||||
features = ALTERNATE_NAMES + [NAME, HTML, FAST, PERMISSIVE]
|
||||
is_xml = False
|
||||
processing_instruction_class = ProcessingInstruction
|
||||
|
||||
def default_parser(self, encoding):
|
||||
return etree.HTMLParser
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
|
||||
This library converts a bytestream to Unicode through any means
|
||||
necessary. It is heavily based on code from Mark Pilgrim's Universal
|
||||
Feed Parser. It works best on XML and XML, but it does not rewrite the
|
||||
Feed Parser. It works best on XML and HTML, but it does not rewrite the
|
||||
XML or HTML to reflect a new encoding; that's the tree builder's job.
|
||||
"""
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
__license__ = "MIT"
|
||||
|
||||
import codecs
|
||||
from htmlentitydefs import codepoint2name
|
||||
@@ -212,8 +215,11 @@ class EncodingDetector:
|
||||
|
||||
5. Windows-1252.
|
||||
"""
|
||||
def __init__(self, markup, override_encodings=None, is_html=False):
|
||||
def __init__(self, markup, override_encodings=None, is_html=False,
|
||||
exclude_encodings=None):
|
||||
self.override_encodings = override_encodings or []
|
||||
exclude_encodings = exclude_encodings or []
|
||||
self.exclude_encodings = set([x.lower() for x in exclude_encodings])
|
||||
self.chardet_encoding = None
|
||||
self.is_html = is_html
|
||||
self.declared_encoding = None
|
||||
@@ -224,6 +230,8 @@ class EncodingDetector:
|
||||
def _usable(self, encoding, tried):
|
||||
if encoding is not None:
|
||||
encoding = encoding.lower()
|
||||
if encoding in self.exclude_encodings:
|
||||
return False
|
||||
if encoding not in tried:
|
||||
tried.add(encoding)
|
||||
return True
|
||||
@@ -266,6 +274,9 @@ class EncodingDetector:
|
||||
def strip_byte_order_mark(cls, data):
|
||||
"""If a byte-order mark is present, strip it and return the encoding it implies."""
|
||||
encoding = None
|
||||
if isinstance(data, unicode):
|
||||
# Unicode data cannot have a byte-order mark.
|
||||
return data, encoding
|
||||
if (len(data) >= 4) and (data[:2] == b'\xfe\xff') \
|
||||
and (data[2:4] != '\x00\x00'):
|
||||
encoding = 'utf-16be'
|
||||
@@ -299,14 +310,14 @@ class EncodingDetector:
|
||||
else:
|
||||
xml_endpos = 1024
|
||||
html_endpos = max(2048, int(len(markup) * 0.05))
|
||||
|
||||
|
||||
declared_encoding = None
|
||||
declared_encoding_match = xml_encoding_re.search(markup, endpos=xml_endpos)
|
||||
if not declared_encoding_match and is_html:
|
||||
declared_encoding_match = html_meta_re.search(markup, endpos=html_endpos)
|
||||
if declared_encoding_match is not None:
|
||||
declared_encoding = declared_encoding_match.groups()[0].decode(
|
||||
'ascii')
|
||||
'ascii', 'replace')
|
||||
if declared_encoding:
|
||||
return declared_encoding.lower()
|
||||
return None
|
||||
@@ -331,13 +342,14 @@ class UnicodeDammit:
|
||||
]
|
||||
|
||||
def __init__(self, markup, override_encodings=[],
|
||||
smart_quotes_to=None, is_html=False):
|
||||
smart_quotes_to=None, is_html=False, exclude_encodings=[]):
|
||||
self.smart_quotes_to = smart_quotes_to
|
||||
self.tried_encodings = []
|
||||
self.contains_replacement_characters = False
|
||||
self.is_html = is_html
|
||||
|
||||
self.detector = EncodingDetector(markup, override_encodings, is_html)
|
||||
self.log = logging.getLogger(__name__)
|
||||
self.detector = EncodingDetector(
|
||||
markup, override_encodings, is_html, exclude_encodings)
|
||||
|
||||
# Short-circuit if the data is in Unicode to begin with.
|
||||
if isinstance(markup, unicode) or markup == '':
|
||||
@@ -365,9 +377,10 @@ class UnicodeDammit:
|
||||
if encoding != "ascii":
|
||||
u = self._convert_from(encoding, "replace")
|
||||
if u is not None:
|
||||
logging.warning(
|
||||
self.log.warning(
|
||||
"Some characters could not be decoded, and were "
|
||||
"replaced with REPLACEMENT CHARACTER.")
|
||||
"replaced with REPLACEMENT CHARACTER."
|
||||
)
|
||||
self.contains_replacement_characters = True
|
||||
break
|
||||
|
||||
@@ -723,7 +736,7 @@ class UnicodeDammit:
|
||||
0xde : b'\xc3\x9e', # Þ
|
||||
0xdf : b'\xc3\x9f', # ß
|
||||
0xe0 : b'\xc3\xa0', # à
|
||||
0xe1 : b'\xa1', # á
|
||||
0xe1 : b'\xa1', # á
|
||||
0xe2 : b'\xc3\xa2', # â
|
||||
0xe3 : b'\xc3\xa3', # ã
|
||||
0xe4 : b'\xc3\xa4', # ä
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Diagnostic functions, mainly for use when doing tech support."""
|
||||
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
__license__ = "MIT"
|
||||
|
||||
import cProfile
|
||||
from StringIO import StringIO
|
||||
from HTMLParser import HTMLParser
|
||||
@@ -33,18 +38,28 @@ def diagnose(data):
|
||||
|
||||
if 'lxml' in basic_parsers:
|
||||
basic_parsers.append(["lxml", "xml"])
|
||||
from lxml import etree
|
||||
print "Found lxml version %s" % ".".join(map(str,etree.LXML_VERSION))
|
||||
try:
|
||||
from lxml import etree
|
||||
print "Found lxml version %s" % ".".join(map(str,etree.LXML_VERSION))
|
||||
except ImportError, e:
|
||||
print (
|
||||
"lxml is not installed or couldn't be imported.")
|
||||
|
||||
|
||||
if 'html5lib' in basic_parsers:
|
||||
import html5lib
|
||||
print "Found html5lib version %s" % html5lib.__version__
|
||||
try:
|
||||
import html5lib
|
||||
print "Found html5lib version %s" % html5lib.__version__
|
||||
except ImportError, e:
|
||||
print (
|
||||
"html5lib is not installed or couldn't be imported.")
|
||||
|
||||
if hasattr(data, 'read'):
|
||||
data = data.read()
|
||||
elif os.path.exists(data):
|
||||
print '"%s" looks like a filename. Reading data from the file.' % data
|
||||
data = open(data).read()
|
||||
with open(data) as fp:
|
||||
data = fp.read()
|
||||
elif data.startswith("http:") or data.startswith("https:"):
|
||||
print '"%s" looks like a URL. Beautiful Soup is not an HTTP client.' % data
|
||||
print "You need to use some other library to get the document behind the URL, and feed that document to Beautiful Soup."
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
__license__ = "MIT"
|
||||
|
||||
import collections
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import warnings
|
||||
from bs4.dammit import EntitySubstitution
|
||||
@@ -96,6 +101,8 @@ class HTMLAwareEntitySubstitution(EntitySubstitution):
|
||||
|
||||
preformatted_tags = set(["pre"])
|
||||
|
||||
preserve_whitespace_tags = set(['pre', 'textarea'])
|
||||
|
||||
@classmethod
|
||||
def _substitute_if_appropriate(cls, ns, f):
|
||||
if (isinstance(ns, NavigableString)
|
||||
@@ -124,8 +131,8 @@ class PageElement(object):
|
||||
# to methods like encode() and prettify():
|
||||
#
|
||||
# "html" - All Unicode characters with corresponding HTML entities
|
||||
# are converted to those entities on output.
|
||||
# "minimal" - Bare ampersands and angle brackets are converted to
|
||||
# are converted to those entities on output.
|
||||
# "minimal" - Bare ampersands and angle brackets are converted to
|
||||
# XML entities: & < >
|
||||
# None - The null formatter. Unicode characters are never
|
||||
# converted to entities. This is not recommended, but it's
|
||||
@@ -166,11 +173,19 @@ class PageElement(object):
|
||||
|
||||
This is used when mapping a formatter name ("minimal") to an
|
||||
appropriate function (one that performs entity-substitution on
|
||||
the contents of <script> and <style> tags, or not). It's
|
||||
the contents of <script> and <style> tags, or not). It can be
|
||||
inefficient, but it should be called very rarely.
|
||||
"""
|
||||
if self.known_xml is not None:
|
||||
# Most of the time we will have determined this when the
|
||||
# document is parsed.
|
||||
return self.known_xml
|
||||
|
||||
# Otherwise, it's likely that this element was created by
|
||||
# direct invocation of the constructor from within the user's
|
||||
# Python code.
|
||||
if self.parent is None:
|
||||
# This is the top-level object. It should have .is_xml set
|
||||
# This is the top-level object. It should have .known_xml set
|
||||
# from tree creation. If not, take a guess--BS is usually
|
||||
# used on HTML markup.
|
||||
return getattr(self, 'is_xml', False)
|
||||
@@ -185,24 +200,40 @@ class PageElement(object):
|
||||
return self.HTML_FORMATTERS.get(
|
||||
name, HTMLAwareEntitySubstitution.substitute_xml)
|
||||
|
||||
def setup(self, parent=None, previous_element=None):
|
||||
def setup(self, parent=None, previous_element=None, next_element=None,
|
||||
previous_sibling=None, next_sibling=None):
|
||||
"""Sets up the initial relations between this element and
|
||||
other elements."""
|
||||
self.parent = parent
|
||||
|
||||
self.previous_element = previous_element
|
||||
if previous_element is not None:
|
||||
self.previous_element.next_element = self
|
||||
self.next_element = None
|
||||
self.previous_sibling = None
|
||||
self.next_sibling = None
|
||||
if self.parent is not None and self.parent.contents:
|
||||
self.previous_sibling = self.parent.contents[-1]
|
||||
|
||||
self.next_element = next_element
|
||||
if self.next_element:
|
||||
self.next_element.previous_element = self
|
||||
|
||||
self.next_sibling = next_sibling
|
||||
if self.next_sibling:
|
||||
self.next_sibling.previous_sibling = self
|
||||
|
||||
if (not previous_sibling
|
||||
and self.parent is not None and self.parent.contents):
|
||||
previous_sibling = self.parent.contents[-1]
|
||||
|
||||
self.previous_sibling = previous_sibling
|
||||
if previous_sibling:
|
||||
self.previous_sibling.next_sibling = self
|
||||
|
||||
nextSibling = _alias("next_sibling") # BS3
|
||||
previousSibling = _alias("previous_sibling") # BS3
|
||||
|
||||
def replace_with(self, replace_with):
|
||||
if not self.parent:
|
||||
raise ValueError(
|
||||
"Cannot replace one element with another when the"
|
||||
"element to be replaced is not part of a tree.")
|
||||
if replace_with is self:
|
||||
return
|
||||
if replace_with is self.parent:
|
||||
@@ -216,6 +247,10 @@ class PageElement(object):
|
||||
|
||||
def unwrap(self):
|
||||
my_parent = self.parent
|
||||
if not self.parent:
|
||||
raise ValueError(
|
||||
"Cannot replace an element with its contents when that"
|
||||
"element is not part of a tree.")
|
||||
my_index = self.parent.index(self)
|
||||
self.extract()
|
||||
for child in reversed(self.contents[:]):
|
||||
@@ -240,17 +275,20 @@ class PageElement(object):
|
||||
last_child = self._last_descendant()
|
||||
next_element = last_child.next_element
|
||||
|
||||
if self.previous_element is not None:
|
||||
if (self.previous_element is not None and
|
||||
self.previous_element is not next_element):
|
||||
self.previous_element.next_element = next_element
|
||||
if next_element is not None:
|
||||
if next_element is not None and next_element is not self.previous_element:
|
||||
next_element.previous_element = self.previous_element
|
||||
self.previous_element = None
|
||||
last_child.next_element = None
|
||||
|
||||
self.parent = None
|
||||
if self.previous_sibling is not None:
|
||||
if (self.previous_sibling is not None
|
||||
and self.previous_sibling is not self.next_sibling):
|
||||
self.previous_sibling.next_sibling = self.next_sibling
|
||||
if self.next_sibling is not None:
|
||||
if (self.next_sibling is not None
|
||||
and self.next_sibling is not self.previous_sibling):
|
||||
self.next_sibling.previous_sibling = self.previous_sibling
|
||||
self.previous_sibling = self.next_sibling = None
|
||||
return self
|
||||
@@ -263,13 +301,15 @@ class PageElement(object):
|
||||
last_child = self
|
||||
while isinstance(last_child, Tag) and last_child.contents:
|
||||
last_child = last_child.contents[-1]
|
||||
if not accept_self and last_child == self:
|
||||
if not accept_self and last_child is self:
|
||||
last_child = None
|
||||
return last_child
|
||||
# BS3: Not part of the API!
|
||||
_lastRecursiveChild = _last_descendant
|
||||
|
||||
def insert(self, position, new_child):
|
||||
if new_child is None:
|
||||
raise ValueError("Cannot insert None into a tag.")
|
||||
if new_child is self:
|
||||
raise ValueError("Cannot insert a tag into itself.")
|
||||
if (isinstance(new_child, basestring)
|
||||
@@ -478,6 +518,10 @@ class PageElement(object):
|
||||
def _find_all(self, name, attrs, text, limit, generator, **kwargs):
|
||||
"Iterates over a generator looking for things that match."
|
||||
|
||||
if text is None and 'string' in kwargs:
|
||||
text = kwargs['string']
|
||||
del kwargs['string']
|
||||
|
||||
if isinstance(name, SoupStrainer):
|
||||
strainer = name
|
||||
else:
|
||||
@@ -491,9 +535,16 @@ class PageElement(object):
|
||||
return ResultSet(strainer, result)
|
||||
elif isinstance(name, basestring):
|
||||
# Optimization to find all tags with a given name.
|
||||
if name.count(':') == 1:
|
||||
# This is a name with a prefix.
|
||||
prefix, name = name.split(':', 1)
|
||||
else:
|
||||
prefix = None
|
||||
result = (element for element in generator
|
||||
if isinstance(element, Tag)
|
||||
and element.name == name)
|
||||
and element.name == name
|
||||
and (prefix is None or element.prefix == prefix)
|
||||
)
|
||||
return ResultSet(strainer, result)
|
||||
results = ResultSet(strainer)
|
||||
while True:
|
||||
@@ -548,17 +599,17 @@ class PageElement(object):
|
||||
|
||||
# Methods for supporting CSS selectors.
|
||||
|
||||
tag_name_re = re.compile('^[a-z0-9]+$')
|
||||
tag_name_re = re.compile('^[a-zA-Z0-9][-.a-zA-Z0-9:_]*$')
|
||||
|
||||
# /^(\w+)\[(\w+)([=~\|\^\$\*]?)=?"?([^\]"]*)"?\]$/
|
||||
# \---/ \---/\-------------/ \-------/
|
||||
# | | | |
|
||||
# | | | The value
|
||||
# | | ~,|,^,$,* or =
|
||||
# | Attribute
|
||||
# /^([a-zA-Z0-9][-.a-zA-Z0-9:_]*)\[(\w+)([=~\|\^\$\*]?)=?"?([^\]"]*)"?\]$/
|
||||
# \---------------------------/ \---/\-------------/ \-------/
|
||||
# | | | |
|
||||
# | | | The value
|
||||
# | | ~,|,^,$,* or =
|
||||
# | Attribute
|
||||
# Tag
|
||||
attribselect_re = re.compile(
|
||||
r'^(?P<tag>\w+)?\[(?P<attribute>\w+)(?P<operator>[=~\|\^\$\*]?)' +
|
||||
r'^(?P<tag>[a-zA-Z0-9][-.a-zA-Z0-9:_]*)?\[(?P<attribute>[\w-]+)(?P<operator>[=~\|\^\$\*]?)' +
|
||||
r'=?"?(?P<value>[^\]"]*)"?\]$'
|
||||
)
|
||||
|
||||
@@ -605,7 +656,7 @@ class PageElement(object):
|
||||
return lambda el: el._attr_value_as_string(
|
||||
attribute, '').startswith(value)
|
||||
elif operator == '$':
|
||||
# string represenation of `attribute` ends with `value`
|
||||
# string representation of `attribute` ends with `value`
|
||||
return lambda el: el._attr_value_as_string(
|
||||
attribute, '').endswith(value)
|
||||
elif operator == '*':
|
||||
@@ -645,6 +696,11 @@ class NavigableString(unicode, PageElement):
|
||||
PREFIX = ''
|
||||
SUFFIX = ''
|
||||
|
||||
# We can't tell just by looking at a string whether it's contained
|
||||
# in an XML document or an HTML document.
|
||||
|
||||
known_xml = None
|
||||
|
||||
def __new__(cls, value):
|
||||
"""Create a new NavigableString.
|
||||
|
||||
@@ -654,11 +710,17 @@ class NavigableString(unicode, PageElement):
|
||||
how to handle non-ASCII characters.
|
||||
"""
|
||||
if isinstance(value, unicode):
|
||||
return unicode.__new__(cls, value)
|
||||
return unicode.__new__(cls, value, DEFAULT_OUTPUT_ENCODING)
|
||||
u = unicode.__new__(cls, value)
|
||||
else:
|
||||
u = unicode.__new__(cls, value, DEFAULT_OUTPUT_ENCODING)
|
||||
u.setup()
|
||||
return u
|
||||
|
||||
def __copy__(self):
|
||||
return self
|
||||
"""A copy of a NavigableString has the same contents and class
|
||||
as the original, but it is not connected to the parse tree.
|
||||
"""
|
||||
return type(self)(self)
|
||||
|
||||
def __getnewargs__(self):
|
||||
return (unicode(self),)
|
||||
@@ -705,7 +767,13 @@ class CData(PreformattedString):
|
||||
SUFFIX = u']]>'
|
||||
|
||||
class ProcessingInstruction(PreformattedString):
|
||||
"""A SGML processing instruction."""
|
||||
|
||||
PREFIX = u'<?'
|
||||
SUFFIX = u'>'
|
||||
|
||||
class XMLProcessingInstruction(ProcessingInstruction):
|
||||
"""An XML processing instruction."""
|
||||
PREFIX = u'<?'
|
||||
SUFFIX = u'?>'
|
||||
|
||||
@@ -716,8 +784,8 @@ class Comment(PreformattedString):
|
||||
|
||||
|
||||
class Declaration(PreformattedString):
|
||||
PREFIX = u'<!'
|
||||
SUFFIX = u'!>'
|
||||
PREFIX = u'<?'
|
||||
SUFFIX = u'?>'
|
||||
|
||||
|
||||
class Doctype(PreformattedString):
|
||||
@@ -743,7 +811,8 @@ class Tag(PageElement):
|
||||
"""Represents a found HTML tag with its attributes and contents."""
|
||||
|
||||
def __init__(self, parser=None, builder=None, name=None, namespace=None,
|
||||
prefix=None, attrs=None, parent=None, previous=None):
|
||||
prefix=None, attrs=None, parent=None, previous=None,
|
||||
is_xml=None):
|
||||
"Basic constructor."
|
||||
|
||||
if parser is None:
|
||||
@@ -757,13 +826,31 @@ class Tag(PageElement):
|
||||
self.name = name
|
||||
self.namespace = namespace
|
||||
self.prefix = prefix
|
||||
if builder is not None:
|
||||
preserve_whitespace_tags = builder.preserve_whitespace_tags
|
||||
else:
|
||||
if is_xml:
|
||||
preserve_whitespace_tags = []
|
||||
else:
|
||||
preserve_whitespace_tags = HTMLAwareEntitySubstitution.preserve_whitespace_tags
|
||||
self.preserve_whitespace_tags = preserve_whitespace_tags
|
||||
if attrs is None:
|
||||
attrs = {}
|
||||
elif attrs and builder.cdata_list_attributes:
|
||||
attrs = builder._replace_cdata_list_attribute_values(
|
||||
self.name, attrs)
|
||||
elif attrs:
|
||||
if builder is not None and builder.cdata_list_attributes:
|
||||
attrs = builder._replace_cdata_list_attribute_values(
|
||||
self.name, attrs)
|
||||
else:
|
||||
attrs = dict(attrs)
|
||||
else:
|
||||
attrs = dict(attrs)
|
||||
|
||||
# If possible, determine ahead of time whether this tag is an
|
||||
# XML tag.
|
||||
if builder:
|
||||
self.known_xml = builder.is_xml
|
||||
else:
|
||||
self.known_xml = is_xml
|
||||
self.attrs = attrs
|
||||
self.contents = []
|
||||
self.setup(parent, previous)
|
||||
@@ -778,6 +865,18 @@ class Tag(PageElement):
|
||||
|
||||
parserClass = _alias("parser_class") # BS3
|
||||
|
||||
def __copy__(self):
|
||||
"""A copy of a Tag is a new Tag, unconnected to the parse tree.
|
||||
Its contents are a copy of the old Tag's contents.
|
||||
"""
|
||||
clone = type(self)(None, self.builder, self.name, self.namespace,
|
||||
self.prefix, self.attrs, is_xml=self._is_xml)
|
||||
for attr in ('can_be_empty_element', 'hidden'):
|
||||
setattr(clone, attr, getattr(self, attr))
|
||||
for child in self.contents:
|
||||
clone.append(child.__copy__())
|
||||
return clone
|
||||
|
||||
@property
|
||||
def is_empty_element(self):
|
||||
"""Is this tag an empty-element tag? (aka a self-closing tag)
|
||||
@@ -893,6 +992,13 @@ class Tag(PageElement):
|
||||
attribute."""
|
||||
return self.attrs.get(key, default)
|
||||
|
||||
def get_attribute_list(self, key, default=None):
|
||||
"""The same as get(), but always returns a list."""
|
||||
value = self.get(key, default)
|
||||
if not isinstance(value, list):
|
||||
value = [value]
|
||||
return value
|
||||
|
||||
def has_attr(self, key):
|
||||
return key in self.attrs
|
||||
|
||||
@@ -944,7 +1050,7 @@ class Tag(PageElement):
|
||||
tag_name, tag_name))
|
||||
return self.find(tag_name)
|
||||
# We special case contents to avoid recursion.
|
||||
elif not tag.startswith("__") and not tag=="contents":
|
||||
elif not tag.startswith("__") and not tag == "contents":
|
||||
return self.find(tag)
|
||||
raise AttributeError(
|
||||
"'%s' object has no attribute '%s'" % (self.__class__, tag))
|
||||
@@ -971,15 +1077,25 @@ class Tag(PageElement):
|
||||
as defined in __eq__."""
|
||||
return not self == other
|
||||
|
||||
def __repr__(self, encoding=DEFAULT_OUTPUT_ENCODING):
|
||||
def __repr__(self, encoding="unicode-escape"):
|
||||
"""Renders this tag as a string."""
|
||||
return self.encode(encoding)
|
||||
if PY3K:
|
||||
# "The return value must be a string object", i.e. Unicode
|
||||
return self.decode()
|
||||
else:
|
||||
# "The return value must be a string object", i.e. a bytestring.
|
||||
# By convention, the return value of __repr__ should also be
|
||||
# an ASCII string.
|
||||
return self.encode(encoding)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.decode()
|
||||
|
||||
def __str__(self):
|
||||
return self.encode()
|
||||
if PY3K:
|
||||
return self.decode()
|
||||
else:
|
||||
return self.encode()
|
||||
|
||||
if PY3K:
|
||||
__str__ = __repr__ = __unicode__
|
||||
@@ -994,10 +1110,11 @@ class Tag(PageElement):
|
||||
|
||||
def _should_pretty_print(self, indent_level):
|
||||
"""Should this tag be pretty-printed?"""
|
||||
|
||||
return (
|
||||
indent_level is not None and
|
||||
(self.name not in HTMLAwareEntitySubstitution.preformatted_tags
|
||||
or self._is_xml))
|
||||
indent_level is not None
|
||||
and self.name not in self.preserve_whitespace_tags
|
||||
)
|
||||
|
||||
def decode(self, indent_level=None,
|
||||
eventual_encoding=DEFAULT_OUTPUT_ENCODING,
|
||||
@@ -1103,12 +1220,18 @@ class Tag(PageElement):
|
||||
formatter="minimal"):
|
||||
"""Renders the contents of this tag as a Unicode string.
|
||||
|
||||
:param indent_level: Each line of the rendering will be
|
||||
indented this many spaces.
|
||||
|
||||
:param eventual_encoding: The tag is destined to be
|
||||
encoded into this encoding. This method is _not_
|
||||
responsible for performing that encoding. This information
|
||||
is passed in so that it can be substituted in if the
|
||||
document contains a <META> tag that mentions the document's
|
||||
encoding.
|
||||
|
||||
:param formatter: The output formatter responsible for converting
|
||||
entities to Unicode characters.
|
||||
"""
|
||||
# First off, turn a string formatter into a function. This
|
||||
# will stop the lookup from happening over and over again.
|
||||
@@ -1137,7 +1260,17 @@ class Tag(PageElement):
|
||||
def encode_contents(
|
||||
self, indent_level=None, encoding=DEFAULT_OUTPUT_ENCODING,
|
||||
formatter="minimal"):
|
||||
"""Renders the contents of this tag as a bytestring."""
|
||||
"""Renders the contents of this tag as a bytestring.
|
||||
|
||||
:param indent_level: Each line of the rendering will be
|
||||
indented this many spaces.
|
||||
|
||||
:param eventual_encoding: The bytestring will be in this encoding.
|
||||
|
||||
:param formatter: The output formatter responsible for converting
|
||||
entities to Unicode characters.
|
||||
"""
|
||||
|
||||
contents = self.decode_contents(indent_level, encoding, formatter)
|
||||
return contents.encode(encoding)
|
||||
|
||||
@@ -1201,26 +1334,57 @@ class Tag(PageElement):
|
||||
|
||||
_selector_combinators = ['>', '+', '~']
|
||||
_select_debug = False
|
||||
def select(self, selector, _candidate_generator=None):
|
||||
quoted_colon = re.compile('"[^"]*:[^"]*"')
|
||||
def select_one(self, selector):
|
||||
"""Perform a CSS selection operation on the current element."""
|
||||
tokens = selector.split()
|
||||
value = self.select(selector, limit=1)
|
||||
if value:
|
||||
return value[0]
|
||||
return None
|
||||
|
||||
def select(self, selector, _candidate_generator=None, limit=None):
|
||||
"""Perform a CSS selection operation on the current element."""
|
||||
|
||||
# Handle grouping selectors if ',' exists, ie: p,a
|
||||
if ',' in selector:
|
||||
context = []
|
||||
for partial_selector in selector.split(','):
|
||||
partial_selector = partial_selector.strip()
|
||||
if partial_selector == '':
|
||||
raise ValueError('Invalid group selection syntax: %s' % selector)
|
||||
candidates = self.select(partial_selector, limit=limit)
|
||||
for candidate in candidates:
|
||||
if candidate not in context:
|
||||
context.append(candidate)
|
||||
|
||||
if limit and len(context) >= limit:
|
||||
break
|
||||
return context
|
||||
tokens = shlex.split(selector)
|
||||
current_context = [self]
|
||||
|
||||
if tokens[-1] in self._selector_combinators:
|
||||
raise ValueError(
|
||||
'Final combinator "%s" is missing an argument.' % tokens[-1])
|
||||
|
||||
if self._select_debug:
|
||||
print 'Running CSS selector "%s"' % selector
|
||||
|
||||
for index, token in enumerate(tokens):
|
||||
if self._select_debug:
|
||||
print ' Considering token "%s"' % token
|
||||
recursive_candidate_generator = None
|
||||
tag_name = None
|
||||
new_context = []
|
||||
new_context_ids = set([])
|
||||
|
||||
if tokens[index-1] in self._selector_combinators:
|
||||
# This token was consumed by the previous combinator. Skip it.
|
||||
if self._select_debug:
|
||||
print ' Token was consumed by the previous combinator.'
|
||||
continue
|
||||
|
||||
if self._select_debug:
|
||||
print ' Considering token "%s"' % token
|
||||
recursive_candidate_generator = None
|
||||
tag_name = None
|
||||
|
||||
# Each operation corresponds to a checker function, a rule
|
||||
# for determining whether a candidate matches the
|
||||
# selector. Candidates are generated by the active
|
||||
@@ -1248,7 +1412,7 @@ class Tag(PageElement):
|
||||
return classes.issubset(candidate.get('class', []))
|
||||
checker = classes_match
|
||||
|
||||
elif ':' in token:
|
||||
elif ':' in token and not self.quoted_colon.search(token):
|
||||
# Pseudo-class
|
||||
tag_name, pseudo = token.split(':', 1)
|
||||
if tag_name == '':
|
||||
@@ -1256,35 +1420,35 @@ class Tag(PageElement):
|
||||
"A pseudo-class must be prefixed with a tag name.")
|
||||
pseudo_attributes = re.match('([a-zA-Z\d-]+)\(([a-zA-Z\d]+)\)', pseudo)
|
||||
found = []
|
||||
if pseudo_attributes is not None:
|
||||
if pseudo_attributes is None:
|
||||
pseudo_type = pseudo
|
||||
pseudo_value = None
|
||||
else:
|
||||
pseudo_type, pseudo_value = pseudo_attributes.groups()
|
||||
if pseudo_type == 'nth-of-type':
|
||||
try:
|
||||
pseudo_value = int(pseudo_value)
|
||||
except:
|
||||
raise NotImplementedError(
|
||||
'Only numeric values are currently supported for the nth-of-type pseudo-class.')
|
||||
if pseudo_value < 1:
|
||||
raise ValueError(
|
||||
'nth-of-type pseudo-class value must be at least 1.')
|
||||
class Counter(object):
|
||||
def __init__(self, destination):
|
||||
self.count = 0
|
||||
self.destination = destination
|
||||
|
||||
def nth_child_of_type(self, tag):
|
||||
self.count += 1
|
||||
if self.count == self.destination:
|
||||
return True
|
||||
if self.count > self.destination:
|
||||
# Stop the generator that's sending us
|
||||
# these things.
|
||||
raise StopIteration()
|
||||
return False
|
||||
checker = Counter(pseudo_value).nth_child_of_type
|
||||
else:
|
||||
if pseudo_type == 'nth-of-type':
|
||||
try:
|
||||
pseudo_value = int(pseudo_value)
|
||||
except:
|
||||
raise NotImplementedError(
|
||||
'Only the following pseudo-classes are implemented: nth-of-type.')
|
||||
'Only numeric values are currently supported for the nth-of-type pseudo-class.')
|
||||
if pseudo_value < 1:
|
||||
raise ValueError(
|
||||
'nth-of-type pseudo-class value must be at least 1.')
|
||||
class Counter(object):
|
||||
def __init__(self, destination):
|
||||
self.count = 0
|
||||
self.destination = destination
|
||||
|
||||
def nth_child_of_type(self, tag):
|
||||
self.count += 1
|
||||
if self.count == self.destination:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
checker = Counter(pseudo_value).nth_child_of_type
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
'Only the following pseudo-classes are implemented: nth-of-type.')
|
||||
|
||||
elif token == '*':
|
||||
# Star selector -- matches everything
|
||||
@@ -1311,7 +1475,6 @@ class Tag(PageElement):
|
||||
else:
|
||||
raise ValueError(
|
||||
'Unsupported or invalid CSS selector: "%s"' % token)
|
||||
|
||||
if recursive_candidate_generator:
|
||||
# This happens when the selector looks like "> foo".
|
||||
#
|
||||
@@ -1361,8 +1524,7 @@ class Tag(PageElement):
|
||||
else:
|
||||
_use_candidate_generator = _candidate_generator
|
||||
|
||||
new_context = []
|
||||
new_context_ids = set([])
|
||||
count = 0
|
||||
for tag in current_context:
|
||||
if self._select_debug:
|
||||
print " Running candidate generator on %s %s" % (
|
||||
@@ -1391,6 +1553,8 @@ class Tag(PageElement):
|
||||
print " FAILURE %s %s" % (candidate.name, repr(candidate.attrs))
|
||||
|
||||
current_context = new_context
|
||||
if limit and len(current_context) >= limit:
|
||||
current_context = current_context[:limit]
|
||||
|
||||
if self._select_debug:
|
||||
print "Final verdict:"
|
||||
@@ -1548,28 +1712,22 @@ class SoupStrainer(object):
|
||||
"I don't know how to match against a %s" % markup.__class__)
|
||||
return found
|
||||
|
||||
def _matches(self, markup, match_against):
|
||||
def _matches(self, markup, match_against, already_tried=None):
|
||||
# print u"Matching %s against %s" % (markup, match_against)
|
||||
result = False
|
||||
if isinstance(markup, list) or isinstance(markup, tuple):
|
||||
# This should only happen when searching a multi-valued attribute
|
||||
# like 'class'.
|
||||
if (isinstance(match_against, unicode)
|
||||
and ' ' in match_against):
|
||||
# A bit of a special case. If they try to match "foo
|
||||
# bar" on a multivalue attribute's value, only accept
|
||||
# the literal value "foo bar"
|
||||
#
|
||||
# XXX This is going to be pretty slow because we keep
|
||||
# splitting match_against. But it shouldn't come up
|
||||
# too often.
|
||||
return (whitespace_re.split(match_against) == markup)
|
||||
else:
|
||||
for item in markup:
|
||||
if self._matches(item, match_against):
|
||||
return True
|
||||
return False
|
||||
|
||||
for item in markup:
|
||||
if self._matches(item, match_against):
|
||||
return True
|
||||
# We didn't match any particular value of the multivalue
|
||||
# attribute, but maybe we match the attribute value when
|
||||
# considered as a string.
|
||||
if self._matches(' '.join(markup), match_against):
|
||||
return True
|
||||
return False
|
||||
|
||||
if match_against is True:
|
||||
# True matches any non-None value.
|
||||
return markup is not None
|
||||
@@ -1579,6 +1737,7 @@ class SoupStrainer(object):
|
||||
|
||||
# Custom callables take the tag as an argument, but all
|
||||
# other ways of matching match the tag name as a string.
|
||||
original_markup = markup
|
||||
if isinstance(markup, Tag):
|
||||
markup = markup.name
|
||||
|
||||
@@ -1589,18 +1748,51 @@ class SoupStrainer(object):
|
||||
# None matches None, False, an empty string, an empty list, and so on.
|
||||
return not match_against
|
||||
|
||||
if isinstance(match_against, unicode):
|
||||
if (hasattr(match_against, '__iter__')
|
||||
and not isinstance(match_against, basestring)):
|
||||
# We're asked to match against an iterable of items.
|
||||
# The markup must be match at least one item in the
|
||||
# iterable. We'll try each one in turn.
|
||||
#
|
||||
# To avoid infinite recursion we need to keep track of
|
||||
# items we've already seen.
|
||||
if not already_tried:
|
||||
already_tried = set()
|
||||
for item in match_against:
|
||||
if item.__hash__:
|
||||
key = item
|
||||
else:
|
||||
key = id(item)
|
||||
if key in already_tried:
|
||||
continue
|
||||
else:
|
||||
already_tried.add(key)
|
||||
if self._matches(original_markup, item, already_tried):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
# Beyond this point we might need to run the test twice: once against
|
||||
# the tag's name and once against its prefixed name.
|
||||
match = False
|
||||
|
||||
if not match and isinstance(match_against, unicode):
|
||||
# Exact string match
|
||||
return markup == match_against
|
||||
match = markup == match_against
|
||||
|
||||
if hasattr(match_against, 'match'):
|
||||
if not match and hasattr(match_against, 'search'):
|
||||
# Regexp match
|
||||
return match_against.search(markup)
|
||||
|
||||
if hasattr(match_against, '__iter__'):
|
||||
# The markup must be an exact match against something
|
||||
# in the iterable.
|
||||
return markup in match_against
|
||||
if (not match
|
||||
and isinstance(original_markup, Tag)
|
||||
and original_markup.prefix):
|
||||
# Try the whole thing again with the prefixed tag name.
|
||||
return self._matches(
|
||||
original_markup.prefix + ':' + original_markup.name, match_against
|
||||
)
|
||||
|
||||
return match
|
||||
|
||||
|
||||
class ResultSet(list):
|
||||
@@ -1609,3 +1801,8 @@ class ResultSet(list):
|
||||
def __init__(self, source, result=()):
|
||||
super(ResultSet, self).__init__(result)
|
||||
self.source = source
|
||||
|
||||
def __getattr__(self, key):
|
||||
raise AttributeError(
|
||||
"ResultSet object has no attribute '%s'. You're probably treating a list of items like a single item. Did you call find_all() when you meant to call find()?" % key
|
||||
)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Helper classes for tests."""
|
||||
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
__license__ = "MIT"
|
||||
|
||||
import pickle
|
||||
import copy
|
||||
import functools
|
||||
import unittest
|
||||
@@ -43,6 +48,16 @@ class SoupTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(obj.decode(), self.document_for(compare_parsed_to))
|
||||
|
||||
def assertConnectedness(self, element):
|
||||
"""Ensure that next_element and previous_element are properly
|
||||
set for all descendants of the given element.
|
||||
"""
|
||||
earlier = None
|
||||
for e in element.descendants:
|
||||
if earlier:
|
||||
self.assertEqual(e, earlier.next_element)
|
||||
self.assertEqual(earlier, e.previous_element)
|
||||
earlier = e
|
||||
|
||||
class HTMLTreeBuilderSmokeTest(object):
|
||||
|
||||
@@ -54,6 +69,27 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
markup in these tests, there's not much room for interpretation.
|
||||
"""
|
||||
|
||||
def test_empty_element_tags(self):
|
||||
"""Verify that all HTML4 and HTML5 empty element (aka void element) tags
|
||||
are handled correctly.
|
||||
"""
|
||||
for name in [
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr',
|
||||
'spacer', 'frame'
|
||||
]:
|
||||
soup = self.soup("")
|
||||
new_tag = soup.new_tag(name)
|
||||
self.assertEqual(True, new_tag.is_empty_element)
|
||||
|
||||
def test_pickle_and_unpickle_identity(self):
|
||||
# Pickling a tree, then unpickling it, yields a tree identical
|
||||
# to the original.
|
||||
tree = self.soup("<a><b>foo</a>")
|
||||
dumped = pickle.dumps(tree, 2)
|
||||
loaded = pickle.loads(dumped)
|
||||
self.assertEqual(loaded.__class__, BeautifulSoup)
|
||||
self.assertEqual(loaded.decode(), tree.decode())
|
||||
|
||||
def assertDoctypeHandled(self, doctype_fragment):
|
||||
"""Assert that a given doctype string is handled correctly."""
|
||||
doctype_str, soup = self._document_with_doctype(doctype_fragment)
|
||||
@@ -114,6 +150,19 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
soup.encode("utf-8").replace(b"\n", b""),
|
||||
markup.replace(b"\n", b""))
|
||||
|
||||
def test_processing_instruction(self):
|
||||
# We test both Unicode and bytestring to verify that
|
||||
# process_markup correctly sets processing_instruction_class
|
||||
# even when the markup is already Unicode and there is no
|
||||
# need to process anything.
|
||||
markup = u"""<?PITarget PIContent?>"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(markup, soup.decode())
|
||||
|
||||
markup = b"""<?PITarget PIContent?>"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(markup, soup.encode("utf8"))
|
||||
|
||||
def test_deepcopy(self):
|
||||
"""Make sure you can copy the tree builder.
|
||||
|
||||
@@ -155,6 +204,23 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
def test_nested_formatting_elements(self):
|
||||
self.assertSoupEquals("<em><em></em></em>")
|
||||
|
||||
def test_double_head(self):
|
||||
html = '''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Ordinary HEAD element test</title>
|
||||
</head>
|
||||
<script type="text/javascript">
|
||||
alert("Help!");
|
||||
</script>
|
||||
<body>
|
||||
Hello, world!
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
soup = self.soup(html)
|
||||
self.assertEqual("text/javascript", soup.find('script')['type'])
|
||||
|
||||
def test_comment(self):
|
||||
# Comments are represented as Comment objects.
|
||||
markup = "<p>foo<!--foobar-->baz</p>"
|
||||
@@ -171,9 +237,22 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
self.assertEqual(comment, baz.previous_element)
|
||||
|
||||
def test_preserved_whitespace_in_pre_and_textarea(self):
|
||||
"""Whitespace must be preserved in <pre> and <textarea> tags."""
|
||||
self.assertSoupEquals("<pre> </pre>")
|
||||
self.assertSoupEquals("<textarea> woo </textarea>")
|
||||
"""Whitespace must be preserved in <pre> and <textarea> tags,
|
||||
even if that would mean not prettifying the markup.
|
||||
"""
|
||||
pre_markup = "<pre> </pre>"
|
||||
textarea_markup = "<textarea> woo\nwoo </textarea>"
|
||||
self.assertSoupEquals(pre_markup)
|
||||
self.assertSoupEquals(textarea_markup)
|
||||
|
||||
soup = self.soup(pre_markup)
|
||||
self.assertEqual(soup.pre.prettify(), pre_markup)
|
||||
|
||||
soup = self.soup(textarea_markup)
|
||||
self.assertEqual(soup.textarea.prettify(), textarea_markup)
|
||||
|
||||
soup = self.soup("<textarea></textarea>")
|
||||
self.assertEqual(soup.textarea.prettify(), "<textarea></textarea>")
|
||||
|
||||
def test_nested_inline_elements(self):
|
||||
"""Inline elements can be nested indefinitely."""
|
||||
@@ -221,6 +300,14 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(["css"], soup.div.div['class'])
|
||||
|
||||
def test_multivalued_attribute_on_html(self):
|
||||
# html5lib uses a different API to set the attributes ot the
|
||||
# <html> tag. This has caused problems with multivalued
|
||||
# attributes.
|
||||
markup = '<html class="a b"></html>'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(["a", "b"], soup.html['class'])
|
||||
|
||||
def test_angle_brackets_in_attribute_values_are_escaped(self):
|
||||
self.assertSoupEquals('<a b="<a>"></a>', '<a b="<a>"></a>')
|
||||
|
||||
@@ -253,6 +340,42 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
soup = self.soup("<html><h2>\nfoo</h2><p></p></html>")
|
||||
self.assertEqual("p", soup.h2.string.next_element.name)
|
||||
self.assertEqual("p", soup.p.name)
|
||||
self.assertConnectedness(soup)
|
||||
|
||||
def test_empty_element_tags(self):
|
||||
"""Verify consistent handling of empty-element tags,
|
||||
no matter how they come in through the markup.
|
||||
"""
|
||||
self.assertSoupEquals('<br/><br/><br/>', "<br/><br/><br/>")
|
||||
self.assertSoupEquals('<br /><br /><br />', "<br/><br/><br/>")
|
||||
|
||||
def test_head_tag_between_head_and_body(self):
|
||||
"Prevent recurrence of a bug in the html5lib treebuilder."
|
||||
content = """<html><head></head>
|
||||
<link></link>
|
||||
<body>foo</body>
|
||||
</html>
|
||||
"""
|
||||
soup = self.soup(content)
|
||||
self.assertNotEqual(None, soup.html.body)
|
||||
self.assertConnectedness(soup)
|
||||
|
||||
def test_multiple_copies_of_a_tag(self):
|
||||
"Prevent recurrence of a bug in the html5lib treebuilder."
|
||||
content = """<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<article id="a" >
|
||||
<div><a href="1"></div>
|
||||
<footer>
|
||||
<a href="2"></a>
|
||||
</footer>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
soup = self.soup(content)
|
||||
self.assertConnectedness(soup.article)
|
||||
|
||||
def test_basic_namespaces(self):
|
||||
"""Parsers don't need to *understand* namespaces, but at the
|
||||
@@ -399,7 +522,9 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
hebrew_document = b'<html><head><title>Hebrew (ISO 8859-8) in Visual Directionality</title></head><body><h1>Hebrew (ISO 8859-8) in Visual Directionality</h1>\xed\xe5\xec\xf9</body></html>'
|
||||
soup = self.soup(
|
||||
hebrew_document, from_encoding="iso8859-8")
|
||||
self.assertEqual(soup.original_encoding, 'iso8859-8')
|
||||
# Some tree builders call it iso8859-8, others call it iso-8859-9.
|
||||
# That's not a difference we really care about.
|
||||
assert soup.original_encoding in ('iso8859-8', 'iso-8859-8')
|
||||
self.assertEqual(
|
||||
soup.encode('utf-8'),
|
||||
hebrew_document.decode("iso8859-8").encode("utf-8"))
|
||||
@@ -463,11 +588,30 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
|
||||
class XMLTreeBuilderSmokeTest(object):
|
||||
|
||||
def test_pickle_and_unpickle_identity(self):
|
||||
# Pickling a tree, then unpickling it, yields a tree identical
|
||||
# to the original.
|
||||
tree = self.soup("<a><b>foo</a>")
|
||||
dumped = pickle.dumps(tree, 2)
|
||||
loaded = pickle.loads(dumped)
|
||||
self.assertEqual(loaded.__class__, BeautifulSoup)
|
||||
self.assertEqual(loaded.decode(), tree.decode())
|
||||
|
||||
def test_docstring_generated(self):
|
||||
soup = self.soup("<root/>")
|
||||
self.assertEqual(
|
||||
soup.encode(), b'<?xml version="1.0" encoding="utf-8"?>\n<root/>')
|
||||
|
||||
def test_xml_declaration(self):
|
||||
markup = b"""<?xml version="1.0" encoding="utf8"?>\n<foo/>"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(markup, soup.encode("utf8"))
|
||||
|
||||
def test_processing_instruction(self):
|
||||
markup = b"""<?xml version="1.0" encoding="utf8"?>\n<?PITarget PIContent?>"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(markup, soup.encode("utf8"))
|
||||
|
||||
def test_real_xhtml_document(self):
|
||||
"""A real XHTML document should come out *exactly* the same as it went in."""
|
||||
markup = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
@@ -485,7 +629,7 @@ class XMLTreeBuilderSmokeTest(object):
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
"""
|
||||
soup = BeautifulSoup(doc, "xml")
|
||||
soup = BeautifulSoup(doc, "lxml-xml")
|
||||
# lxml would have stripped this while parsing, but we can add
|
||||
# it later.
|
||||
soup.script.string = 'console.log("< < hey > > ");'
|
||||
@@ -544,6 +688,40 @@ class XMLTreeBuilderSmokeTest(object):
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(unicode(soup.foo), markup)
|
||||
|
||||
def test_find_by_prefixed_name(self):
|
||||
doc = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<Document xmlns="http://example.com/ns0"
|
||||
xmlns:ns1="http://example.com/ns1"
|
||||
xmlns:ns2="http://example.com/ns2"
|
||||
<ns1:tag>foo</ns1:tag>
|
||||
<ns1:tag>bar</ns1:tag>
|
||||
<ns2:tag key="value">baz</ns2:tag>
|
||||
</Document>
|
||||
"""
|
||||
soup = self.soup(doc)
|
||||
|
||||
# There are three <tag> tags.
|
||||
self.assertEqual(3, len(soup.find_all('tag')))
|
||||
|
||||
# But two of them are ns1:tag and one of them is ns2:tag.
|
||||
self.assertEqual(2, len(soup.find_all('ns1:tag')))
|
||||
self.assertEqual(1, len(soup.find_all('ns2:tag')))
|
||||
|
||||
self.assertEqual(1, len(soup.find_all('ns2:tag', key='value')))
|
||||
self.assertEqual(3, len(soup.find_all(['ns1:tag', 'ns2:tag'])))
|
||||
|
||||
def test_copy_tag_preserves_namespace(self):
|
||||
xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://example.com/ns0"/>"""
|
||||
|
||||
soup = self.soup(xml)
|
||||
tag = soup.document
|
||||
duplicate = copy.copy(tag)
|
||||
|
||||
# The two tags have the same namespace prefix.
|
||||
self.assertEqual(tag.prefix, duplicate.prefix)
|
||||
|
||||
|
||||
class HTML5TreeBuilderSmokeTest(HTMLTreeBuilderSmokeTest):
|
||||
"""Smoke test for a tree builder that supports HTML5."""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests of the builder registry."""
|
||||
|
||||
import unittest
|
||||
import warnings
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.builder import (
|
||||
@@ -67,10 +68,15 @@ class BuiltInRegistryTest(unittest.TestCase):
|
||||
HTMLParserTreeBuilder)
|
||||
|
||||
def test_beautifulsoup_constructor_does_lookup(self):
|
||||
# You can pass in a string.
|
||||
BeautifulSoup("", features="html")
|
||||
# Or a list of strings.
|
||||
BeautifulSoup("", features=["html", "fast"])
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
# This will create a warning about not explicitly
|
||||
# specifying a parser, but we'll ignore it.
|
||||
|
||||
# You can pass in a string.
|
||||
BeautifulSoup("", features="html")
|
||||
# Or a list of strings.
|
||||
BeautifulSoup("", features=["html", "fast"])
|
||||
|
||||
# You'll get an exception if BS can't find an appropriate
|
||||
# builder.
|
||||
|
||||
@@ -83,3 +83,48 @@ class HTML5LibBuilderSmokeTest(SoupTest, HTML5TreeBuilderSmokeTest):
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(u"<body><p><em>foo</em></p><em>\n</em><p><em>bar<a></a></em></p>\n</body>", soup.body.decode())
|
||||
self.assertEqual(2, len(soup.find_all('p')))
|
||||
|
||||
def test_reparented_markup_containing_identical_whitespace_nodes(self):
|
||||
"""Verify that we keep the two whitespace nodes in this
|
||||
document distinct when reparenting the adjacent <tbody> tags.
|
||||
"""
|
||||
markup = '<table> <tbody><tbody><ims></tbody> </table>'
|
||||
soup = self.soup(markup)
|
||||
space1, space2 = soup.find_all(string=' ')
|
||||
tbody1, tbody2 = soup.find_all('tbody')
|
||||
assert space1.next_element is tbody1
|
||||
assert tbody2.next_element is space2
|
||||
|
||||
def test_reparented_markup_containing_children(self):
|
||||
markup = '<div><a>aftermath<p><noscript>target</noscript>aftermath</a></p></div>'
|
||||
soup = self.soup(markup)
|
||||
noscript = soup.noscript
|
||||
self.assertEqual("target", noscript.next_element)
|
||||
target = soup.find(string='target')
|
||||
|
||||
# The 'aftermath' string was duplicated; we want the second one.
|
||||
final_aftermath = soup.find_all(string='aftermath')[-1]
|
||||
|
||||
# The <noscript> tag was moved beneath a copy of the <a> tag,
|
||||
# but the 'target' string within is still connected to the
|
||||
# (second) 'aftermath' string.
|
||||
self.assertEqual(final_aftermath, target.next_element)
|
||||
self.assertEqual(target, final_aftermath.previous_element)
|
||||
|
||||
def test_processing_instruction(self):
|
||||
"""Processing instructions become comments."""
|
||||
markup = b"""<?PITarget PIContent?>"""
|
||||
soup = self.soup(markup)
|
||||
assert str(soup).startswith("<!--?PITarget PIContent?-->")
|
||||
|
||||
def test_cloned_multivalue_node(self):
|
||||
markup = b"""<a class="my_class"><p></a>"""
|
||||
soup = self.soup(markup)
|
||||
a1, a2 = soup.find_all('a')
|
||||
self.assertEqual(a1, a2)
|
||||
assert a1 is not a2
|
||||
|
||||
def test_foster_parenting(self):
|
||||
markup = b"""<table><td></tbody>A"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(u"<body>A<table><tbody><tr><td></td></tr></tbody></table></body>", soup.body.decode())
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests to ensure that the html.parser tree builder generates good
|
||||
trees."""
|
||||
|
||||
from pdb import set_trace
|
||||
import pickle
|
||||
from bs4.testing import SoupTest, HTMLTreeBuilderSmokeTest
|
||||
from bs4.builder import HTMLParserTreeBuilder
|
||||
|
||||
@@ -17,3 +19,16 @@ class HTMLParserTreeBuilderSmokeTest(SoupTest, HTMLTreeBuilderSmokeTest):
|
||||
def test_namespaced_public_doctype(self):
|
||||
# html.parser can't handle namespaced doctypes, so skip this one.
|
||||
pass
|
||||
|
||||
def test_builder_is_pickled(self):
|
||||
"""Unlike most tree builders, HTMLParserTreeBuilder and will
|
||||
be restored after pickling.
|
||||
"""
|
||||
tree = self.soup("<a><b>foo</a>")
|
||||
dumped = pickle.dumps(tree, 2)
|
||||
loaded = pickle.loads(dumped)
|
||||
self.assertTrue(isinstance(loaded.builder, type(tree.builder)))
|
||||
|
||||
def test_redundant_empty_element_closing_tags(self):
|
||||
self.assertSoupEquals('<br></br><br></br><br></br>', "<br/><br/><br/>")
|
||||
self.assertSoupEquals('</br></br></br>', "")
|
||||
|
||||
@@ -65,21 +65,6 @@ class LXMLTreeBuilderSmokeTest(SoupTest, HTMLTreeBuilderSmokeTest):
|
||||
self.assertEqual(u"<b/>", unicode(soup.b))
|
||||
self.assertTrue("BeautifulStoneSoup class is deprecated" in str(w[0].message))
|
||||
|
||||
def test_real_xhtml_document(self):
|
||||
"""lxml strips the XML definition from an XHTML doc, which is fine."""
|
||||
markup = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Hello.</title></head>
|
||||
<body>Goodbye.</body>
|
||||
</html>"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(
|
||||
soup.encode("utf-8").replace(b"\n", b''),
|
||||
markup.replace(b'\n', b'').replace(
|
||||
b'<?xml version="1.0" encoding="utf-8"?>', b''))
|
||||
|
||||
|
||||
@skipIf(
|
||||
not LXML_PRESENT,
|
||||
"lxml seems not to be present, not testing its XML tree builder.")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests of Beautiful Soup as a whole."""
|
||||
|
||||
from pdb import set_trace
|
||||
import logging
|
||||
import unittest
|
||||
import sys
|
||||
@@ -20,6 +21,7 @@ import bs4.dammit
|
||||
from bs4.dammit import (
|
||||
EntitySubstitution,
|
||||
UnicodeDammit,
|
||||
EncodingDetector,
|
||||
)
|
||||
from bs4.testing import (
|
||||
SoupTest,
|
||||
@@ -33,7 +35,6 @@ try:
|
||||
except ImportError, e:
|
||||
LXML_PRESENT = False
|
||||
|
||||
PYTHON_2_PRE_2_7 = (sys.version_info < (2,7))
|
||||
PYTHON_3_PRE_3_2 = (sys.version_info[0] == 3 and sys.version_info < (3,2))
|
||||
|
||||
class TestConstructor(SoupTest):
|
||||
@@ -48,8 +49,34 @@ class TestConstructor(SoupTest):
|
||||
soup = self.soup(data)
|
||||
self.assertEqual(u"foo\0bar", soup.h1.string)
|
||||
|
||||
def test_exclude_encodings(self):
|
||||
utf8_data = u"Räksmörgås".encode("utf-8")
|
||||
soup = self.soup(utf8_data, exclude_encodings=["utf-8"])
|
||||
self.assertEqual("windows-1252", soup.original_encoding)
|
||||
|
||||
class TestDeprecatedConstructorArguments(SoupTest):
|
||||
|
||||
class TestWarnings(SoupTest):
|
||||
|
||||
def _no_parser_specified(self, s, is_there=True):
|
||||
v = s.startswith(BeautifulSoup.NO_PARSER_SPECIFIED_WARNING[:80])
|
||||
self.assertTrue(v)
|
||||
|
||||
def test_warning_if_no_parser_specified(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("<a><b></b></a>")
|
||||
msg = str(w[0].message)
|
||||
self._assert_no_parser_specified(msg)
|
||||
|
||||
def test_warning_if_parser_specified_too_vague(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("<a><b></b></a>", "html")
|
||||
msg = str(w[0].message)
|
||||
self._assert_no_parser_specified(msg)
|
||||
|
||||
def test_no_warning_if_explicit_parser_specified(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("<a><b></b></a>", "html.parser")
|
||||
self.assertEqual([], w)
|
||||
|
||||
def test_parseOnlyThese_renamed_to_parse_only(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
@@ -90,15 +117,34 @@ class TestWarnings(SoupTest):
|
||||
soup = self.soup(filename)
|
||||
self.assertEqual(0, len(w))
|
||||
|
||||
def test_url_warning(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("http://www.crummy.com/")
|
||||
msg = str(w[0].message)
|
||||
self.assertTrue("looks like a URL" in msg)
|
||||
def test_url_warning_with_bytes_url(self):
|
||||
with warnings.catch_warnings(record=True) as warning_list:
|
||||
soup = self.soup(b"http://www.crummybytes.com/")
|
||||
# Be aware this isn't the only warning that can be raised during
|
||||
# execution..
|
||||
self.assertTrue(any("looks like a URL" in str(w.message)
|
||||
for w in warning_list))
|
||||
|
||||
def test_url_warning_with_unicode_url(self):
|
||||
with warnings.catch_warnings(record=True) as warning_list:
|
||||
# note - this url must differ from the bytes one otherwise
|
||||
# python's warnings system swallows the second warning
|
||||
soup = self.soup(u"http://www.crummyunicode.com/")
|
||||
self.assertTrue(any("looks like a URL" in str(w.message)
|
||||
for w in warning_list))
|
||||
|
||||
def test_url_warning_with_bytes_and_space(self):
|
||||
with warnings.catch_warnings(record=True) as warning_list:
|
||||
soup = self.soup(b"http://www.crummybytes.com/ is great")
|
||||
self.assertFalse(any("looks like a URL" in str(w.message)
|
||||
for w in warning_list))
|
||||
|
||||
def test_url_warning_with_unicode_and_space(self):
|
||||
with warnings.catch_warnings(record=True) as warning_list:
|
||||
soup = self.soup(u"http://www.crummyuncode.com/ is great")
|
||||
self.assertFalse(any("looks like a URL" in str(w.message)
|
||||
for w in warning_list))
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("http://www.crummy.com/ is great")
|
||||
self.assertEqual(0, len(w))
|
||||
|
||||
class TestSelectiveParsing(SoupTest):
|
||||
|
||||
@@ -232,7 +278,7 @@ class TestEncodingConversion(SoupTest):
|
||||
self.assertEqual(soup_from_unicode.encode('utf-8'), self.utf8_data)
|
||||
|
||||
@skipIf(
|
||||
PYTHON_2_PRE_2_7 or PYTHON_3_PRE_3_2,
|
||||
PYTHON_3_PRE_3_2,
|
||||
"Bad HTMLParser detected; skipping test of non-ASCII characters in attribute name.")
|
||||
def test_attribute_name_containing_unicode_characters(self):
|
||||
markup = u'<div><a \N{SNOWMAN}="snowman"></a></div>'
|
||||
@@ -271,10 +317,11 @@ class TestUnicodeDammit(unittest.TestCase):
|
||||
dammit.unicode_markup, """<foo>''""</foo>""")
|
||||
|
||||
def test_detect_utf8(self):
|
||||
utf8 = b"\xc3\xa9"
|
||||
utf8 = b"Sacr\xc3\xa9 bleu! \xe2\x98\x83"
|
||||
dammit = UnicodeDammit(utf8)
|
||||
self.assertEqual(dammit.unicode_markup, u'\xe9')
|
||||
self.assertEqual(dammit.original_encoding.lower(), 'utf-8')
|
||||
self.assertEqual(dammit.unicode_markup, u'Sacr\xe9 bleu! \N{SNOWMAN}')
|
||||
|
||||
|
||||
def test_convert_hebrew(self):
|
||||
hebrew = b"\xed\xe5\xec\xf9"
|
||||
@@ -299,6 +346,26 @@ class TestUnicodeDammit(unittest.TestCase):
|
||||
dammit = UnicodeDammit(utf8_data, [bad_encoding])
|
||||
self.assertEqual(dammit.original_encoding.lower(), 'utf-8')
|
||||
|
||||
def test_exclude_encodings(self):
|
||||
# This is UTF-8.
|
||||
utf8_data = u"Räksmörgås".encode("utf-8")
|
||||
|
||||
# But if we exclude UTF-8 from consideration, the guess is
|
||||
# Windows-1252.
|
||||
dammit = UnicodeDammit(utf8_data, exclude_encodings=["utf-8"])
|
||||
self.assertEqual(dammit.original_encoding.lower(), 'windows-1252')
|
||||
|
||||
# And if we exclude that, there is no valid guess at all.
|
||||
dammit = UnicodeDammit(
|
||||
utf8_data, exclude_encodings=["utf-8", "windows-1252"])
|
||||
self.assertEqual(dammit.original_encoding, None)
|
||||
|
||||
def test_encoding_detector_replaces_junk_in_encoding_name_with_replacement_character(self):
|
||||
detected = EncodingDetector(
|
||||
b'<?xml version="1.0" encoding="UTF-\xdb" ?>')
|
||||
encodings = list(detected.encodings)
|
||||
assert u'utf-\N{REPLACEMENT CHARACTER}' in encodings
|
||||
|
||||
def test_detect_html5_style_meta_tag(self):
|
||||
|
||||
for data in (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Beautiful Soup's tree traversal methods.
|
||||
|
||||
@@ -9,6 +10,7 @@ same markup, but all Beautiful Soup trees can be traversed with the
|
||||
methods tested here.
|
||||
"""
|
||||
|
||||
from pdb import set_trace
|
||||
import copy
|
||||
import pickle
|
||||
import re
|
||||
@@ -19,8 +21,10 @@ from bs4.builder import (
|
||||
HTMLParserTreeBuilder,
|
||||
)
|
||||
from bs4.element import (
|
||||
PY3K,
|
||||
CData,
|
||||
Comment,
|
||||
Declaration,
|
||||
Doctype,
|
||||
NavigableString,
|
||||
SoupStrainer,
|
||||
@@ -68,7 +72,13 @@ class TestFind(TreeTest):
|
||||
|
||||
def test_unicode_text_find(self):
|
||||
soup = self.soup(u'<h1>Räksmörgås</h1>')
|
||||
self.assertEqual(soup.find(text=u'Räksmörgås'), u'Räksmörgås')
|
||||
self.assertEqual(soup.find(string=u'Räksmörgås'), u'Räksmörgås')
|
||||
|
||||
def test_unicode_attribute_find(self):
|
||||
soup = self.soup(u'<h1 id="Räksmörgås">here it is</h1>')
|
||||
str(soup)
|
||||
self.assertEqual("here it is", soup.find(id=u'Räksmörgås').text)
|
||||
|
||||
|
||||
def test_find_everything(self):
|
||||
"""Test an optimization that finds all tags."""
|
||||
@@ -87,6 +97,7 @@ class TestFindAll(TreeTest):
|
||||
"""You can search the tree for text nodes."""
|
||||
soup = self.soup("<html>Foo<b>bar</b>\xbb</html>")
|
||||
# Exact match.
|
||||
self.assertEqual(soup.find_all(string="bar"), [u"bar"])
|
||||
self.assertEqual(soup.find_all(text="bar"), [u"bar"])
|
||||
# Match any of a number of strings.
|
||||
self.assertEqual(
|
||||
@@ -212,7 +223,19 @@ class TestFindAllByName(TreeTest):
|
||||
self.assertSelects(
|
||||
tree.find_all(id_matches_name), ["Match 1.", "Match 2."])
|
||||
|
||||
def test_find_with_multi_valued_attribute(self):
|
||||
soup = self.soup(
|
||||
"<div class='a b'>1</div><div class='a c'>2</div><div class='a d'>3</div>"
|
||||
)
|
||||
r1 = soup.find('div', 'a d');
|
||||
r2 = soup.find('div', re.compile(r'a d'));
|
||||
r3, r4 = soup.find_all('div', ['a b', 'a d']);
|
||||
self.assertEqual('3', r1.string)
|
||||
self.assertEqual('3', r2.string)
|
||||
self.assertEqual('1', r3.string)
|
||||
self.assertEqual('3', r4.string)
|
||||
|
||||
|
||||
class TestFindAllByAttribute(TreeTest):
|
||||
|
||||
def test_find_all_by_attribute_name(self):
|
||||
@@ -284,10 +307,10 @@ class TestFindAllByAttribute(TreeTest):
|
||||
f = tree.find_all("gar", class_=re.compile("a"))
|
||||
self.assertSelects(f, ["Found it"])
|
||||
|
||||
# Since the class is not the string "foo bar", but the two
|
||||
# strings "foo" and "bar", this will not find anything.
|
||||
# If the search fails to match the individual strings "foo" and "bar",
|
||||
# it will be tried against the combined string "foo bar".
|
||||
f = tree.find_all("gar", class_=re.compile("o b"))
|
||||
self.assertSelects(f, [])
|
||||
self.assertSelects(f, ["Found it"])
|
||||
|
||||
def test_find_all_with_non_dictionary_for_attrs_finds_by_class(self):
|
||||
soup = self.soup("<a class='bar'>Found it</a>")
|
||||
@@ -325,7 +348,7 @@ class TestFindAllByAttribute(TreeTest):
|
||||
strainer = SoupStrainer(attrs={'id' : 'first'})
|
||||
self.assertSelects(tree.find_all(strainer), ['Match.'])
|
||||
|
||||
def test_find_all_with_missing_atribute(self):
|
||||
def test_find_all_with_missing_attribute(self):
|
||||
# You can pass in None as the value of an attribute to find_all.
|
||||
# This will match tags that do not have that attribute set.
|
||||
tree = self.soup("""<a id="1">ID present.</a>
|
||||
@@ -688,7 +711,7 @@ class TestTagCreation(SoupTest):
|
||||
|
||||
def test_tag_inherits_self_closing_rules_from_builder(self):
|
||||
if XML_BUILDER_PRESENT:
|
||||
xml_soup = BeautifulSoup("", "xml")
|
||||
xml_soup = BeautifulSoup("", "lxml-xml")
|
||||
xml_br = xml_soup.new_tag("br")
|
||||
xml_p = xml_soup.new_tag("p")
|
||||
|
||||
@@ -697,7 +720,7 @@ class TestTagCreation(SoupTest):
|
||||
self.assertEqual(b"<br/>", xml_br.encode())
|
||||
self.assertEqual(b"<p/>", xml_p.encode())
|
||||
|
||||
html_soup = BeautifulSoup("", "html")
|
||||
html_soup = BeautifulSoup("", "html.parser")
|
||||
html_br = html_soup.new_tag("br")
|
||||
html_p = html_soup.new_tag("p")
|
||||
|
||||
@@ -773,6 +796,14 @@ class TestTreeModification(SoupTest):
|
||||
new_a = a.unwrap()
|
||||
self.assertEqual(a, new_a)
|
||||
|
||||
def test_replace_with_and_unwrap_give_useful_exception_when_tag_has_no_parent(self):
|
||||
soup = self.soup("<a><b>Foo</b></a><c>Bar</c>")
|
||||
a = soup.a
|
||||
a.extract()
|
||||
self.assertEqual(None, a.parent)
|
||||
self.assertRaises(ValueError, a.unwrap)
|
||||
self.assertRaises(ValueError, a.replace_with, soup.c)
|
||||
|
||||
def test_replace_tag_with_itself(self):
|
||||
text = "<a><b></b><c>Foo<d></d></c></a><a><e></e></a>"
|
||||
soup = self.soup(text)
|
||||
@@ -1067,6 +1098,31 @@ class TestTreeModification(SoupTest):
|
||||
self.assertEqual(foo_2, soup.a.string)
|
||||
self.assertEqual(bar_2, soup.b.string)
|
||||
|
||||
def test_extract_multiples_of_same_tag(self):
|
||||
soup = self.soup("""
|
||||
<html>
|
||||
<head>
|
||||
<script>foo</script>
|
||||
</head>
|
||||
<body>
|
||||
<script>bar</script>
|
||||
<a></a>
|
||||
</body>
|
||||
<script>baz</script>
|
||||
</html>""")
|
||||
[soup.script.extract() for i in soup.find_all("script")]
|
||||
self.assertEqual("<body>\n\n<a></a>\n</body>", unicode(soup.body))
|
||||
|
||||
|
||||
def test_extract_works_when_element_is_surrounded_by_identical_strings(self):
|
||||
soup = self.soup(
|
||||
'<html>\n'
|
||||
'<body>hi</body>\n'
|
||||
'</html>')
|
||||
soup.find('body').extract()
|
||||
self.assertEqual(None, soup.find('body'))
|
||||
|
||||
|
||||
def test_clear(self):
|
||||
"""Tag.clear()"""
|
||||
soup = self.soup("<p><a>String <em>Italicized</em></a> and another</p>")
|
||||
@@ -1230,6 +1286,10 @@ class TestCDAtaListAttributes(SoupTest):
|
||||
soup = self.soup("<a class='foo\tbar'>")
|
||||
self.assertEqual(b'<a class="foo bar"></a>', soup.a.encode())
|
||||
|
||||
def test_get_attribute_list(self):
|
||||
soup = self.soup("<a id='abc def'>")
|
||||
self.assertEqual(['abc def'], soup.a.get_attribute_list('id'))
|
||||
|
||||
def test_accept_charset(self):
|
||||
soup = self.soup('<form accept-charset="ISO-8859-1 UTF-8">')
|
||||
self.assertEqual(['ISO-8859-1', 'UTF-8'], soup.form['accept-charset'])
|
||||
@@ -1285,6 +1345,13 @@ class TestPersistence(SoupTest):
|
||||
copied = copy.deepcopy(self.tree)
|
||||
self.assertEqual(copied.decode(), self.tree.decode())
|
||||
|
||||
def test_copy_preserves_encoding(self):
|
||||
soup = BeautifulSoup(b'<p> </p>', 'html.parser')
|
||||
encoding = soup.original_encoding
|
||||
copy = soup.__copy__()
|
||||
self.assertEqual(u"<p> </p>", unicode(copy))
|
||||
self.assertEqual(encoding, copy.original_encoding)
|
||||
|
||||
def test_unicode_pickle(self):
|
||||
# A tree containing Unicode characters can be pickled.
|
||||
html = u"<b>\N{SNOWMAN}</b>"
|
||||
@@ -1293,6 +1360,51 @@ class TestPersistence(SoupTest):
|
||||
loaded = pickle.loads(dumped)
|
||||
self.assertEqual(loaded.decode(), soup.decode())
|
||||
|
||||
def test_copy_navigablestring_is_not_attached_to_tree(self):
|
||||
html = u"<b>Foo<a></a></b><b>Bar</b>"
|
||||
soup = self.soup(html)
|
||||
s1 = soup.find(string="Foo")
|
||||
s2 = copy.copy(s1)
|
||||
self.assertEqual(s1, s2)
|
||||
self.assertEqual(None, s2.parent)
|
||||
self.assertEqual(None, s2.next_element)
|
||||
self.assertNotEqual(None, s1.next_sibling)
|
||||
self.assertEqual(None, s2.next_sibling)
|
||||
self.assertEqual(None, s2.previous_element)
|
||||
|
||||
def test_copy_navigablestring_subclass_has_same_type(self):
|
||||
html = u"<b><!--Foo--></b>"
|
||||
soup = self.soup(html)
|
||||
s1 = soup.string
|
||||
s2 = copy.copy(s1)
|
||||
self.assertEqual(s1, s2)
|
||||
self.assertTrue(isinstance(s2, Comment))
|
||||
|
||||
def test_copy_entire_soup(self):
|
||||
html = u"<div><b>Foo<a></a></b><b>Bar</b></div>end"
|
||||
soup = self.soup(html)
|
||||
soup_copy = copy.copy(soup)
|
||||
self.assertEqual(soup, soup_copy)
|
||||
|
||||
def test_copy_tag_copies_contents(self):
|
||||
html = u"<div><b>Foo<a></a></b><b>Bar</b></div>end"
|
||||
soup = self.soup(html)
|
||||
div = soup.div
|
||||
div_copy = copy.copy(div)
|
||||
|
||||
# The two tags look the same, and evaluate to equal.
|
||||
self.assertEqual(unicode(div), unicode(div_copy))
|
||||
self.assertEqual(div, div_copy)
|
||||
|
||||
# But they're not the same object.
|
||||
self.assertFalse(div is div_copy)
|
||||
|
||||
# And they don't have the same relation to the parse tree. The
|
||||
# copy is not associated with a parse tree at all.
|
||||
self.assertEqual(None, div_copy.parent)
|
||||
self.assertEqual(None, div_copy.previous_element)
|
||||
self.assertEqual(None, div_copy.find(string='Bar').next_element)
|
||||
self.assertNotEqual(None, div.find(string='Bar').next_element)
|
||||
|
||||
class TestSubstitutions(SoupTest):
|
||||
|
||||
@@ -1366,7 +1478,7 @@ class TestSubstitutions(SoupTest):
|
||||
console.log("< < hey > > ");
|
||||
</script>
|
||||
"""
|
||||
encoded = BeautifulSoup(doc).encode()
|
||||
encoded = BeautifulSoup(doc, 'html.parser').encode()
|
||||
self.assertTrue(b"< < hey > >" in encoded)
|
||||
|
||||
def test_formatter_skips_style_tag_for_html_documents(self):
|
||||
@@ -1375,7 +1487,7 @@ class TestSubstitutions(SoupTest):
|
||||
console.log("< < hey > > ");
|
||||
</style>
|
||||
"""
|
||||
encoded = BeautifulSoup(doc).encode()
|
||||
encoded = BeautifulSoup(doc, 'html.parser').encode()
|
||||
self.assertTrue(b"< < hey > >" in encoded)
|
||||
|
||||
def test_prettify_leaves_preformatted_text_alone(self):
|
||||
@@ -1387,7 +1499,7 @@ class TestSubstitutions(SoupTest):
|
||||
soup.div.prettify())
|
||||
|
||||
def test_prettify_accepts_formatter(self):
|
||||
soup = BeautifulSoup("<html><body>foo</body></html>")
|
||||
soup = BeautifulSoup("<html><body>foo</body></html>", 'html.parser')
|
||||
pretty = soup.prettify(formatter = lambda x: x.upper())
|
||||
self.assertTrue("FOO" in pretty)
|
||||
|
||||
@@ -1484,6 +1596,14 @@ class TestEncoding(SoupTest):
|
||||
self.assertEqual(
|
||||
u"\N{SNOWMAN}".encode("utf8"), soup.b.renderContents())
|
||||
|
||||
def test_repr(self):
|
||||
html = u"<b>\N{SNOWMAN}</b>"
|
||||
soup = self.soup(html)
|
||||
if PY3K:
|
||||
self.assertEqual(html, repr(soup))
|
||||
else:
|
||||
self.assertEqual(b'<b>\\u2603</b>', repr(soup))
|
||||
|
||||
class TestNavigableStringSubclasses(SoupTest):
|
||||
|
||||
def test_cdata(self):
|
||||
@@ -1522,6 +1642,9 @@ class TestNavigableStringSubclasses(SoupTest):
|
||||
soup.insert(1, doctype)
|
||||
self.assertEqual(soup.encode(), b"<!DOCTYPE foo>\n")
|
||||
|
||||
def test_declaration(self):
|
||||
d = Declaration("foo")
|
||||
self.assertEqual("<?foo?>", d.output_ready())
|
||||
|
||||
class TestSoupSelector(TreeTest):
|
||||
|
||||
@@ -1534,7 +1657,7 @@ class TestSoupSelector(TreeTest):
|
||||
<link rel="stylesheet" href="blah.css" type="text/css" id="l1">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<custom-dashed-tag class="dashed" id="dash1">Hello there.</custom-dashed-tag>
|
||||
<div id="main" class="fancy">
|
||||
<div id="inner">
|
||||
<h1 id="header1">An H1</h1>
|
||||
@@ -1552,8 +1675,18 @@ class TestSoupSelector(TreeTest):
|
||||
<a href="#" id="s2a1">span2a1</a>
|
||||
</span>
|
||||
<span class="span3"></span>
|
||||
<custom-dashed-tag class="dashed" id="dash2"/>
|
||||
<div data-tag="dashedvalue" id="data1"/>
|
||||
</span>
|
||||
</div>
|
||||
<x id="xid">
|
||||
<z id="zida"/>
|
||||
<z id="zidab"/>
|
||||
<z id="zidac"/>
|
||||
</x>
|
||||
<y id="yid">
|
||||
<z id="zidb"/>
|
||||
</y>
|
||||
<p lang="en" id="lang-en">English</p>
|
||||
<p lang="en-gb" id="lang-en-gb">English UK</p>
|
||||
<p lang="en-us" id="lang-en-us">English US</p>
|
||||
@@ -1565,10 +1698,10 @@ class TestSoupSelector(TreeTest):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.soup = BeautifulSoup(self.HTML)
|
||||
self.soup = BeautifulSoup(self.HTML, 'html.parser')
|
||||
|
||||
def assertSelects(self, selector, expected_ids):
|
||||
el_ids = [el['id'] for el in self.soup.select(selector)]
|
||||
def assertSelects(self, selector, expected_ids, **kwargs):
|
||||
el_ids = [el['id'] for el in self.soup.select(selector, **kwargs)]
|
||||
el_ids.sort()
|
||||
expected_ids.sort()
|
||||
self.assertEqual(expected_ids, el_ids,
|
||||
@@ -1591,17 +1724,32 @@ class TestSoupSelector(TreeTest):
|
||||
|
||||
def test_one_tag_many(self):
|
||||
els = self.soup.select('div')
|
||||
self.assertEqual(len(els), 3)
|
||||
self.assertEqual(len(els), 4)
|
||||
for div in els:
|
||||
self.assertEqual(div.name, 'div')
|
||||
|
||||
el = self.soup.select_one('div')
|
||||
self.assertEqual('main', el['id'])
|
||||
|
||||
def test_select_one_returns_none_if_no_match(self):
|
||||
match = self.soup.select_one('nonexistenttag')
|
||||
self.assertEqual(None, match)
|
||||
|
||||
|
||||
def test_tag_in_tag_one(self):
|
||||
els = self.soup.select('div div')
|
||||
self.assertSelects('div div', ['inner'])
|
||||
self.assertSelects('div div', ['inner', 'data1'])
|
||||
|
||||
def test_tag_in_tag_many(self):
|
||||
for selector in ('html div', 'html body div', 'body div'):
|
||||
self.assertSelects(selector, ['main', 'inner', 'footer'])
|
||||
self.assertSelects(selector, ['data1', 'main', 'inner', 'footer'])
|
||||
|
||||
|
||||
def test_limit(self):
|
||||
self.assertSelects('html div', ['main'], limit=1)
|
||||
self.assertSelects('html body div', ['inner', 'main'], limit=2)
|
||||
self.assertSelects('body div', ['data1', 'main', 'inner', 'footer'],
|
||||
limit=10)
|
||||
|
||||
def test_tag_no_match(self):
|
||||
self.assertEqual(len(self.soup.select('del')), 0)
|
||||
@@ -1609,6 +1757,20 @@ class TestSoupSelector(TreeTest):
|
||||
def test_invalid_tag(self):
|
||||
self.assertRaises(ValueError, self.soup.select, 'tag%t')
|
||||
|
||||
def test_select_dashed_tag_ids(self):
|
||||
self.assertSelects('custom-dashed-tag', ['dash1', 'dash2'])
|
||||
|
||||
def test_select_dashed_by_id(self):
|
||||
dashed = self.soup.select('custom-dashed-tag[id=\"dash2\"]')
|
||||
self.assertEqual(dashed[0].name, 'custom-dashed-tag')
|
||||
self.assertEqual(dashed[0]['id'], 'dash2')
|
||||
|
||||
def test_dashed_tag_text(self):
|
||||
self.assertEqual(self.soup.select('body > custom-dashed-tag')[0].text, u'Hello there.')
|
||||
|
||||
def test_select_dashed_matches_find_all(self):
|
||||
self.assertEqual(self.soup.select('custom-dashed-tag'), self.soup.find_all('custom-dashed-tag'))
|
||||
|
||||
def test_header_tags(self):
|
||||
self.assertSelectMultiple(
|
||||
('h1', ['header1']),
|
||||
@@ -1709,6 +1871,7 @@ class TestSoupSelector(TreeTest):
|
||||
('[id^="m"]', ['me', 'main']),
|
||||
('div[id^="m"]', ['main']),
|
||||
('a[id^="m"]', ['me']),
|
||||
('div[data-tag^="dashed"]', ['data1'])
|
||||
)
|
||||
|
||||
def test_attribute_endswith(self):
|
||||
@@ -1716,8 +1879,8 @@ class TestSoupSelector(TreeTest):
|
||||
('[href$=".css"]', ['l1']),
|
||||
('link[href$=".css"]', ['l1']),
|
||||
('link[id$="1"]', ['l1']),
|
||||
('[id$="1"]', ['l1', 'p1', 'header1', 's1a1', 's2a1', 's1a2s1']),
|
||||
('div[id$="1"]', []),
|
||||
('[id$="1"]', ['data1', 'l1', 'p1', 'header1', 's1a1', 's2a1', 's1a2s1', 'dash1']),
|
||||
('div[id$="1"]', ['data1']),
|
||||
('[id$="noending"]', []),
|
||||
)
|
||||
|
||||
@@ -1730,7 +1893,6 @@ class TestSoupSelector(TreeTest):
|
||||
('[rel*="notstyle"]', []),
|
||||
('link[rel*="notstyle"]', []),
|
||||
('link[href*="bla"]', ['l1']),
|
||||
('a[href*="http://"]', ['bob', 'me']),
|
||||
('[href*="http://"]', ['bob', 'me']),
|
||||
('[id*="p"]', ['pmulti', 'p1']),
|
||||
('div[id*="m"]', ['main']),
|
||||
@@ -1739,8 +1901,8 @@ class TestSoupSelector(TreeTest):
|
||||
('[href*=".css"]', ['l1']),
|
||||
('link[href*=".css"]', ['l1']),
|
||||
('link[id*="1"]', ['l1']),
|
||||
('[id*="1"]', ['l1', 'p1', 'header1', 's1a1', 's1a2', 's2a1', 's1a2s1']),
|
||||
('div[id*="1"]', []),
|
||||
('[id*="1"]', ['data1', 'l1', 'p1', 'header1', 's1a1', 's1a2', 's2a1', 's1a2s1', 'dash1']),
|
||||
('div[id*="1"]', ['data1']),
|
||||
('[id*="noending"]', []),
|
||||
# New for this test
|
||||
('[href*="."]', ['bob', 'me', 'l1']),
|
||||
@@ -1748,6 +1910,7 @@ class TestSoupSelector(TreeTest):
|
||||
('link[href*="."]', ['l1']),
|
||||
('div[id*="n"]', ['main', 'inner']),
|
||||
('div[id*="nn"]', ['inner']),
|
||||
('div[data-tag*="edval"]', ['data1'])
|
||||
)
|
||||
|
||||
def test_attribute_exact_or_hypen(self):
|
||||
@@ -1767,8 +1930,25 @@ class TestSoupSelector(TreeTest):
|
||||
('p[class]', ['p1', 'pmulti']),
|
||||
('[blah]', []),
|
||||
('p[blah]', []),
|
||||
('div[data-tag]', ['data1'])
|
||||
)
|
||||
|
||||
def test_quoted_space_in_selector_name(self):
|
||||
html = """<div style="display: wrong">nope</div>
|
||||
<div style="display: right">yes</div>
|
||||
"""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
[chosen] = soup.select('div[style="display: right"]')
|
||||
self.assertEqual("yes", chosen.string)
|
||||
|
||||
def test_unsupported_pseudoclass(self):
|
||||
self.assertRaises(
|
||||
NotImplementedError, self.soup.select, "a:no-such-pseudoclass")
|
||||
|
||||
self.assertRaises(
|
||||
NotImplementedError, self.soup.select, "a:nth-of-type(a)")
|
||||
|
||||
|
||||
def test_nth_of_type(self):
|
||||
# Try to select first paragraph
|
||||
els = self.soup.select('div#inner p:nth-of-type(1)')
|
||||
@@ -1803,7 +1983,7 @@ class TestSoupSelector(TreeTest):
|
||||
selected = inner.select("div")
|
||||
# The <div id="inner"> tag was selected. The <div id="footer">
|
||||
# tag was not.
|
||||
self.assertSelectsIDs(selected, ['inner'])
|
||||
self.assertSelectsIDs(selected, ['inner', 'data1'])
|
||||
|
||||
def test_overspecified_child_id(self):
|
||||
self.assertSelects(".fancy #inner", ['inner'])
|
||||
@@ -1827,3 +2007,44 @@ class TestSoupSelector(TreeTest):
|
||||
|
||||
def test_sibling_combinator_wont_select_same_tag_twice(self):
|
||||
self.assertSelects('p[lang] ~ p', ['lang-en-gb', 'lang-en-us', 'lang-fr'])
|
||||
|
||||
# Test the selector grouping operator (the comma)
|
||||
def test_multiple_select(self):
|
||||
self.assertSelects('x, y', ['xid', 'yid'])
|
||||
|
||||
def test_multiple_select_with_no_space(self):
|
||||
self.assertSelects('x,y', ['xid', 'yid'])
|
||||
|
||||
def test_multiple_select_with_more_space(self):
|
||||
self.assertSelects('x, y', ['xid', 'yid'])
|
||||
|
||||
def test_multiple_select_duplicated(self):
|
||||
self.assertSelects('x, x', ['xid'])
|
||||
|
||||
def test_multiple_select_sibling(self):
|
||||
self.assertSelects('x, y ~ p[lang=fr]', ['xid', 'lang-fr'])
|
||||
|
||||
def test_multiple_select_tag_and_direct_descendant(self):
|
||||
self.assertSelects('x, y > z', ['xid', 'zidb'])
|
||||
|
||||
def test_multiple_select_direct_descendant_and_tags(self):
|
||||
self.assertSelects('div > x, y, z', ['xid', 'yid', 'zida', 'zidb', 'zidab', 'zidac'])
|
||||
|
||||
def test_multiple_select_indirect_descendant(self):
|
||||
self.assertSelects('div x,y, z', ['xid', 'yid', 'zida', 'zidb', 'zidab', 'zidac'])
|
||||
|
||||
def test_invalid_multiple_select(self):
|
||||
self.assertRaises(ValueError, self.soup.select, ',x, y')
|
||||
self.assertRaises(ValueError, self.soup.select, 'x,,y')
|
||||
|
||||
def test_multiple_select_attrs(self):
|
||||
self.assertSelects('p[lang=en], p[lang=en-gb]', ['lang-en', 'lang-en-gb'])
|
||||
|
||||
def test_multiple_select_ids(self):
|
||||
self.assertSelects('x, y > z[id=zida], z[id=zidab], z[id=zidb]', ['xid', 'zidb', 'zidab'])
|
||||
|
||||
def test_multiple_select_nested(self):
|
||||
self.assertSelects('body > div > x, y > z', ['xid', 'zidb'])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .core import contents, where
|
||||
|
||||
__version__ = "2020.06.20"
|
||||
@@ -0,0 +1,12 @@
|
||||
import argparse
|
||||
|
||||
from certifi import contents, where
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("-c", "--contents", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.contents:
|
||||
print(contents())
|
||||
else:
|
||||
print(where())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
certifi.py
|
||||
~~~~~~~~~~
|
||||
|
||||
This module returns the installation location of cacert.pem or its contents.
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
from importlib.resources import path as get_path, read_text
|
||||
|
||||
_CACERT_CTX = None
|
||||
_CACERT_PATH = None
|
||||
|
||||
def where():
|
||||
# This is slightly terrible, but we want to delay extracting the file
|
||||
# in cases where we're inside of a zipimport situation until someone
|
||||
# actually calls where(), but we don't want to re-extract the file
|
||||
# on every call of where(), so we'll do it once then store it in a
|
||||
# global variable.
|
||||
global _CACERT_CTX
|
||||
global _CACERT_PATH
|
||||
if _CACERT_PATH is None:
|
||||
# This is slightly janky, the importlib.resources API wants you to
|
||||
# manage the cleanup of this file, so it doesn't actually return a
|
||||
# path, it returns a context manager that will give you the path
|
||||
# when you enter it and will do any cleanup when you leave it. In
|
||||
# the common case of not needing a temporary file, it will just
|
||||
# return the file system location and the __exit__() is a no-op.
|
||||
#
|
||||
# We also have to hold onto the actual context manager, because
|
||||
# it will do the cleanup whenever it gets garbage collected, so
|
||||
# we will also store that at the global level as well.
|
||||
_CACERT_CTX = get_path("certifi", "cacert.pem")
|
||||
_CACERT_PATH = str(_CACERT_CTX.__enter__())
|
||||
|
||||
return _CACERT_PATH
|
||||
|
||||
|
||||
except ImportError:
|
||||
# This fallback will work for Python versions prior to 3.7 that lack the
|
||||
# importlib.resources module but relies on the existing `where` function
|
||||
# so won't address issues with environments like PyOxidizer that don't set
|
||||
# __file__ on modules.
|
||||
def read_text(_module, _path, encoding="ascii"):
|
||||
with open(where(), "r", encoding=encoding) as data:
|
||||
return data.read()
|
||||
|
||||
# If we don't have importlib.resources, then we will just do the old logic
|
||||
# of assuming we're on the filesystem and munge the path directly.
|
||||
def where():
|
||||
f = os.path.dirname(__file__)
|
||||
|
||||
return os.path.join(f, "cacert.pem")
|
||||
|
||||
|
||||
def contents():
|
||||
return read_text("certifi", "cacert.pem", encoding="ascii")
|
||||
@@ -1,38 +0,0 @@
|
||||
Chardet: The Universal Character Encoding Detector
|
||||
--------------------------------------------------
|
||||
|
||||
Detects
|
||||
- ASCII, UTF-8, UTF-16 (2 variants), UTF-32 (4 variants)
|
||||
- Big5, GB2312, EUC-TW, HZ-GB-2312, ISO-2022-CN (Traditional and Simplified Chinese)
|
||||
- EUC-JP, SHIFT_JIS, ISO-2022-JP (Japanese)
|
||||
- EUC-KR, ISO-2022-KR (Korean)
|
||||
- KOI8-R, MacCyrillic, IBM855, IBM866, ISO-8859-5, windows-1251 (Cyrillic)
|
||||
- ISO-8859-2, windows-1250 (Hungarian)
|
||||
- ISO-8859-5, windows-1251 (Bulgarian)
|
||||
- windows-1252 (English)
|
||||
- ISO-8859-7, windows-1253 (Greek)
|
||||
- ISO-8859-8, windows-1255 (Visual and Logical Hebrew)
|
||||
- TIS-620 (Thai)
|
||||
|
||||
Requires Python 2.6 or later
|
||||
|
||||
Command-line Tool
|
||||
-----------------
|
||||
|
||||
chardet comes with a command-line script which reports on the encodings of one
|
||||
or more files::
|
||||
|
||||
% chardetect somefile someotherfile
|
||||
somefile: windows-1252 with confidence 0.5
|
||||
someotherfile: ascii with confidence 1.0
|
||||
|
||||
About
|
||||
-----
|
||||
|
||||
This is a continuation of Mark Pilgrim's excellent chardet. Previously, two
|
||||
versions needed to be maintained: one that supported python 2.x and one that
|
||||
supported python 3.x. We've recently merged with `Ian Corduscano <https://github.com/sigmavirus24>`_'s
|
||||
`charade <https://github.com/sigmavirus24/charade>`_ fork, so now we have one
|
||||
coherent version that works for Python 2.6+.
|
||||
|
||||
:maintainer: Dan Blanchard
|
||||
@@ -15,18 +15,25 @@
|
||||
# 02110-1301 USA
|
||||
######################### END LICENSE BLOCK #########################
|
||||
|
||||
__version__ = "2.2.1"
|
||||
from sys import version_info
|
||||
|
||||
from .compat import PY2, PY3
|
||||
from .universaldetector import UniversalDetector
|
||||
from .version import __version__, VERSION
|
||||
|
||||
|
||||
def detect(aBuf):
|
||||
if ((version_info < (3, 0) and isinstance(aBuf, unicode)) or
|
||||
(version_info >= (3, 0) and not isinstance(aBuf, bytes))):
|
||||
raise ValueError('Expected a bytes object, not a unicode object')
|
||||
def detect(byte_str):
|
||||
"""
|
||||
Detect the encoding of the given byte string.
|
||||
|
||||
from . import universaldetector
|
||||
u = universaldetector.UniversalDetector()
|
||||
u.reset()
|
||||
u.feed(aBuf)
|
||||
u.close()
|
||||
return u.result
|
||||
:param byte_str: The byte sequence to examine.
|
||||
:type byte_str: ``bytes`` or ``bytearray``
|
||||
"""
|
||||
if not isinstance(byte_str, bytearray):
|
||||
if not isinstance(byte_str, bytes):
|
||||
raise TypeError('Expected object of type bytes or bytearray, got: '
|
||||
'{0}'.format(type(byte_str)))
|
||||
else:
|
||||
byte_str = bytearray(byte_str)
|
||||
detector = UniversalDetector()
|
||||
detector.feed(byte_str)
|
||||
return detector.close()
|
||||
|
||||
@@ -45,7 +45,7 @@ BIG5_TYPICAL_DISTRIBUTION_RATIO = 0.75
|
||||
#Char to FreqOrder table
|
||||
BIG5_TABLE_SIZE = 5376
|
||||
|
||||
Big5CharToFreqOrder = (
|
||||
BIG5_CHAR_TO_FREQ_ORDER = (
|
||||
1,1801,1506, 255,1431, 198, 9, 82, 6,5008, 177, 202,3681,1256,2821, 110, # 16
|
||||
3814, 33,3274, 261, 76, 44,2114, 16,2946,2187,1176, 659,3971, 26,3451,2653, # 32
|
||||
1198,3972,3350,4202, 410,2215, 302, 590, 361,1964, 8, 204, 58,4510,5009,1932, # 48
|
||||
@@ -381,545 +381,6 @@ Big5CharToFreqOrder = (
|
||||
938,3941, 553,2680, 116,5783,3942,3667,5784,3545,2681,2783,3438,3344,2820,5785, # 5328
|
||||
3668,2943,4160,1747,2944,2983,5786,5787, 207,5788,4809,5789,4810,2521,5790,3033, # 5344
|
||||
890,3669,3943,5791,1878,3798,3439,5792,2186,2358,3440,1652,5793,5794,5795, 941, # 5360
|
||||
2299, 208,3546,4161,2020, 330,4438,3944,2906,2499,3799,4439,4811,5796,5797,5798, # 5376 #last 512
|
||||
#Everything below is of no interest for detection purpose
|
||||
2522,1613,4812,5799,3345,3945,2523,5800,4162,5801,1637,4163,2471,4813,3946,5802, # 5392
|
||||
2500,3034,3800,5803,5804,2195,4814,5805,2163,5806,5807,5808,5809,5810,5811,5812, # 5408
|
||||
5813,5814,5815,5816,5817,5818,5819,5820,5821,5822,5823,5824,5825,5826,5827,5828, # 5424
|
||||
5829,5830,5831,5832,5833,5834,5835,5836,5837,5838,5839,5840,5841,5842,5843,5844, # 5440
|
||||
5845,5846,5847,5848,5849,5850,5851,5852,5853,5854,5855,5856,5857,5858,5859,5860, # 5456
|
||||
5861,5862,5863,5864,5865,5866,5867,5868,5869,5870,5871,5872,5873,5874,5875,5876, # 5472
|
||||
5877,5878,5879,5880,5881,5882,5883,5884,5885,5886,5887,5888,5889,5890,5891,5892, # 5488
|
||||
5893,5894,5895,5896,5897,5898,5899,5900,5901,5902,5903,5904,5905,5906,5907,5908, # 5504
|
||||
5909,5910,5911,5912,5913,5914,5915,5916,5917,5918,5919,5920,5921,5922,5923,5924, # 5520
|
||||
5925,5926,5927,5928,5929,5930,5931,5932,5933,5934,5935,5936,5937,5938,5939,5940, # 5536
|
||||
5941,5942,5943,5944,5945,5946,5947,5948,5949,5950,5951,5952,5953,5954,5955,5956, # 5552
|
||||
5957,5958,5959,5960,5961,5962,5963,5964,5965,5966,5967,5968,5969,5970,5971,5972, # 5568
|
||||
5973,5974,5975,5976,5977,5978,5979,5980,5981,5982,5983,5984,5985,5986,5987,5988, # 5584
|
||||
5989,5990,5991,5992,5993,5994,5995,5996,5997,5998,5999,6000,6001,6002,6003,6004, # 5600
|
||||
6005,6006,6007,6008,6009,6010,6011,6012,6013,6014,6015,6016,6017,6018,6019,6020, # 5616
|
||||
6021,6022,6023,6024,6025,6026,6027,6028,6029,6030,6031,6032,6033,6034,6035,6036, # 5632
|
||||
6037,6038,6039,6040,6041,6042,6043,6044,6045,6046,6047,6048,6049,6050,6051,6052, # 5648
|
||||
6053,6054,6055,6056,6057,6058,6059,6060,6061,6062,6063,6064,6065,6066,6067,6068, # 5664
|
||||
6069,6070,6071,6072,6073,6074,6075,6076,6077,6078,6079,6080,6081,6082,6083,6084, # 5680
|
||||
6085,6086,6087,6088,6089,6090,6091,6092,6093,6094,6095,6096,6097,6098,6099,6100, # 5696
|
||||
6101,6102,6103,6104,6105,6106,6107,6108,6109,6110,6111,6112,6113,6114,6115,6116, # 5712
|
||||
6117,6118,6119,6120,6121,6122,6123,6124,6125,6126,6127,6128,6129,6130,6131,6132, # 5728
|
||||
6133,6134,6135,6136,6137,6138,6139,6140,6141,6142,6143,6144,6145,6146,6147,6148, # 5744
|
||||
6149,6150,6151,6152,6153,6154,6155,6156,6157,6158,6159,6160,6161,6162,6163,6164, # 5760
|
||||
6165,6166,6167,6168,6169,6170,6171,6172,6173,6174,6175,6176,6177,6178,6179,6180, # 5776
|
||||
6181,6182,6183,6184,6185,6186,6187,6188,6189,6190,6191,6192,6193,6194,6195,6196, # 5792
|
||||
6197,6198,6199,6200,6201,6202,6203,6204,6205,6206,6207,6208,6209,6210,6211,6212, # 5808
|
||||
6213,6214,6215,6216,6217,6218,6219,6220,6221,6222,6223,3670,6224,6225,6226,6227, # 5824
|
||||
6228,6229,6230,6231,6232,6233,6234,6235,6236,6237,6238,6239,6240,6241,6242,6243, # 5840
|
||||
6244,6245,6246,6247,6248,6249,6250,6251,6252,6253,6254,6255,6256,6257,6258,6259, # 5856
|
||||
6260,6261,6262,6263,6264,6265,6266,6267,6268,6269,6270,6271,6272,6273,6274,6275, # 5872
|
||||
6276,6277,6278,6279,6280,6281,6282,6283,6284,6285,4815,6286,6287,6288,6289,6290, # 5888
|
||||
6291,6292,4816,6293,6294,6295,6296,6297,6298,6299,6300,6301,6302,6303,6304,6305, # 5904
|
||||
6306,6307,6308,6309,6310,6311,4817,4818,6312,6313,6314,6315,6316,6317,6318,4819, # 5920
|
||||
6319,6320,6321,6322,6323,6324,6325,6326,6327,6328,6329,6330,6331,6332,6333,6334, # 5936
|
||||
6335,6336,6337,4820,6338,6339,6340,6341,6342,6343,6344,6345,6346,6347,6348,6349, # 5952
|
||||
6350,6351,6352,6353,6354,6355,6356,6357,6358,6359,6360,6361,6362,6363,6364,6365, # 5968
|
||||
6366,6367,6368,6369,6370,6371,6372,6373,6374,6375,6376,6377,6378,6379,6380,6381, # 5984
|
||||
6382,6383,6384,6385,6386,6387,6388,6389,6390,6391,6392,6393,6394,6395,6396,6397, # 6000
|
||||
6398,6399,6400,6401,6402,6403,6404,6405,6406,6407,6408,6409,6410,3441,6411,6412, # 6016
|
||||
6413,6414,6415,6416,6417,6418,6419,6420,6421,6422,6423,6424,6425,4440,6426,6427, # 6032
|
||||
6428,6429,6430,6431,6432,6433,6434,6435,6436,6437,6438,6439,6440,6441,6442,6443, # 6048
|
||||
6444,6445,6446,6447,6448,6449,6450,6451,6452,6453,6454,4821,6455,6456,6457,6458, # 6064
|
||||
6459,6460,6461,6462,6463,6464,6465,6466,6467,6468,6469,6470,6471,6472,6473,6474, # 6080
|
||||
6475,6476,6477,3947,3948,6478,6479,6480,6481,3272,4441,6482,6483,6484,6485,4442, # 6096
|
||||
6486,6487,6488,6489,6490,6491,6492,6493,6494,6495,6496,4822,6497,6498,6499,6500, # 6112
|
||||
6501,6502,6503,6504,6505,6506,6507,6508,6509,6510,6511,6512,6513,6514,6515,6516, # 6128
|
||||
6517,6518,6519,6520,6521,6522,6523,6524,6525,6526,6527,6528,6529,6530,6531,6532, # 6144
|
||||
6533,6534,6535,6536,6537,6538,6539,6540,6541,6542,6543,6544,6545,6546,6547,6548, # 6160
|
||||
6549,6550,6551,6552,6553,6554,6555,6556,2784,6557,4823,6558,6559,6560,6561,6562, # 6176
|
||||
6563,6564,6565,6566,6567,6568,6569,3949,6570,6571,6572,4824,6573,6574,6575,6576, # 6192
|
||||
6577,6578,6579,6580,6581,6582,6583,4825,6584,6585,6586,3950,2785,6587,6588,6589, # 6208
|
||||
6590,6591,6592,6593,6594,6595,6596,6597,6598,6599,6600,6601,6602,6603,6604,6605, # 6224
|
||||
6606,6607,6608,6609,6610,6611,6612,4826,6613,6614,6615,4827,6616,6617,6618,6619, # 6240
|
||||
6620,6621,6622,6623,6624,6625,4164,6626,6627,6628,6629,6630,6631,6632,6633,6634, # 6256
|
||||
3547,6635,4828,6636,6637,6638,6639,6640,6641,6642,3951,2984,6643,6644,6645,6646, # 6272
|
||||
6647,6648,6649,4165,6650,4829,6651,6652,4830,6653,6654,6655,6656,6657,6658,6659, # 6288
|
||||
6660,6661,6662,4831,6663,6664,6665,6666,6667,6668,6669,6670,6671,4166,6672,4832, # 6304
|
||||
3952,6673,6674,6675,6676,4833,6677,6678,6679,4167,6680,6681,6682,3198,6683,6684, # 6320
|
||||
6685,6686,6687,6688,6689,6690,6691,6692,6693,6694,6695,6696,6697,4834,6698,6699, # 6336
|
||||
6700,6701,6702,6703,6704,6705,6706,6707,6708,6709,6710,6711,6712,6713,6714,6715, # 6352
|
||||
6716,6717,6718,6719,6720,6721,6722,6723,6724,6725,6726,6727,6728,6729,6730,6731, # 6368
|
||||
6732,6733,6734,4443,6735,6736,6737,6738,6739,6740,6741,6742,6743,6744,6745,4444, # 6384
|
||||
6746,6747,6748,6749,6750,6751,6752,6753,6754,6755,6756,6757,6758,6759,6760,6761, # 6400
|
||||
6762,6763,6764,6765,6766,6767,6768,6769,6770,6771,6772,6773,6774,6775,6776,6777, # 6416
|
||||
6778,6779,6780,6781,4168,6782,6783,3442,6784,6785,6786,6787,6788,6789,6790,6791, # 6432
|
||||
4169,6792,6793,6794,6795,6796,6797,6798,6799,6800,6801,6802,6803,6804,6805,6806, # 6448
|
||||
6807,6808,6809,6810,6811,4835,6812,6813,6814,4445,6815,6816,4446,6817,6818,6819, # 6464
|
||||
6820,6821,6822,6823,6824,6825,6826,6827,6828,6829,6830,6831,6832,6833,6834,6835, # 6480
|
||||
3548,6836,6837,6838,6839,6840,6841,6842,6843,6844,6845,6846,4836,6847,6848,6849, # 6496
|
||||
6850,6851,6852,6853,6854,3953,6855,6856,6857,6858,6859,6860,6861,6862,6863,6864, # 6512
|
||||
6865,6866,6867,6868,6869,6870,6871,6872,6873,6874,6875,6876,6877,3199,6878,6879, # 6528
|
||||
6880,6881,6882,4447,6883,6884,6885,6886,6887,6888,6889,6890,6891,6892,6893,6894, # 6544
|
||||
6895,6896,6897,6898,6899,6900,6901,6902,6903,6904,4170,6905,6906,6907,6908,6909, # 6560
|
||||
6910,6911,6912,6913,6914,6915,6916,6917,6918,6919,6920,6921,6922,6923,6924,6925, # 6576
|
||||
6926,6927,4837,6928,6929,6930,6931,6932,6933,6934,6935,6936,3346,6937,6938,4838, # 6592
|
||||
6939,6940,6941,4448,6942,6943,6944,6945,6946,4449,6947,6948,6949,6950,6951,6952, # 6608
|
||||
6953,6954,6955,6956,6957,6958,6959,6960,6961,6962,6963,6964,6965,6966,6967,6968, # 6624
|
||||
6969,6970,6971,6972,6973,6974,6975,6976,6977,6978,6979,6980,6981,6982,6983,6984, # 6640
|
||||
6985,6986,6987,6988,6989,6990,6991,6992,6993,6994,3671,6995,6996,6997,6998,4839, # 6656
|
||||
6999,7000,7001,7002,3549,7003,7004,7005,7006,7007,7008,7009,7010,7011,7012,7013, # 6672
|
||||
7014,7015,7016,7017,7018,7019,7020,7021,7022,7023,7024,7025,7026,7027,7028,7029, # 6688
|
||||
7030,4840,7031,7032,7033,7034,7035,7036,7037,7038,4841,7039,7040,7041,7042,7043, # 6704
|
||||
7044,7045,7046,7047,7048,7049,7050,7051,7052,7053,7054,7055,7056,7057,7058,7059, # 6720
|
||||
7060,7061,7062,7063,7064,7065,7066,7067,7068,7069,7070,2985,7071,7072,7073,7074, # 6736
|
||||
7075,7076,7077,7078,7079,7080,4842,7081,7082,7083,7084,7085,7086,7087,7088,7089, # 6752
|
||||
7090,7091,7092,7093,7094,7095,7096,7097,7098,7099,7100,7101,7102,7103,7104,7105, # 6768
|
||||
7106,7107,7108,7109,7110,7111,7112,7113,7114,7115,7116,7117,7118,4450,7119,7120, # 6784
|
||||
7121,7122,7123,7124,7125,7126,7127,7128,7129,7130,7131,7132,7133,7134,7135,7136, # 6800
|
||||
7137,7138,7139,7140,7141,7142,7143,4843,7144,7145,7146,7147,7148,7149,7150,7151, # 6816
|
||||
7152,7153,7154,7155,7156,7157,7158,7159,7160,7161,7162,7163,7164,7165,7166,7167, # 6832
|
||||
7168,7169,7170,7171,7172,7173,7174,7175,7176,7177,7178,7179,7180,7181,7182,7183, # 6848
|
||||
7184,7185,7186,7187,7188,4171,4172,7189,7190,7191,7192,7193,7194,7195,7196,7197, # 6864
|
||||
7198,7199,7200,7201,7202,7203,7204,7205,7206,7207,7208,7209,7210,7211,7212,7213, # 6880
|
||||
7214,7215,7216,7217,7218,7219,7220,7221,7222,7223,7224,7225,7226,7227,7228,7229, # 6896
|
||||
7230,7231,7232,7233,7234,7235,7236,7237,7238,7239,7240,7241,7242,7243,7244,7245, # 6912
|
||||
7246,7247,7248,7249,7250,7251,7252,7253,7254,7255,7256,7257,7258,7259,7260,7261, # 6928
|
||||
7262,7263,7264,7265,7266,7267,7268,7269,7270,7271,7272,7273,7274,7275,7276,7277, # 6944
|
||||
7278,7279,7280,7281,7282,7283,7284,7285,7286,7287,7288,7289,7290,7291,7292,7293, # 6960
|
||||
7294,7295,7296,4844,7297,7298,7299,7300,7301,7302,7303,7304,7305,7306,7307,7308, # 6976
|
||||
7309,7310,7311,7312,7313,7314,7315,7316,4451,7317,7318,7319,7320,7321,7322,7323, # 6992
|
||||
7324,7325,7326,7327,7328,7329,7330,7331,7332,7333,7334,7335,7336,7337,7338,7339, # 7008
|
||||
7340,7341,7342,7343,7344,7345,7346,7347,7348,7349,7350,7351,7352,7353,4173,7354, # 7024
|
||||
7355,4845,7356,7357,7358,7359,7360,7361,7362,7363,7364,7365,7366,7367,7368,7369, # 7040
|
||||
7370,7371,7372,7373,7374,7375,7376,7377,7378,7379,7380,7381,7382,7383,7384,7385, # 7056
|
||||
7386,7387,7388,4846,7389,7390,7391,7392,7393,7394,7395,7396,7397,7398,7399,7400, # 7072
|
||||
7401,7402,7403,7404,7405,3672,7406,7407,7408,7409,7410,7411,7412,7413,7414,7415, # 7088
|
||||
7416,7417,7418,7419,7420,7421,7422,7423,7424,7425,7426,7427,7428,7429,7430,7431, # 7104
|
||||
7432,7433,7434,7435,7436,7437,7438,7439,7440,7441,7442,7443,7444,7445,7446,7447, # 7120
|
||||
7448,7449,7450,7451,7452,7453,4452,7454,3200,7455,7456,7457,7458,7459,7460,7461, # 7136
|
||||
7462,7463,7464,7465,7466,7467,7468,7469,7470,7471,7472,7473,7474,4847,7475,7476, # 7152
|
||||
7477,3133,7478,7479,7480,7481,7482,7483,7484,7485,7486,7487,7488,7489,7490,7491, # 7168
|
||||
7492,7493,7494,7495,7496,7497,7498,7499,7500,7501,7502,3347,7503,7504,7505,7506, # 7184
|
||||
7507,7508,7509,7510,7511,7512,7513,7514,7515,7516,7517,7518,7519,7520,7521,4848, # 7200
|
||||
7522,7523,7524,7525,7526,7527,7528,7529,7530,7531,7532,7533,7534,7535,7536,7537, # 7216
|
||||
7538,7539,7540,7541,7542,7543,7544,7545,7546,7547,7548,7549,3801,4849,7550,7551, # 7232
|
||||
7552,7553,7554,7555,7556,7557,7558,7559,7560,7561,7562,7563,7564,7565,7566,7567, # 7248
|
||||
7568,7569,3035,7570,7571,7572,7573,7574,7575,7576,7577,7578,7579,7580,7581,7582, # 7264
|
||||
7583,7584,7585,7586,7587,7588,7589,7590,7591,7592,7593,7594,7595,7596,7597,7598, # 7280
|
||||
7599,7600,7601,7602,7603,7604,7605,7606,7607,7608,7609,7610,7611,7612,7613,7614, # 7296
|
||||
7615,7616,4850,7617,7618,3802,7619,7620,7621,7622,7623,7624,7625,7626,7627,7628, # 7312
|
||||
7629,7630,7631,7632,4851,7633,7634,7635,7636,7637,7638,7639,7640,7641,7642,7643, # 7328
|
||||
7644,7645,7646,7647,7648,7649,7650,7651,7652,7653,7654,7655,7656,7657,7658,7659, # 7344
|
||||
7660,7661,7662,7663,7664,7665,7666,7667,7668,7669,7670,4453,7671,7672,7673,7674, # 7360
|
||||
7675,7676,7677,7678,7679,7680,7681,7682,7683,7684,7685,7686,7687,7688,7689,7690, # 7376
|
||||
7691,7692,7693,7694,7695,7696,7697,3443,7698,7699,7700,7701,7702,4454,7703,7704, # 7392
|
||||
7705,7706,7707,7708,7709,7710,7711,7712,7713,2472,7714,7715,7716,7717,7718,7719, # 7408
|
||||
7720,7721,7722,7723,7724,7725,7726,7727,7728,7729,7730,7731,3954,7732,7733,7734, # 7424
|
||||
7735,7736,7737,7738,7739,7740,7741,7742,7743,7744,7745,7746,7747,7748,7749,7750, # 7440
|
||||
3134,7751,7752,4852,7753,7754,7755,4853,7756,7757,7758,7759,7760,4174,7761,7762, # 7456
|
||||
7763,7764,7765,7766,7767,7768,7769,7770,7771,7772,7773,7774,7775,7776,7777,7778, # 7472
|
||||
7779,7780,7781,7782,7783,7784,7785,7786,7787,7788,7789,7790,7791,7792,7793,7794, # 7488
|
||||
7795,7796,7797,7798,7799,7800,7801,7802,7803,7804,7805,4854,7806,7807,7808,7809, # 7504
|
||||
7810,7811,7812,7813,7814,7815,7816,7817,7818,7819,7820,7821,7822,7823,7824,7825, # 7520
|
||||
4855,7826,7827,7828,7829,7830,7831,7832,7833,7834,7835,7836,7837,7838,7839,7840, # 7536
|
||||
7841,7842,7843,7844,7845,7846,7847,3955,7848,7849,7850,7851,7852,7853,7854,7855, # 7552
|
||||
7856,7857,7858,7859,7860,3444,7861,7862,7863,7864,7865,7866,7867,7868,7869,7870, # 7568
|
||||
7871,7872,7873,7874,7875,7876,7877,7878,7879,7880,7881,7882,7883,7884,7885,7886, # 7584
|
||||
7887,7888,7889,7890,7891,4175,7892,7893,7894,7895,7896,4856,4857,7897,7898,7899, # 7600
|
||||
7900,2598,7901,7902,7903,7904,7905,7906,7907,7908,4455,7909,7910,7911,7912,7913, # 7616
|
||||
7914,3201,7915,7916,7917,7918,7919,7920,7921,4858,7922,7923,7924,7925,7926,7927, # 7632
|
||||
7928,7929,7930,7931,7932,7933,7934,7935,7936,7937,7938,7939,7940,7941,7942,7943, # 7648
|
||||
7944,7945,7946,7947,7948,7949,7950,7951,7952,7953,7954,7955,7956,7957,7958,7959, # 7664
|
||||
7960,7961,7962,7963,7964,7965,7966,7967,7968,7969,7970,7971,7972,7973,7974,7975, # 7680
|
||||
7976,7977,7978,7979,7980,7981,4859,7982,7983,7984,7985,7986,7987,7988,7989,7990, # 7696
|
||||
7991,7992,7993,7994,7995,7996,4860,7997,7998,7999,8000,8001,8002,8003,8004,8005, # 7712
|
||||
8006,8007,8008,8009,8010,8011,8012,8013,8014,8015,8016,4176,8017,8018,8019,8020, # 7728
|
||||
8021,8022,8023,4861,8024,8025,8026,8027,8028,8029,8030,8031,8032,8033,8034,8035, # 7744
|
||||
8036,4862,4456,8037,8038,8039,8040,4863,8041,8042,8043,8044,8045,8046,8047,8048, # 7760
|
||||
8049,8050,8051,8052,8053,8054,8055,8056,8057,8058,8059,8060,8061,8062,8063,8064, # 7776
|
||||
8065,8066,8067,8068,8069,8070,8071,8072,8073,8074,8075,8076,8077,8078,8079,8080, # 7792
|
||||
8081,8082,8083,8084,8085,8086,8087,8088,8089,8090,8091,8092,8093,8094,8095,8096, # 7808
|
||||
8097,8098,8099,4864,4177,8100,8101,8102,8103,8104,8105,8106,8107,8108,8109,8110, # 7824
|
||||
8111,8112,8113,8114,8115,8116,8117,8118,8119,8120,4178,8121,8122,8123,8124,8125, # 7840
|
||||
8126,8127,8128,8129,8130,8131,8132,8133,8134,8135,8136,8137,8138,8139,8140,8141, # 7856
|
||||
8142,8143,8144,8145,4865,4866,8146,8147,8148,8149,8150,8151,8152,8153,8154,8155, # 7872
|
||||
8156,8157,8158,8159,8160,8161,8162,8163,8164,8165,4179,8166,8167,8168,8169,8170, # 7888
|
||||
8171,8172,8173,8174,8175,8176,8177,8178,8179,8180,8181,4457,8182,8183,8184,8185, # 7904
|
||||
8186,8187,8188,8189,8190,8191,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201, # 7920
|
||||
8202,8203,8204,8205,8206,8207,8208,8209,8210,8211,8212,8213,8214,8215,8216,8217, # 7936
|
||||
8218,8219,8220,8221,8222,8223,8224,8225,8226,8227,8228,8229,8230,8231,8232,8233, # 7952
|
||||
8234,8235,8236,8237,8238,8239,8240,8241,8242,8243,8244,8245,8246,8247,8248,8249, # 7968
|
||||
8250,8251,8252,8253,8254,8255,8256,3445,8257,8258,8259,8260,8261,8262,4458,8263, # 7984
|
||||
8264,8265,8266,8267,8268,8269,8270,8271,8272,4459,8273,8274,8275,8276,3550,8277, # 8000
|
||||
8278,8279,8280,8281,8282,8283,8284,8285,8286,8287,8288,8289,4460,8290,8291,8292, # 8016
|
||||
8293,8294,8295,8296,8297,8298,8299,8300,8301,8302,8303,8304,8305,8306,8307,4867, # 8032
|
||||
8308,8309,8310,8311,8312,3551,8313,8314,8315,8316,8317,8318,8319,8320,8321,8322, # 8048
|
||||
8323,8324,8325,8326,4868,8327,8328,8329,8330,8331,8332,8333,8334,8335,8336,8337, # 8064
|
||||
8338,8339,8340,8341,8342,8343,8344,8345,8346,8347,8348,8349,8350,8351,8352,8353, # 8080
|
||||
8354,8355,8356,8357,8358,8359,8360,8361,8362,8363,4869,4461,8364,8365,8366,8367, # 8096
|
||||
8368,8369,8370,4870,8371,8372,8373,8374,8375,8376,8377,8378,8379,8380,8381,8382, # 8112
|
||||
8383,8384,8385,8386,8387,8388,8389,8390,8391,8392,8393,8394,8395,8396,8397,8398, # 8128
|
||||
8399,8400,8401,8402,8403,8404,8405,8406,8407,8408,8409,8410,4871,8411,8412,8413, # 8144
|
||||
8414,8415,8416,8417,8418,8419,8420,8421,8422,4462,8423,8424,8425,8426,8427,8428, # 8160
|
||||
8429,8430,8431,8432,8433,2986,8434,8435,8436,8437,8438,8439,8440,8441,8442,8443, # 8176
|
||||
8444,8445,8446,8447,8448,8449,8450,8451,8452,8453,8454,8455,8456,8457,8458,8459, # 8192
|
||||
8460,8461,8462,8463,8464,8465,8466,8467,8468,8469,8470,8471,8472,8473,8474,8475, # 8208
|
||||
8476,8477,8478,4180,8479,8480,8481,8482,8483,8484,8485,8486,8487,8488,8489,8490, # 8224
|
||||
8491,8492,8493,8494,8495,8496,8497,8498,8499,8500,8501,8502,8503,8504,8505,8506, # 8240
|
||||
8507,8508,8509,8510,8511,8512,8513,8514,8515,8516,8517,8518,8519,8520,8521,8522, # 8256
|
||||
8523,8524,8525,8526,8527,8528,8529,8530,8531,8532,8533,8534,8535,8536,8537,8538, # 8272
|
||||
8539,8540,8541,8542,8543,8544,8545,8546,8547,8548,8549,8550,8551,8552,8553,8554, # 8288
|
||||
8555,8556,8557,8558,8559,8560,8561,8562,8563,8564,4872,8565,8566,8567,8568,8569, # 8304
|
||||
8570,8571,8572,8573,4873,8574,8575,8576,8577,8578,8579,8580,8581,8582,8583,8584, # 8320
|
||||
8585,8586,8587,8588,8589,8590,8591,8592,8593,8594,8595,8596,8597,8598,8599,8600, # 8336
|
||||
8601,8602,8603,8604,8605,3803,8606,8607,8608,8609,8610,8611,8612,8613,4874,3804, # 8352
|
||||
8614,8615,8616,8617,8618,8619,8620,8621,3956,8622,8623,8624,8625,8626,8627,8628, # 8368
|
||||
8629,8630,8631,8632,8633,8634,8635,8636,8637,8638,2865,8639,8640,8641,8642,8643, # 8384
|
||||
8644,8645,8646,8647,8648,8649,8650,8651,8652,8653,8654,8655,8656,4463,8657,8658, # 8400
|
||||
8659,4875,4876,8660,8661,8662,8663,8664,8665,8666,8667,8668,8669,8670,8671,8672, # 8416
|
||||
8673,8674,8675,8676,8677,8678,8679,8680,8681,4464,8682,8683,8684,8685,8686,8687, # 8432
|
||||
8688,8689,8690,8691,8692,8693,8694,8695,8696,8697,8698,8699,8700,8701,8702,8703, # 8448
|
||||
8704,8705,8706,8707,8708,8709,2261,8710,8711,8712,8713,8714,8715,8716,8717,8718, # 8464
|
||||
8719,8720,8721,8722,8723,8724,8725,8726,8727,8728,8729,8730,8731,8732,8733,4181, # 8480
|
||||
8734,8735,8736,8737,8738,8739,8740,8741,8742,8743,8744,8745,8746,8747,8748,8749, # 8496
|
||||
8750,8751,8752,8753,8754,8755,8756,8757,8758,8759,8760,8761,8762,8763,4877,8764, # 8512
|
||||
8765,8766,8767,8768,8769,8770,8771,8772,8773,8774,8775,8776,8777,8778,8779,8780, # 8528
|
||||
8781,8782,8783,8784,8785,8786,8787,8788,4878,8789,4879,8790,8791,8792,4880,8793, # 8544
|
||||
8794,8795,8796,8797,8798,8799,8800,8801,4881,8802,8803,8804,8805,8806,8807,8808, # 8560
|
||||
8809,8810,8811,8812,8813,8814,8815,3957,8816,8817,8818,8819,8820,8821,8822,8823, # 8576
|
||||
8824,8825,8826,8827,8828,8829,8830,8831,8832,8833,8834,8835,8836,8837,8838,8839, # 8592
|
||||
8840,8841,8842,8843,8844,8845,8846,8847,4882,8848,8849,8850,8851,8852,8853,8854, # 8608
|
||||
8855,8856,8857,8858,8859,8860,8861,8862,8863,8864,8865,8866,8867,8868,8869,8870, # 8624
|
||||
8871,8872,8873,8874,8875,8876,8877,8878,8879,8880,8881,8882,8883,8884,3202,8885, # 8640
|
||||
8886,8887,8888,8889,8890,8891,8892,8893,8894,8895,8896,8897,8898,8899,8900,8901, # 8656
|
||||
8902,8903,8904,8905,8906,8907,8908,8909,8910,8911,8912,8913,8914,8915,8916,8917, # 8672
|
||||
8918,8919,8920,8921,8922,8923,8924,4465,8925,8926,8927,8928,8929,8930,8931,8932, # 8688
|
||||
4883,8933,8934,8935,8936,8937,8938,8939,8940,8941,8942,8943,2214,8944,8945,8946, # 8704
|
||||
8947,8948,8949,8950,8951,8952,8953,8954,8955,8956,8957,8958,8959,8960,8961,8962, # 8720
|
||||
8963,8964,8965,4884,8966,8967,8968,8969,8970,8971,8972,8973,8974,8975,8976,8977, # 8736
|
||||
8978,8979,8980,8981,8982,8983,8984,8985,8986,8987,8988,8989,8990,8991,8992,4885, # 8752
|
||||
8993,8994,8995,8996,8997,8998,8999,9000,9001,9002,9003,9004,9005,9006,9007,9008, # 8768
|
||||
9009,9010,9011,9012,9013,9014,9015,9016,9017,9018,9019,9020,9021,4182,9022,9023, # 8784
|
||||
9024,9025,9026,9027,9028,9029,9030,9031,9032,9033,9034,9035,9036,9037,9038,9039, # 8800
|
||||
9040,9041,9042,9043,9044,9045,9046,9047,9048,9049,9050,9051,9052,9053,9054,9055, # 8816
|
||||
9056,9057,9058,9059,9060,9061,9062,9063,4886,9064,9065,9066,9067,9068,9069,4887, # 8832
|
||||
9070,9071,9072,9073,9074,9075,9076,9077,9078,9079,9080,9081,9082,9083,9084,9085, # 8848
|
||||
9086,9087,9088,9089,9090,9091,9092,9093,9094,9095,9096,9097,9098,9099,9100,9101, # 8864
|
||||
9102,9103,9104,9105,9106,9107,9108,9109,9110,9111,9112,9113,9114,9115,9116,9117, # 8880
|
||||
9118,9119,9120,9121,9122,9123,9124,9125,9126,9127,9128,9129,9130,9131,9132,9133, # 8896
|
||||
9134,9135,9136,9137,9138,9139,9140,9141,3958,9142,9143,9144,9145,9146,9147,9148, # 8912
|
||||
9149,9150,9151,4888,9152,9153,9154,9155,9156,9157,9158,9159,9160,9161,9162,9163, # 8928
|
||||
9164,9165,9166,9167,9168,9169,9170,9171,9172,9173,9174,9175,4889,9176,9177,9178, # 8944
|
||||
9179,9180,9181,9182,9183,9184,9185,9186,9187,9188,9189,9190,9191,9192,9193,9194, # 8960
|
||||
9195,9196,9197,9198,9199,9200,9201,9202,9203,4890,9204,9205,9206,9207,9208,9209, # 8976
|
||||
9210,9211,9212,9213,9214,9215,9216,9217,9218,9219,9220,9221,9222,4466,9223,9224, # 8992
|
||||
9225,9226,9227,9228,9229,9230,9231,9232,9233,9234,9235,9236,9237,9238,9239,9240, # 9008
|
||||
9241,9242,9243,9244,9245,4891,9246,9247,9248,9249,9250,9251,9252,9253,9254,9255, # 9024
|
||||
9256,9257,4892,9258,9259,9260,9261,4893,4894,9262,9263,9264,9265,9266,9267,9268, # 9040
|
||||
9269,9270,9271,9272,9273,4467,9274,9275,9276,9277,9278,9279,9280,9281,9282,9283, # 9056
|
||||
9284,9285,3673,9286,9287,9288,9289,9290,9291,9292,9293,9294,9295,9296,9297,9298, # 9072
|
||||
9299,9300,9301,9302,9303,9304,9305,9306,9307,9308,9309,9310,9311,9312,9313,9314, # 9088
|
||||
9315,9316,9317,9318,9319,9320,9321,9322,4895,9323,9324,9325,9326,9327,9328,9329, # 9104
|
||||
9330,9331,9332,9333,9334,9335,9336,9337,9338,9339,9340,9341,9342,9343,9344,9345, # 9120
|
||||
9346,9347,4468,9348,9349,9350,9351,9352,9353,9354,9355,9356,9357,9358,9359,9360, # 9136
|
||||
9361,9362,9363,9364,9365,9366,9367,9368,9369,9370,9371,9372,9373,4896,9374,4469, # 9152
|
||||
9375,9376,9377,9378,9379,4897,9380,9381,9382,9383,9384,9385,9386,9387,9388,9389, # 9168
|
||||
9390,9391,9392,9393,9394,9395,9396,9397,9398,9399,9400,9401,9402,9403,9404,9405, # 9184
|
||||
9406,4470,9407,2751,9408,9409,3674,3552,9410,9411,9412,9413,9414,9415,9416,9417, # 9200
|
||||
9418,9419,9420,9421,4898,9422,9423,9424,9425,9426,9427,9428,9429,3959,9430,9431, # 9216
|
||||
9432,9433,9434,9435,9436,4471,9437,9438,9439,9440,9441,9442,9443,9444,9445,9446, # 9232
|
||||
9447,9448,9449,9450,3348,9451,9452,9453,9454,9455,9456,9457,9458,9459,9460,9461, # 9248
|
||||
9462,9463,9464,9465,9466,9467,9468,9469,9470,9471,9472,4899,9473,9474,9475,9476, # 9264
|
||||
9477,4900,9478,9479,9480,9481,9482,9483,9484,9485,9486,9487,9488,3349,9489,9490, # 9280
|
||||
9491,9492,9493,9494,9495,9496,9497,9498,9499,9500,9501,9502,9503,9504,9505,9506, # 9296
|
||||
9507,9508,9509,9510,9511,9512,9513,9514,9515,9516,9517,9518,9519,9520,4901,9521, # 9312
|
||||
9522,9523,9524,9525,9526,4902,9527,9528,9529,9530,9531,9532,9533,9534,9535,9536, # 9328
|
||||
9537,9538,9539,9540,9541,9542,9543,9544,9545,9546,9547,9548,9549,9550,9551,9552, # 9344
|
||||
9553,9554,9555,9556,9557,9558,9559,9560,9561,9562,9563,9564,9565,9566,9567,9568, # 9360
|
||||
9569,9570,9571,9572,9573,9574,9575,9576,9577,9578,9579,9580,9581,9582,9583,9584, # 9376
|
||||
3805,9585,9586,9587,9588,9589,9590,9591,9592,9593,9594,9595,9596,9597,9598,9599, # 9392
|
||||
9600,9601,9602,4903,9603,9604,9605,9606,9607,4904,9608,9609,9610,9611,9612,9613, # 9408
|
||||
9614,4905,9615,9616,9617,9618,9619,9620,9621,9622,9623,9624,9625,9626,9627,9628, # 9424
|
||||
9629,9630,9631,9632,4906,9633,9634,9635,9636,9637,9638,9639,9640,9641,9642,9643, # 9440
|
||||
4907,9644,9645,9646,9647,9648,9649,9650,9651,9652,9653,9654,9655,9656,9657,9658, # 9456
|
||||
9659,9660,9661,9662,9663,9664,9665,9666,9667,9668,9669,9670,9671,9672,4183,9673, # 9472
|
||||
9674,9675,9676,9677,4908,9678,9679,9680,9681,4909,9682,9683,9684,9685,9686,9687, # 9488
|
||||
9688,9689,9690,4910,9691,9692,9693,3675,9694,9695,9696,2945,9697,9698,9699,9700, # 9504
|
||||
9701,9702,9703,9704,9705,4911,9706,9707,9708,9709,9710,9711,9712,9713,9714,9715, # 9520
|
||||
9716,9717,9718,9719,9720,9721,9722,9723,9724,9725,9726,9727,9728,9729,9730,9731, # 9536
|
||||
9732,9733,9734,9735,4912,9736,9737,9738,9739,9740,4913,9741,9742,9743,9744,9745, # 9552
|
||||
9746,9747,9748,9749,9750,9751,9752,9753,9754,9755,9756,9757,9758,4914,9759,9760, # 9568
|
||||
9761,9762,9763,9764,9765,9766,9767,9768,9769,9770,9771,9772,9773,9774,9775,9776, # 9584
|
||||
9777,9778,9779,9780,9781,9782,4915,9783,9784,9785,9786,9787,9788,9789,9790,9791, # 9600
|
||||
9792,9793,4916,9794,9795,9796,9797,9798,9799,9800,9801,9802,9803,9804,9805,9806, # 9616
|
||||
9807,9808,9809,9810,9811,9812,9813,9814,9815,9816,9817,9818,9819,9820,9821,9822, # 9632
|
||||
9823,9824,9825,9826,9827,9828,9829,9830,9831,9832,9833,9834,9835,9836,9837,9838, # 9648
|
||||
9839,9840,9841,9842,9843,9844,9845,9846,9847,9848,9849,9850,9851,9852,9853,9854, # 9664
|
||||
9855,9856,9857,9858,9859,9860,9861,9862,9863,9864,9865,9866,9867,9868,4917,9869, # 9680
|
||||
9870,9871,9872,9873,9874,9875,9876,9877,9878,9879,9880,9881,9882,9883,9884,9885, # 9696
|
||||
9886,9887,9888,9889,9890,9891,9892,4472,9893,9894,9895,9896,9897,3806,9898,9899, # 9712
|
||||
9900,9901,9902,9903,9904,9905,9906,9907,9908,9909,9910,9911,9912,9913,9914,4918, # 9728
|
||||
9915,9916,9917,4919,9918,9919,9920,9921,4184,9922,9923,9924,9925,9926,9927,9928, # 9744
|
||||
9929,9930,9931,9932,9933,9934,9935,9936,9937,9938,9939,9940,9941,9942,9943,9944, # 9760
|
||||
9945,9946,4920,9947,9948,9949,9950,9951,9952,9953,9954,9955,4185,9956,9957,9958, # 9776
|
||||
9959,9960,9961,9962,9963,9964,9965,4921,9966,9967,9968,4473,9969,9970,9971,9972, # 9792
|
||||
9973,9974,9975,9976,9977,4474,9978,9979,9980,9981,9982,9983,9984,9985,9986,9987, # 9808
|
||||
9988,9989,9990,9991,9992,9993,9994,9995,9996,9997,9998,9999,10000,10001,10002,10003, # 9824
|
||||
10004,10005,10006,10007,10008,10009,10010,10011,10012,10013,10014,10015,10016,10017,10018,10019, # 9840
|
||||
10020,10021,4922,10022,4923,10023,10024,10025,10026,10027,10028,10029,10030,10031,10032,10033, # 9856
|
||||
10034,10035,10036,10037,10038,10039,10040,10041,10042,10043,10044,10045,10046,10047,10048,4924, # 9872
|
||||
10049,10050,10051,10052,10053,10054,10055,10056,10057,10058,10059,10060,10061,10062,10063,10064, # 9888
|
||||
10065,10066,10067,10068,10069,10070,10071,10072,10073,10074,10075,10076,10077,10078,10079,10080, # 9904
|
||||
10081,10082,10083,10084,10085,10086,10087,4475,10088,10089,10090,10091,10092,10093,10094,10095, # 9920
|
||||
10096,10097,4476,10098,10099,10100,10101,10102,10103,10104,10105,10106,10107,10108,10109,10110, # 9936
|
||||
10111,2174,10112,10113,10114,10115,10116,10117,10118,10119,10120,10121,10122,10123,10124,10125, # 9952
|
||||
10126,10127,10128,10129,10130,10131,10132,10133,10134,10135,10136,10137,10138,10139,10140,3807, # 9968
|
||||
4186,4925,10141,10142,10143,10144,10145,10146,10147,4477,4187,10148,10149,10150,10151,10152, # 9984
|
||||
10153,4188,10154,10155,10156,10157,10158,10159,10160,10161,4926,10162,10163,10164,10165,10166, #10000
|
||||
10167,10168,10169,10170,10171,10172,10173,10174,10175,10176,10177,10178,10179,10180,10181,10182, #10016
|
||||
10183,10184,10185,10186,10187,10188,10189,10190,10191,10192,3203,10193,10194,10195,10196,10197, #10032
|
||||
10198,10199,10200,4478,10201,10202,10203,10204,4479,10205,10206,10207,10208,10209,10210,10211, #10048
|
||||
10212,10213,10214,10215,10216,10217,10218,10219,10220,10221,10222,10223,10224,10225,10226,10227, #10064
|
||||
10228,10229,10230,10231,10232,10233,10234,4927,10235,10236,10237,10238,10239,10240,10241,10242, #10080
|
||||
10243,10244,10245,10246,10247,10248,10249,10250,10251,10252,10253,10254,10255,10256,10257,10258, #10096
|
||||
10259,10260,10261,10262,10263,10264,10265,10266,10267,10268,10269,10270,10271,10272,10273,4480, #10112
|
||||
4928,4929,10274,10275,10276,10277,10278,10279,10280,10281,10282,10283,10284,10285,10286,10287, #10128
|
||||
10288,10289,10290,10291,10292,10293,10294,10295,10296,10297,10298,10299,10300,10301,10302,10303, #10144
|
||||
10304,10305,10306,10307,10308,10309,10310,10311,10312,10313,10314,10315,10316,10317,10318,10319, #10160
|
||||
10320,10321,10322,10323,10324,10325,10326,10327,10328,10329,10330,10331,10332,10333,10334,4930, #10176
|
||||
10335,10336,10337,10338,10339,10340,10341,10342,4931,10343,10344,10345,10346,10347,10348,10349, #10192
|
||||
10350,10351,10352,10353,10354,10355,3088,10356,2786,10357,10358,10359,10360,4189,10361,10362, #10208
|
||||
10363,10364,10365,10366,10367,10368,10369,10370,10371,10372,10373,10374,10375,4932,10376,10377, #10224
|
||||
10378,10379,10380,10381,10382,10383,10384,10385,10386,10387,10388,10389,10390,10391,10392,4933, #10240
|
||||
10393,10394,10395,4934,10396,10397,10398,10399,10400,10401,10402,10403,10404,10405,10406,10407, #10256
|
||||
10408,10409,10410,10411,10412,3446,10413,10414,10415,10416,10417,10418,10419,10420,10421,10422, #10272
|
||||
10423,4935,10424,10425,10426,10427,10428,10429,10430,4936,10431,10432,10433,10434,10435,10436, #10288
|
||||
10437,10438,10439,10440,10441,10442,10443,4937,10444,10445,10446,10447,4481,10448,10449,10450, #10304
|
||||
10451,10452,10453,10454,10455,10456,10457,10458,10459,10460,10461,10462,10463,10464,10465,10466, #10320
|
||||
10467,10468,10469,10470,10471,10472,10473,10474,10475,10476,10477,10478,10479,10480,10481,10482, #10336
|
||||
10483,10484,10485,10486,10487,10488,10489,10490,10491,10492,10493,10494,10495,10496,10497,10498, #10352
|
||||
10499,10500,10501,10502,10503,10504,10505,4938,10506,10507,10508,10509,10510,2552,10511,10512, #10368
|
||||
10513,10514,10515,10516,3447,10517,10518,10519,10520,10521,10522,10523,10524,10525,10526,10527, #10384
|
||||
10528,10529,10530,10531,10532,10533,10534,10535,10536,10537,10538,10539,10540,10541,10542,10543, #10400
|
||||
4482,10544,4939,10545,10546,10547,10548,10549,10550,10551,10552,10553,10554,10555,10556,10557, #10416
|
||||
10558,10559,10560,10561,10562,10563,10564,10565,10566,10567,3676,4483,10568,10569,10570,10571, #10432
|
||||
10572,3448,10573,10574,10575,10576,10577,10578,10579,10580,10581,10582,10583,10584,10585,10586, #10448
|
||||
10587,10588,10589,10590,10591,10592,10593,10594,10595,10596,10597,10598,10599,10600,10601,10602, #10464
|
||||
10603,10604,10605,10606,10607,10608,10609,10610,10611,10612,10613,10614,10615,10616,10617,10618, #10480
|
||||
10619,10620,10621,10622,10623,10624,10625,10626,10627,4484,10628,10629,10630,10631,10632,4940, #10496
|
||||
10633,10634,10635,10636,10637,10638,10639,10640,10641,10642,10643,10644,10645,10646,10647,10648, #10512
|
||||
10649,10650,10651,10652,10653,10654,10655,10656,4941,10657,10658,10659,2599,10660,10661,10662, #10528
|
||||
10663,10664,10665,10666,3089,10667,10668,10669,10670,10671,10672,10673,10674,10675,10676,10677, #10544
|
||||
10678,10679,10680,4942,10681,10682,10683,10684,10685,10686,10687,10688,10689,10690,10691,10692, #10560
|
||||
10693,10694,10695,10696,10697,4485,10698,10699,10700,10701,10702,10703,10704,4943,10705,3677, #10576
|
||||
10706,10707,10708,10709,10710,10711,10712,4944,10713,10714,10715,10716,10717,10718,10719,10720, #10592
|
||||
10721,10722,10723,10724,10725,10726,10727,10728,4945,10729,10730,10731,10732,10733,10734,10735, #10608
|
||||
10736,10737,10738,10739,10740,10741,10742,10743,10744,10745,10746,10747,10748,10749,10750,10751, #10624
|
||||
10752,10753,10754,10755,10756,10757,10758,10759,10760,10761,4946,10762,10763,10764,10765,10766, #10640
|
||||
10767,4947,4948,10768,10769,10770,10771,10772,10773,10774,10775,10776,10777,10778,10779,10780, #10656
|
||||
10781,10782,10783,10784,10785,10786,10787,10788,10789,10790,10791,10792,10793,10794,10795,10796, #10672
|
||||
10797,10798,10799,10800,10801,10802,10803,10804,10805,10806,10807,10808,10809,10810,10811,10812, #10688
|
||||
10813,10814,10815,10816,10817,10818,10819,10820,10821,10822,10823,10824,10825,10826,10827,10828, #10704
|
||||
10829,10830,10831,10832,10833,10834,10835,10836,10837,10838,10839,10840,10841,10842,10843,10844, #10720
|
||||
10845,10846,10847,10848,10849,10850,10851,10852,10853,10854,10855,10856,10857,10858,10859,10860, #10736
|
||||
10861,10862,10863,10864,10865,10866,10867,10868,10869,10870,10871,10872,10873,10874,10875,10876, #10752
|
||||
10877,10878,4486,10879,10880,10881,10882,10883,10884,10885,4949,10886,10887,10888,10889,10890, #10768
|
||||
10891,10892,10893,10894,10895,10896,10897,10898,10899,10900,10901,10902,10903,10904,10905,10906, #10784
|
||||
10907,10908,10909,10910,10911,10912,10913,10914,10915,10916,10917,10918,10919,4487,10920,10921, #10800
|
||||
10922,10923,10924,10925,10926,10927,10928,10929,10930,10931,10932,4950,10933,10934,10935,10936, #10816
|
||||
10937,10938,10939,10940,10941,10942,10943,10944,10945,10946,10947,10948,10949,4488,10950,10951, #10832
|
||||
10952,10953,10954,10955,10956,10957,10958,10959,4190,10960,10961,10962,10963,10964,10965,10966, #10848
|
||||
10967,10968,10969,10970,10971,10972,10973,10974,10975,10976,10977,10978,10979,10980,10981,10982, #10864
|
||||
10983,10984,10985,10986,10987,10988,10989,10990,10991,10992,10993,10994,10995,10996,10997,10998, #10880
|
||||
10999,11000,11001,11002,11003,11004,11005,11006,3960,11007,11008,11009,11010,11011,11012,11013, #10896
|
||||
11014,11015,11016,11017,11018,11019,11020,11021,11022,11023,11024,11025,11026,11027,11028,11029, #10912
|
||||
11030,11031,11032,4951,11033,11034,11035,11036,11037,11038,11039,11040,11041,11042,11043,11044, #10928
|
||||
11045,11046,11047,4489,11048,11049,11050,11051,4952,11052,11053,11054,11055,11056,11057,11058, #10944
|
||||
4953,11059,11060,11061,11062,11063,11064,11065,11066,11067,11068,11069,11070,11071,4954,11072, #10960
|
||||
11073,11074,11075,11076,11077,11078,11079,11080,11081,11082,11083,11084,11085,11086,11087,11088, #10976
|
||||
11089,11090,11091,11092,11093,11094,11095,11096,11097,11098,11099,11100,11101,11102,11103,11104, #10992
|
||||
11105,11106,11107,11108,11109,11110,11111,11112,11113,11114,11115,3808,11116,11117,11118,11119, #11008
|
||||
11120,11121,11122,11123,11124,11125,11126,11127,11128,11129,11130,11131,11132,11133,11134,4955, #11024
|
||||
11135,11136,11137,11138,11139,11140,11141,11142,11143,11144,11145,11146,11147,11148,11149,11150, #11040
|
||||
11151,11152,11153,11154,11155,11156,11157,11158,11159,11160,11161,4956,11162,11163,11164,11165, #11056
|
||||
11166,11167,11168,11169,11170,11171,11172,11173,11174,11175,11176,11177,11178,11179,11180,4957, #11072
|
||||
11181,11182,11183,11184,11185,11186,4958,11187,11188,11189,11190,11191,11192,11193,11194,11195, #11088
|
||||
11196,11197,11198,11199,11200,3678,11201,11202,11203,11204,11205,11206,4191,11207,11208,11209, #11104
|
||||
11210,11211,11212,11213,11214,11215,11216,11217,11218,11219,11220,11221,11222,11223,11224,11225, #11120
|
||||
11226,11227,11228,11229,11230,11231,11232,11233,11234,11235,11236,11237,11238,11239,11240,11241, #11136
|
||||
11242,11243,11244,11245,11246,11247,11248,11249,11250,11251,4959,11252,11253,11254,11255,11256, #11152
|
||||
11257,11258,11259,11260,11261,11262,11263,11264,11265,11266,11267,11268,11269,11270,11271,11272, #11168
|
||||
11273,11274,11275,11276,11277,11278,11279,11280,11281,11282,11283,11284,11285,11286,11287,11288, #11184
|
||||
11289,11290,11291,11292,11293,11294,11295,11296,11297,11298,11299,11300,11301,11302,11303,11304, #11200
|
||||
11305,11306,11307,11308,11309,11310,11311,11312,11313,11314,3679,11315,11316,11317,11318,4490, #11216
|
||||
11319,11320,11321,11322,11323,11324,11325,11326,11327,11328,11329,11330,11331,11332,11333,11334, #11232
|
||||
11335,11336,11337,11338,11339,11340,11341,11342,11343,11344,11345,11346,11347,4960,11348,11349, #11248
|
||||
11350,11351,11352,11353,11354,11355,11356,11357,11358,11359,11360,11361,11362,11363,11364,11365, #11264
|
||||
11366,11367,11368,11369,11370,11371,11372,11373,11374,11375,11376,11377,3961,4961,11378,11379, #11280
|
||||
11380,11381,11382,11383,11384,11385,11386,11387,11388,11389,11390,11391,11392,11393,11394,11395, #11296
|
||||
11396,11397,4192,11398,11399,11400,11401,11402,11403,11404,11405,11406,11407,11408,11409,11410, #11312
|
||||
11411,4962,11412,11413,11414,11415,11416,11417,11418,11419,11420,11421,11422,11423,11424,11425, #11328
|
||||
11426,11427,11428,11429,11430,11431,11432,11433,11434,11435,11436,11437,11438,11439,11440,11441, #11344
|
||||
11442,11443,11444,11445,11446,11447,11448,11449,11450,11451,11452,11453,11454,11455,11456,11457, #11360
|
||||
11458,11459,11460,11461,11462,11463,11464,11465,11466,11467,11468,11469,4963,11470,11471,4491, #11376
|
||||
11472,11473,11474,11475,4964,11476,11477,11478,11479,11480,11481,11482,11483,11484,11485,11486, #11392
|
||||
11487,11488,11489,11490,11491,11492,4965,11493,11494,11495,11496,11497,11498,11499,11500,11501, #11408
|
||||
11502,11503,11504,11505,11506,11507,11508,11509,11510,11511,11512,11513,11514,11515,11516,11517, #11424
|
||||
11518,11519,11520,11521,11522,11523,11524,11525,11526,11527,11528,11529,3962,11530,11531,11532, #11440
|
||||
11533,11534,11535,11536,11537,11538,11539,11540,11541,11542,11543,11544,11545,11546,11547,11548, #11456
|
||||
11549,11550,11551,11552,11553,11554,11555,11556,11557,11558,11559,11560,11561,11562,11563,11564, #11472
|
||||
4193,4194,11565,11566,11567,11568,11569,11570,11571,11572,11573,11574,11575,11576,11577,11578, #11488
|
||||
11579,11580,11581,11582,11583,11584,11585,11586,11587,11588,11589,11590,11591,4966,4195,11592, #11504
|
||||
11593,11594,11595,11596,11597,11598,11599,11600,11601,11602,11603,11604,3090,11605,11606,11607, #11520
|
||||
11608,11609,11610,4967,11611,11612,11613,11614,11615,11616,11617,11618,11619,11620,11621,11622, #11536
|
||||
11623,11624,11625,11626,11627,11628,11629,11630,11631,11632,11633,11634,11635,11636,11637,11638, #11552
|
||||
11639,11640,11641,11642,11643,11644,11645,11646,11647,11648,11649,11650,11651,11652,11653,11654, #11568
|
||||
11655,11656,11657,11658,11659,11660,11661,11662,11663,11664,11665,11666,11667,11668,11669,11670, #11584
|
||||
11671,11672,11673,11674,4968,11675,11676,11677,11678,11679,11680,11681,11682,11683,11684,11685, #11600
|
||||
11686,11687,11688,11689,11690,11691,11692,11693,3809,11694,11695,11696,11697,11698,11699,11700, #11616
|
||||
11701,11702,11703,11704,11705,11706,11707,11708,11709,11710,11711,11712,11713,11714,11715,11716, #11632
|
||||
11717,11718,3553,11719,11720,11721,11722,11723,11724,11725,11726,11727,11728,11729,11730,4969, #11648
|
||||
11731,11732,11733,11734,11735,11736,11737,11738,11739,11740,4492,11741,11742,11743,11744,11745, #11664
|
||||
11746,11747,11748,11749,11750,11751,11752,4970,11753,11754,11755,11756,11757,11758,11759,11760, #11680
|
||||
11761,11762,11763,11764,11765,11766,11767,11768,11769,11770,11771,11772,11773,11774,11775,11776, #11696
|
||||
11777,11778,11779,11780,11781,11782,11783,11784,11785,11786,11787,11788,11789,11790,4971,11791, #11712
|
||||
11792,11793,11794,11795,11796,11797,4972,11798,11799,11800,11801,11802,11803,11804,11805,11806, #11728
|
||||
11807,11808,11809,11810,4973,11811,11812,11813,11814,11815,11816,11817,11818,11819,11820,11821, #11744
|
||||
11822,11823,11824,11825,11826,11827,11828,11829,11830,11831,11832,11833,11834,3680,3810,11835, #11760
|
||||
11836,4974,11837,11838,11839,11840,11841,11842,11843,11844,11845,11846,11847,11848,11849,11850, #11776
|
||||
11851,11852,11853,11854,11855,11856,11857,11858,11859,11860,11861,11862,11863,11864,11865,11866, #11792
|
||||
11867,11868,11869,11870,11871,11872,11873,11874,11875,11876,11877,11878,11879,11880,11881,11882, #11808
|
||||
11883,11884,4493,11885,11886,11887,11888,11889,11890,11891,11892,11893,11894,11895,11896,11897, #11824
|
||||
11898,11899,11900,11901,11902,11903,11904,11905,11906,11907,11908,11909,11910,11911,11912,11913, #11840
|
||||
11914,11915,4975,11916,11917,11918,11919,11920,11921,11922,11923,11924,11925,11926,11927,11928, #11856
|
||||
11929,11930,11931,11932,11933,11934,11935,11936,11937,11938,11939,11940,11941,11942,11943,11944, #11872
|
||||
11945,11946,11947,11948,11949,4976,11950,11951,11952,11953,11954,11955,11956,11957,11958,11959, #11888
|
||||
11960,11961,11962,11963,11964,11965,11966,11967,11968,11969,11970,11971,11972,11973,11974,11975, #11904
|
||||
11976,11977,11978,11979,11980,11981,11982,11983,11984,11985,11986,11987,4196,11988,11989,11990, #11920
|
||||
11991,11992,4977,11993,11994,11995,11996,11997,11998,11999,12000,12001,12002,12003,12004,12005, #11936
|
||||
12006,12007,12008,12009,12010,12011,12012,12013,12014,12015,12016,12017,12018,12019,12020,12021, #11952
|
||||
12022,12023,12024,12025,12026,12027,12028,12029,12030,12031,12032,12033,12034,12035,12036,12037, #11968
|
||||
12038,12039,12040,12041,12042,12043,12044,12045,12046,12047,12048,12049,12050,12051,12052,12053, #11984
|
||||
12054,12055,12056,12057,12058,12059,12060,12061,4978,12062,12063,12064,12065,12066,12067,12068, #12000
|
||||
12069,12070,12071,12072,12073,12074,12075,12076,12077,12078,12079,12080,12081,12082,12083,12084, #12016
|
||||
12085,12086,12087,12088,12089,12090,12091,12092,12093,12094,12095,12096,12097,12098,12099,12100, #12032
|
||||
12101,12102,12103,12104,12105,12106,12107,12108,12109,12110,12111,12112,12113,12114,12115,12116, #12048
|
||||
12117,12118,12119,12120,12121,12122,12123,4979,12124,12125,12126,12127,12128,4197,12129,12130, #12064
|
||||
12131,12132,12133,12134,12135,12136,12137,12138,12139,12140,12141,12142,12143,12144,12145,12146, #12080
|
||||
12147,12148,12149,12150,12151,12152,12153,12154,4980,12155,12156,12157,12158,12159,12160,4494, #12096
|
||||
12161,12162,12163,12164,3811,12165,12166,12167,12168,12169,4495,12170,12171,4496,12172,12173, #12112
|
||||
12174,12175,12176,3812,12177,12178,12179,12180,12181,12182,12183,12184,12185,12186,12187,12188, #12128
|
||||
12189,12190,12191,12192,12193,12194,12195,12196,12197,12198,12199,12200,12201,12202,12203,12204, #12144
|
||||
12205,12206,12207,12208,12209,12210,12211,12212,12213,12214,12215,12216,12217,12218,12219,12220, #12160
|
||||
12221,4981,12222,12223,12224,12225,12226,12227,12228,12229,12230,12231,12232,12233,12234,12235, #12176
|
||||
4982,12236,12237,12238,12239,12240,12241,12242,12243,12244,12245,4983,12246,12247,12248,12249, #12192
|
||||
4984,12250,12251,12252,12253,12254,12255,12256,12257,12258,12259,12260,12261,12262,12263,12264, #12208
|
||||
4985,12265,4497,12266,12267,12268,12269,12270,12271,12272,12273,12274,12275,12276,12277,12278, #12224
|
||||
12279,12280,12281,12282,12283,12284,12285,12286,12287,4986,12288,12289,12290,12291,12292,12293, #12240
|
||||
12294,12295,12296,2473,12297,12298,12299,12300,12301,12302,12303,12304,12305,12306,12307,12308, #12256
|
||||
12309,12310,12311,12312,12313,12314,12315,12316,12317,12318,12319,3963,12320,12321,12322,12323, #12272
|
||||
12324,12325,12326,12327,12328,12329,12330,12331,12332,4987,12333,12334,12335,12336,12337,12338, #12288
|
||||
12339,12340,12341,12342,12343,12344,12345,12346,12347,12348,12349,12350,12351,12352,12353,12354, #12304
|
||||
12355,12356,12357,12358,12359,3964,12360,12361,12362,12363,12364,12365,12366,12367,12368,12369, #12320
|
||||
12370,3965,12371,12372,12373,12374,12375,12376,12377,12378,12379,12380,12381,12382,12383,12384, #12336
|
||||
12385,12386,12387,12388,12389,12390,12391,12392,12393,12394,12395,12396,12397,12398,12399,12400, #12352
|
||||
12401,12402,12403,12404,12405,12406,12407,12408,4988,12409,12410,12411,12412,12413,12414,12415, #12368
|
||||
12416,12417,12418,12419,12420,12421,12422,12423,12424,12425,12426,12427,12428,12429,12430,12431, #12384
|
||||
12432,12433,12434,12435,12436,12437,12438,3554,12439,12440,12441,12442,12443,12444,12445,12446, #12400
|
||||
12447,12448,12449,12450,12451,12452,12453,12454,12455,12456,12457,12458,12459,12460,12461,12462, #12416
|
||||
12463,12464,4989,12465,12466,12467,12468,12469,12470,12471,12472,12473,12474,12475,12476,12477, #12432
|
||||
12478,12479,12480,4990,12481,12482,12483,12484,12485,12486,12487,12488,12489,4498,12490,12491, #12448
|
||||
12492,12493,12494,12495,12496,12497,12498,12499,12500,12501,12502,12503,12504,12505,12506,12507, #12464
|
||||
12508,12509,12510,12511,12512,12513,12514,12515,12516,12517,12518,12519,12520,12521,12522,12523, #12480
|
||||
12524,12525,12526,12527,12528,12529,12530,12531,12532,12533,12534,12535,12536,12537,12538,12539, #12496
|
||||
12540,12541,12542,12543,12544,12545,12546,12547,12548,12549,12550,12551,4991,12552,12553,12554, #12512
|
||||
12555,12556,12557,12558,12559,12560,12561,12562,12563,12564,12565,12566,12567,12568,12569,12570, #12528
|
||||
12571,12572,12573,12574,12575,12576,12577,12578,3036,12579,12580,12581,12582,12583,3966,12584, #12544
|
||||
12585,12586,12587,12588,12589,12590,12591,12592,12593,12594,12595,12596,12597,12598,12599,12600, #12560
|
||||
12601,12602,12603,12604,12605,12606,12607,12608,12609,12610,12611,12612,12613,12614,12615,12616, #12576
|
||||
12617,12618,12619,12620,12621,12622,12623,12624,12625,12626,12627,12628,12629,12630,12631,12632, #12592
|
||||
12633,12634,12635,12636,12637,12638,12639,12640,12641,12642,12643,12644,12645,12646,4499,12647, #12608
|
||||
12648,12649,12650,12651,12652,12653,12654,12655,12656,12657,12658,12659,12660,12661,12662,12663, #12624
|
||||
12664,12665,12666,12667,12668,12669,12670,12671,12672,12673,12674,12675,12676,12677,12678,12679, #12640
|
||||
12680,12681,12682,12683,12684,12685,12686,12687,12688,12689,12690,12691,12692,12693,12694,12695, #12656
|
||||
12696,12697,12698,4992,12699,12700,12701,12702,12703,12704,12705,12706,12707,12708,12709,12710, #12672
|
||||
12711,12712,12713,12714,12715,12716,12717,12718,12719,12720,12721,12722,12723,12724,12725,12726, #12688
|
||||
12727,12728,12729,12730,12731,12732,12733,12734,12735,12736,12737,12738,12739,12740,12741,12742, #12704
|
||||
12743,12744,12745,12746,12747,12748,12749,12750,12751,12752,12753,12754,12755,12756,12757,12758, #12720
|
||||
12759,12760,12761,12762,12763,12764,12765,12766,12767,12768,12769,12770,12771,12772,12773,12774, #12736
|
||||
12775,12776,12777,12778,4993,2175,12779,12780,12781,12782,12783,12784,12785,12786,4500,12787, #12752
|
||||
12788,12789,12790,12791,12792,12793,12794,12795,12796,12797,12798,12799,12800,12801,12802,12803, #12768
|
||||
12804,12805,12806,12807,12808,12809,12810,12811,12812,12813,12814,12815,12816,12817,12818,12819, #12784
|
||||
12820,12821,12822,12823,12824,12825,12826,4198,3967,12827,12828,12829,12830,12831,12832,12833, #12800
|
||||
12834,12835,12836,12837,12838,12839,12840,12841,12842,12843,12844,12845,12846,12847,12848,12849, #12816
|
||||
12850,12851,12852,12853,12854,12855,12856,12857,12858,12859,12860,12861,4199,12862,12863,12864, #12832
|
||||
12865,12866,12867,12868,12869,12870,12871,12872,12873,12874,12875,12876,12877,12878,12879,12880, #12848
|
||||
12881,12882,12883,12884,12885,12886,12887,4501,12888,12889,12890,12891,12892,12893,12894,12895, #12864
|
||||
12896,12897,12898,12899,12900,12901,12902,12903,12904,12905,12906,12907,12908,12909,12910,12911, #12880
|
||||
12912,4994,12913,12914,12915,12916,12917,12918,12919,12920,12921,12922,12923,12924,12925,12926, #12896
|
||||
12927,12928,12929,12930,12931,12932,12933,12934,12935,12936,12937,12938,12939,12940,12941,12942, #12912
|
||||
12943,12944,12945,12946,12947,12948,12949,12950,12951,12952,12953,12954,12955,12956,1772,12957, #12928
|
||||
12958,12959,12960,12961,12962,12963,12964,12965,12966,12967,12968,12969,12970,12971,12972,12973, #12944
|
||||
12974,12975,12976,12977,12978,12979,12980,12981,12982,12983,12984,12985,12986,12987,12988,12989, #12960
|
||||
12990,12991,12992,12993,12994,12995,12996,12997,4502,12998,4503,12999,13000,13001,13002,13003, #12976
|
||||
4504,13004,13005,13006,13007,13008,13009,13010,13011,13012,13013,13014,13015,13016,13017,13018, #12992
|
||||
13019,13020,13021,13022,13023,13024,13025,13026,13027,13028,13029,3449,13030,13031,13032,13033, #13008
|
||||
13034,13035,13036,13037,13038,13039,13040,13041,13042,13043,13044,13045,13046,13047,13048,13049, #13024
|
||||
13050,13051,13052,13053,13054,13055,13056,13057,13058,13059,13060,13061,13062,13063,13064,13065, #13040
|
||||
13066,13067,13068,13069,13070,13071,13072,13073,13074,13075,13076,13077,13078,13079,13080,13081, #13056
|
||||
13082,13083,13084,13085,13086,13087,13088,13089,13090,13091,13092,13093,13094,13095,13096,13097, #13072
|
||||
13098,13099,13100,13101,13102,13103,13104,13105,13106,13107,13108,13109,13110,13111,13112,13113, #13088
|
||||
13114,13115,13116,13117,13118,3968,13119,4995,13120,13121,13122,13123,13124,13125,13126,13127, #13104
|
||||
4505,13128,13129,13130,13131,13132,13133,13134,4996,4506,13135,13136,13137,13138,13139,4997, #13120
|
||||
13140,13141,13142,13143,13144,13145,13146,13147,13148,13149,13150,13151,13152,13153,13154,13155, #13136
|
||||
13156,13157,13158,13159,4998,13160,13161,13162,13163,13164,13165,13166,13167,13168,13169,13170, #13152
|
||||
13171,13172,13173,13174,13175,13176,4999,13177,13178,13179,13180,13181,13182,13183,13184,13185, #13168
|
||||
13186,13187,13188,13189,13190,13191,13192,13193,13194,13195,13196,13197,13198,13199,13200,13201, #13184
|
||||
13202,13203,13204,13205,13206,5000,13207,13208,13209,13210,13211,13212,13213,13214,13215,13216, #13200
|
||||
13217,13218,13219,13220,13221,13222,13223,13224,13225,13226,13227,4200,5001,13228,13229,13230, #13216
|
||||
13231,13232,13233,13234,13235,13236,13237,13238,13239,13240,3969,13241,13242,13243,13244,3970, #13232
|
||||
13245,13246,13247,13248,13249,13250,13251,13252,13253,13254,13255,13256,13257,13258,13259,13260, #13248
|
||||
13261,13262,13263,13264,13265,13266,13267,13268,3450,13269,13270,13271,13272,13273,13274,13275, #13264
|
||||
13276,5002,13277,13278,13279,13280,13281,13282,13283,13284,13285,13286,13287,13288,13289,13290, #13280
|
||||
13291,13292,13293,13294,13295,13296,13297,13298,13299,13300,13301,13302,3813,13303,13304,13305, #13296
|
||||
13306,13307,13308,13309,13310,13311,13312,13313,13314,13315,13316,13317,13318,13319,13320,13321, #13312
|
||||
13322,13323,13324,13325,13326,13327,13328,4507,13329,13330,13331,13332,13333,13334,13335,13336, #13328
|
||||
13337,13338,13339,13340,13341,5003,13342,13343,13344,13345,13346,13347,13348,13349,13350,13351, #13344
|
||||
13352,13353,13354,13355,13356,13357,13358,13359,13360,13361,13362,13363,13364,13365,13366,13367, #13360
|
||||
5004,13368,13369,13370,13371,13372,13373,13374,13375,13376,13377,13378,13379,13380,13381,13382, #13376
|
||||
13383,13384,13385,13386,13387,13388,13389,13390,13391,13392,13393,13394,13395,13396,13397,13398, #13392
|
||||
13399,13400,13401,13402,13403,13404,13405,13406,13407,13408,13409,13410,13411,13412,13413,13414, #13408
|
||||
13415,13416,13417,13418,13419,13420,13421,13422,13423,13424,13425,13426,13427,13428,13429,13430, #13424
|
||||
13431,13432,4508,13433,13434,13435,4201,13436,13437,13438,13439,13440,13441,13442,13443,13444, #13440
|
||||
13445,13446,13447,13448,13449,13450,13451,13452,13453,13454,13455,13456,13457,5005,13458,13459, #13456
|
||||
13460,13461,13462,13463,13464,13465,13466,13467,13468,13469,13470,4509,13471,13472,13473,13474, #13472
|
||||
13475,13476,13477,13478,13479,13480,13481,13482,13483,13484,13485,13486,13487,13488,13489,13490, #13488
|
||||
13491,13492,13493,13494,13495,13496,13497,13498,13499,13500,13501,13502,13503,13504,13505,13506, #13504
|
||||
13507,13508,13509,13510,13511,13512,13513,13514,13515,13516,13517,13518,13519,13520,13521,13522, #13520
|
||||
13523,13524,13525,13526,13527,13528,13529,13530,13531,13532,13533,13534,13535,13536,13537,13538, #13536
|
||||
13539,13540,13541,13542,13543,13544,13545,13546,13547,13548,13549,13550,13551,13552,13553,13554, #13552
|
||||
13555,13556,13557,13558,13559,13560,13561,13562,13563,13564,13565,13566,13567,13568,13569,13570, #13568
|
||||
13571,13572,13573,13574,13575,13576,13577,13578,13579,13580,13581,13582,13583,13584,13585,13586, #13584
|
||||
13587,13588,13589,13590,13591,13592,13593,13594,13595,13596,13597,13598,13599,13600,13601,13602, #13600
|
||||
13603,13604,13605,13606,13607,13608,13609,13610,13611,13612,13613,13614,13615,13616,13617,13618, #13616
|
||||
13619,13620,13621,13622,13623,13624,13625,13626,13627,13628,13629,13630,13631,13632,13633,13634, #13632
|
||||
13635,13636,13637,13638,13639,13640,13641,13642,5006,13643,13644,13645,13646,13647,13648,13649, #13648
|
||||
13650,13651,5007,13652,13653,13654,13655,13656,13657,13658,13659,13660,13661,13662,13663,13664, #13664
|
||||
13665,13666,13667,13668,13669,13670,13671,13672,13673,13674,13675,13676,13677,13678,13679,13680, #13680
|
||||
13681,13682,13683,13684,13685,13686,13687,13688,13689,13690,13691,13692,13693,13694,13695,13696, #13696
|
||||
13697,13698,13699,13700,13701,13702,13703,13704,13705,13706,13707,13708,13709,13710,13711,13712, #13712
|
||||
13713,13714,13715,13716,13717,13718,13719,13720,13721,13722,13723,13724,13725,13726,13727,13728, #13728
|
||||
13729,13730,13731,13732,13733,13734,13735,13736,13737,13738,13739,13740,13741,13742,13743,13744, #13744
|
||||
13745,13746,13747,13748,13749,13750,13751,13752,13753,13754,13755,13756,13757,13758,13759,13760, #13760
|
||||
13761,13762,13763,13764,13765,13766,13767,13768,13769,13770,13771,13772,13773,13774,3273,13775, #13776
|
||||
13776,13777,13778,13779,13780,13781,13782,13783,13784,13785,13786,13787,13788,13789,13790,13791, #13792
|
||||
13792,13793,13794,13795,13796,13797,13798,13799,13800,13801,13802,13803,13804,13805,13806,13807, #13808
|
||||
13808,13809,13810,13811,13812,13813,13814,13815,13816,13817,13818,13819,13820,13821,13822,13823, #13824
|
||||
13824,13825,13826,13827,13828,13829,13830,13831,13832,13833,13834,13835,13836,13837,13838,13839, #13840
|
||||
13840,13841,13842,13843,13844,13845,13846,13847,13848,13849,13850,13851,13852,13853,13854,13855, #13856
|
||||
13856,13857,13858,13859,13860,13861,13862,13863,13864,13865,13866,13867,13868,13869,13870,13871, #13872
|
||||
13872,13873,13874,13875,13876,13877,13878,13879,13880,13881,13882,13883,13884,13885,13886,13887, #13888
|
||||
13888,13889,13890,13891,13892,13893,13894,13895,13896,13897,13898,13899,13900,13901,13902,13903, #13904
|
||||
13904,13905,13906,13907,13908,13909,13910,13911,13912,13913,13914,13915,13916,13917,13918,13919, #13920
|
||||
13920,13921,13922,13923,13924,13925,13926,13927,13928,13929,13930,13931,13932,13933,13934,13935, #13936
|
||||
13936,13937,13938,13939,13940,13941,13942,13943,13944,13945,13946,13947,13948,13949,13950,13951, #13952
|
||||
13952,13953,13954,13955,13956,13957,13958,13959,13960,13961,13962,13963,13964,13965,13966,13967, #13968
|
||||
13968,13969,13970,13971,13972) #13973
|
||||
2299, 208,3546,4161,2020, 330,4438,3944,2906,2499,3799,4439,4811,5796,5797,5798, # 5376
|
||||
)
|
||||
|
||||
# flake8: noqa
|
||||
|
||||
@@ -28,15 +28,20 @@
|
||||
from .mbcharsetprober import MultiByteCharSetProber
|
||||
from .codingstatemachine import CodingStateMachine
|
||||
from .chardistribution import Big5DistributionAnalysis
|
||||
from .mbcssm import Big5SMModel
|
||||
from .mbcssm import BIG5_SM_MODEL
|
||||
|
||||
|
||||
class Big5Prober(MultiByteCharSetProber):
|
||||
def __init__(self):
|
||||
MultiByteCharSetProber.__init__(self)
|
||||
self._mCodingSM = CodingStateMachine(Big5SMModel)
|
||||
self._mDistributionAnalyzer = Big5DistributionAnalysis()
|
||||
super(Big5Prober, self).__init__()
|
||||
self.coding_sm = CodingStateMachine(BIG5_SM_MODEL)
|
||||
self.distribution_analyzer = Big5DistributionAnalysis()
|
||||
self.reset()
|
||||
|
||||
def get_charset_name(self):
|
||||
@property
|
||||
def charset_name(self):
|
||||
return "Big5"
|
||||
|
||||
@property
|
||||
def language(self):
|
||||
return "Chinese"
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Script which takes one or more file paths and reports on their detected
|
||||
encodings
|
||||
|
||||
Example::
|
||||
|
||||
% chardetect somefile someotherfile
|
||||
somefile: windows-1252 with confidence 0.5
|
||||
someotherfile: ascii with confidence 1.0
|
||||
|
||||
If no paths are provided, it takes its input from stdin.
|
||||
|
||||
"""
|
||||
from io import open
|
||||
from sys import argv, stdin
|
||||
|
||||
from chardet.universaldetector import UniversalDetector
|
||||
|
||||
|
||||
def description_of(file, name='stdin'):
|
||||
"""Return a string describing the probable encoding of a file."""
|
||||
u = UniversalDetector()
|
||||
for line in file:
|
||||
u.feed(line)
|
||||
u.close()
|
||||
result = u.result
|
||||
if result['encoding']:
|
||||
return '%s: %s with confidence %s' % (name,
|
||||
result['encoding'],
|
||||
result['confidence'])
|
||||
else:
|
||||
return '%s: no result' % name
|
||||
|
||||
|
||||
def main():
|
||||
if len(argv) <= 1:
|
||||
print(description_of(stdin))
|
||||
else:
|
||||
for path in argv[1:]:
|
||||
with open(path, 'rb') as f:
|
||||
print(description_of(f, path))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -25,82 +25,84 @@
|
||||
# 02110-1301 USA
|
||||
######################### END LICENSE BLOCK #########################
|
||||
|
||||
from .euctwfreq import (EUCTWCharToFreqOrder, EUCTW_TABLE_SIZE,
|
||||
from .euctwfreq import (EUCTW_CHAR_TO_FREQ_ORDER, EUCTW_TABLE_SIZE,
|
||||
EUCTW_TYPICAL_DISTRIBUTION_RATIO)
|
||||
from .euckrfreq import (EUCKRCharToFreqOrder, EUCKR_TABLE_SIZE,
|
||||
from .euckrfreq import (EUCKR_CHAR_TO_FREQ_ORDER, EUCKR_TABLE_SIZE,
|
||||
EUCKR_TYPICAL_DISTRIBUTION_RATIO)
|
||||
from .gb2312freq import (GB2312CharToFreqOrder, GB2312_TABLE_SIZE,
|
||||
from .gb2312freq import (GB2312_CHAR_TO_FREQ_ORDER, GB2312_TABLE_SIZE,
|
||||
GB2312_TYPICAL_DISTRIBUTION_RATIO)
|
||||
from .big5freq import (Big5CharToFreqOrder, BIG5_TABLE_SIZE,
|
||||
from .big5freq import (BIG5_CHAR_TO_FREQ_ORDER, BIG5_TABLE_SIZE,
|
||||
BIG5_TYPICAL_DISTRIBUTION_RATIO)
|
||||
from .jisfreq import (JISCharToFreqOrder, JIS_TABLE_SIZE,
|
||||
from .jisfreq import (JIS_CHAR_TO_FREQ_ORDER, JIS_TABLE_SIZE,
|
||||
JIS_TYPICAL_DISTRIBUTION_RATIO)
|
||||
from .compat import wrap_ord
|
||||
|
||||
ENOUGH_DATA_THRESHOLD = 1024
|
||||
SURE_YES = 0.99
|
||||
SURE_NO = 0.01
|
||||
MINIMUM_DATA_THRESHOLD = 3
|
||||
|
||||
|
||||
class CharDistributionAnalysis:
|
||||
class CharDistributionAnalysis(object):
|
||||
ENOUGH_DATA_THRESHOLD = 1024
|
||||
SURE_YES = 0.99
|
||||
SURE_NO = 0.01
|
||||
MINIMUM_DATA_THRESHOLD = 3
|
||||
|
||||
def __init__(self):
|
||||
# Mapping table to get frequency order from char order (get from
|
||||
# GetOrder())
|
||||
self._mCharToFreqOrder = None
|
||||
self._mTableSize = None # Size of above table
|
||||
self._char_to_freq_order = None
|
||||
self._table_size = None # Size of above table
|
||||
# This is a constant value which varies from language to language,
|
||||
# used in calculating confidence. See
|
||||
# http://www.mozilla.org/projects/intl/UniversalCharsetDetection.html
|
||||
# for further detail.
|
||||
self._mTypicalDistributionRatio = None
|
||||
self.typical_distribution_ratio = None
|
||||
self._done = None
|
||||
self._total_chars = None
|
||||
self._freq_chars = None
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
"""reset analyser, clear any state"""
|
||||
# If this flag is set to True, detection is done and conclusion has
|
||||
# been made
|
||||
self._mDone = False
|
||||
self._mTotalChars = 0 # Total characters encountered
|
||||
self._done = False
|
||||
self._total_chars = 0 # Total characters encountered
|
||||
# The number of characters whose frequency order is less than 512
|
||||
self._mFreqChars = 0
|
||||
self._freq_chars = 0
|
||||
|
||||
def feed(self, aBuf, aCharLen):
|
||||
def feed(self, char, char_len):
|
||||
"""feed a character with known length"""
|
||||
if aCharLen == 2:
|
||||
if char_len == 2:
|
||||
# we only care about 2-bytes character in our distribution analysis
|
||||
order = self.get_order(aBuf)
|
||||
order = self.get_order(char)
|
||||
else:
|
||||
order = -1
|
||||
if order >= 0:
|
||||
self._mTotalChars += 1
|
||||
self._total_chars += 1
|
||||
# order is valid
|
||||
if order < self._mTableSize:
|
||||
if 512 > self._mCharToFreqOrder[order]:
|
||||
self._mFreqChars += 1
|
||||
if order < self._table_size:
|
||||
if 512 > self._char_to_freq_order[order]:
|
||||
self._freq_chars += 1
|
||||
|
||||
def get_confidence(self):
|
||||
"""return confidence based on existing data"""
|
||||
# if we didn't receive any character in our consideration range,
|
||||
# return negative answer
|
||||
if self._mTotalChars <= 0 or self._mFreqChars <= MINIMUM_DATA_THRESHOLD:
|
||||
return SURE_NO
|
||||
if self._total_chars <= 0 or self._freq_chars <= self.MINIMUM_DATA_THRESHOLD:
|
||||
return self.SURE_NO
|
||||
|
||||
if self._mTotalChars != self._mFreqChars:
|
||||
r = (self._mFreqChars / ((self._mTotalChars - self._mFreqChars)
|
||||
* self._mTypicalDistributionRatio))
|
||||
if r < SURE_YES:
|
||||
if self._total_chars != self._freq_chars:
|
||||
r = (self._freq_chars / ((self._total_chars - self._freq_chars)
|
||||
* self.typical_distribution_ratio))
|
||||
if r < self.SURE_YES:
|
||||
return r
|
||||
|
||||
# normalize confidence (we don't want to be 100% sure)
|
||||
return SURE_YES
|
||||
return self.SURE_YES
|
||||
|
||||
def got_enough_data(self):
|
||||
# It is not necessary to receive all data to draw conclusion.
|
||||
# For charset detection, certain amount of data is enough
|
||||
return self._mTotalChars > ENOUGH_DATA_THRESHOLD
|
||||
return self._total_chars > self.ENOUGH_DATA_THRESHOLD
|
||||
|
||||
def get_order(self, aBuf):
|
||||
def get_order(self, byte_str):
|
||||
# We do not handle characters based on the original encoding string,
|
||||
# but convert this encoding string to a number, here called order.
|
||||
# This allows multiple encodings of a language to share one frequency
|
||||
@@ -110,55 +112,55 @@ class CharDistributionAnalysis:
|
||||
|
||||
class EUCTWDistributionAnalysis(CharDistributionAnalysis):
|
||||
def __init__(self):
|
||||
CharDistributionAnalysis.__init__(self)
|
||||
self._mCharToFreqOrder = EUCTWCharToFreqOrder
|
||||
self._mTableSize = EUCTW_TABLE_SIZE
|
||||
self._mTypicalDistributionRatio = EUCTW_TYPICAL_DISTRIBUTION_RATIO
|
||||
super(EUCTWDistributionAnalysis, self).__init__()
|
||||
self._char_to_freq_order = EUCTW_CHAR_TO_FREQ_ORDER
|
||||
self._table_size = EUCTW_TABLE_SIZE
|
||||
self.typical_distribution_ratio = EUCTW_TYPICAL_DISTRIBUTION_RATIO
|
||||
|
||||
def get_order(self, aBuf):
|
||||
def get_order(self, byte_str):
|
||||
# for euc-TW encoding, we are interested
|
||||
# first byte range: 0xc4 -- 0xfe
|
||||
# second byte range: 0xa1 -- 0xfe
|
||||
# no validation needed here. State machine has done that
|
||||
first_char = wrap_ord(aBuf[0])
|
||||
first_char = byte_str[0]
|
||||
if first_char >= 0xC4:
|
||||
return 94 * (first_char - 0xC4) + wrap_ord(aBuf[1]) - 0xA1
|
||||
return 94 * (first_char - 0xC4) + byte_str[1] - 0xA1
|
||||
else:
|
||||
return -1
|
||||
|
||||
|
||||
class EUCKRDistributionAnalysis(CharDistributionAnalysis):
|
||||
def __init__(self):
|
||||
CharDistributionAnalysis.__init__(self)
|
||||
self._mCharToFreqOrder = EUCKRCharToFreqOrder
|
||||
self._mTableSize = EUCKR_TABLE_SIZE
|
||||
self._mTypicalDistributionRatio = EUCKR_TYPICAL_DISTRIBUTION_RATIO
|
||||
super(EUCKRDistributionAnalysis, self).__init__()
|
||||
self._char_to_freq_order = EUCKR_CHAR_TO_FREQ_ORDER
|
||||
self._table_size = EUCKR_TABLE_SIZE
|
||||
self.typical_distribution_ratio = EUCKR_TYPICAL_DISTRIBUTION_RATIO
|
||||
|
||||
def get_order(self, aBuf):
|
||||
def get_order(self, byte_str):
|
||||
# for euc-KR encoding, we are interested
|
||||
# first byte range: 0xb0 -- 0xfe
|
||||
# second byte range: 0xa1 -- 0xfe
|
||||
# no validation needed here. State machine has done that
|
||||
first_char = wrap_ord(aBuf[0])
|
||||
first_char = byte_str[0]
|
||||
if first_char >= 0xB0:
|
||||
return 94 * (first_char - 0xB0) + wrap_ord(aBuf[1]) - 0xA1
|
||||
return 94 * (first_char - 0xB0) + byte_str[1] - 0xA1
|
||||
else:
|
||||
return -1
|
||||
|
||||
|
||||
class GB2312DistributionAnalysis(CharDistributionAnalysis):
|
||||
def __init__(self):
|
||||
CharDistributionAnalysis.__init__(self)
|
||||
self._mCharToFreqOrder = GB2312CharToFreqOrder
|
||||
self._mTableSize = GB2312_TABLE_SIZE
|
||||
self._mTypicalDistributionRatio = GB2312_TYPICAL_DISTRIBUTION_RATIO
|
||||
super(GB2312DistributionAnalysis, self).__init__()
|
||||
self._char_to_freq_order = GB2312_CHAR_TO_FREQ_ORDER
|
||||
self._table_size = GB2312_TABLE_SIZE
|
||||
self.typical_distribution_ratio = GB2312_TYPICAL_DISTRIBUTION_RATIO
|
||||
|
||||
def get_order(self, aBuf):
|
||||
def get_order(self, byte_str):
|
||||
# for GB2312 encoding, we are interested
|
||||
# first byte range: 0xb0 -- 0xfe
|
||||
# second byte range: 0xa1 -- 0xfe
|
||||
# no validation needed here. State machine has done that
|
||||
first_char, second_char = wrap_ord(aBuf[0]), wrap_ord(aBuf[1])
|
||||
first_char, second_char = byte_str[0], byte_str[1]
|
||||
if (first_char >= 0xB0) and (second_char >= 0xA1):
|
||||
return 94 * (first_char - 0xB0) + second_char - 0xA1
|
||||
else:
|
||||
@@ -167,17 +169,17 @@ class GB2312DistributionAnalysis(CharDistributionAnalysis):
|
||||
|
||||
class Big5DistributionAnalysis(CharDistributionAnalysis):
|
||||
def __init__(self):
|
||||
CharDistributionAnalysis.__init__(self)
|
||||
self._mCharToFreqOrder = Big5CharToFreqOrder
|
||||
self._mTableSize = BIG5_TABLE_SIZE
|
||||
self._mTypicalDistributionRatio = BIG5_TYPICAL_DISTRIBUTION_RATIO
|
||||
super(Big5DistributionAnalysis, self).__init__()
|
||||
self._char_to_freq_order = BIG5_CHAR_TO_FREQ_ORDER
|
||||
self._table_size = BIG5_TABLE_SIZE
|
||||
self.typical_distribution_ratio = BIG5_TYPICAL_DISTRIBUTION_RATIO
|
||||
|
||||
def get_order(self, aBuf):
|
||||
def get_order(self, byte_str):
|
||||
# for big5 encoding, we are interested
|
||||
# first byte range: 0xa4 -- 0xfe
|
||||
# second byte range: 0x40 -- 0x7e , 0xa1 -- 0xfe
|
||||
# no validation needed here. State machine has done that
|
||||
first_char, second_char = wrap_ord(aBuf[0]), wrap_ord(aBuf[1])
|
||||
first_char, second_char = byte_str[0], byte_str[1]
|
||||
if first_char >= 0xA4:
|
||||
if second_char >= 0xA1:
|
||||
return 157 * (first_char - 0xA4) + second_char - 0xA1 + 63
|
||||
@@ -189,17 +191,17 @@ class Big5DistributionAnalysis(CharDistributionAnalysis):
|
||||
|
||||
class SJISDistributionAnalysis(CharDistributionAnalysis):
|
||||
def __init__(self):
|
||||
CharDistributionAnalysis.__init__(self)
|
||||
self._mCharToFreqOrder = JISCharToFreqOrder
|
||||
self._mTableSize = JIS_TABLE_SIZE
|
||||
self._mTypicalDistributionRatio = JIS_TYPICAL_DISTRIBUTION_RATIO
|
||||
super(SJISDistributionAnalysis, self).__init__()
|
||||
self._char_to_freq_order = JIS_CHAR_TO_FREQ_ORDER
|
||||
self._table_size = JIS_TABLE_SIZE
|
||||
self.typical_distribution_ratio = JIS_TYPICAL_DISTRIBUTION_RATIO
|
||||
|
||||
def get_order(self, aBuf):
|
||||
def get_order(self, byte_str):
|
||||
# for sjis encoding, we are interested
|
||||
# first byte range: 0x81 -- 0x9f , 0xe0 -- 0xfe
|
||||
# second byte range: 0x40 -- 0x7e, 0x81 -- oxfe
|
||||
# no validation needed here. State machine has done that
|
||||
first_char, second_char = wrap_ord(aBuf[0]), wrap_ord(aBuf[1])
|
||||
first_char, second_char = byte_str[0], byte_str[1]
|
||||
if (first_char >= 0x81) and (first_char <= 0x9F):
|
||||
order = 188 * (first_char - 0x81)
|
||||
elif (first_char >= 0xE0) and (first_char <= 0xEF):
|
||||
@@ -214,18 +216,18 @@ class SJISDistributionAnalysis(CharDistributionAnalysis):
|
||||
|
||||
class EUCJPDistributionAnalysis(CharDistributionAnalysis):
|
||||
def __init__(self):
|
||||
CharDistributionAnalysis.__init__(self)
|
||||
self._mCharToFreqOrder = JISCharToFreqOrder
|
||||
self._mTableSize = JIS_TABLE_SIZE
|
||||
self._mTypicalDistributionRatio = JIS_TYPICAL_DISTRIBUTION_RATIO
|
||||
super(EUCJPDistributionAnalysis, self).__init__()
|
||||
self._char_to_freq_order = JIS_CHAR_TO_FREQ_ORDER
|
||||
self._table_size = JIS_TABLE_SIZE
|
||||
self.typical_distribution_ratio = JIS_TYPICAL_DISTRIBUTION_RATIO
|
||||
|
||||
def get_order(self, aBuf):
|
||||
def get_order(self, byte_str):
|
||||
# for euc-JP encoding, we are interested
|
||||
# first byte range: 0xa0 -- 0xfe
|
||||
# second byte range: 0xa1 -- 0xfe
|
||||
# no validation needed here. State machine has done that
|
||||
char = wrap_ord(aBuf[0])
|
||||
char = byte_str[0]
|
||||
if char >= 0xA0:
|
||||
return 94 * (char - 0xA1) + wrap_ord(aBuf[1]) - 0xa1
|
||||
return 94 * (char - 0xA1) + byte_str[1] - 0xa1
|
||||
else:
|
||||
return -1
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
######################## BEGIN LICENSE BLOCK ########################
|
||||
# The Original Code is Mozilla Communicator client code.
|
||||
#
|
||||
#
|
||||
# The Initial Developer of the Original Code is
|
||||
# Netscape Communications Corporation.
|
||||
# Portions created by the Initial Developer are Copyright (C) 1998
|
||||
# the Initial Developer. All Rights Reserved.
|
||||
#
|
||||
#
|
||||
# Contributor(s):
|
||||
# Mark Pilgrim - port to Python
|
||||
#
|
||||
@@ -13,94 +13,94 @@
|
||||
# modify it under the terms of the GNU Lesser General Public
|
||||
# License as published by the Free Software Foundation; either
|
||||
# version 2.1 of the License, or (at your option) any later version.
|
||||
#
|
||||
#
|
||||
# This library is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this library; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
|
||||
# 02110-1301 USA
|
||||
######################### END LICENSE BLOCK #########################
|
||||
|
||||
from . import constants
|
||||
import sys
|
||||
from .enums import ProbingState
|
||||
from .charsetprober import CharSetProber
|
||||
|
||||
|
||||
class CharSetGroupProber(CharSetProber):
|
||||
def __init__(self):
|
||||
CharSetProber.__init__(self)
|
||||
self._mActiveNum = 0
|
||||
self._mProbers = []
|
||||
self._mBestGuessProber = None
|
||||
def __init__(self, lang_filter=None):
|
||||
super(CharSetGroupProber, self).__init__(lang_filter=lang_filter)
|
||||
self._active_num = 0
|
||||
self.probers = []
|
||||
self._best_guess_prober = None
|
||||
|
||||
def reset(self):
|
||||
CharSetProber.reset(self)
|
||||
self._mActiveNum = 0
|
||||
for prober in self._mProbers:
|
||||
super(CharSetGroupProber, self).reset()
|
||||
self._active_num = 0
|
||||
for prober in self.probers:
|
||||
if prober:
|
||||
prober.reset()
|
||||
prober.active = True
|
||||
self._mActiveNum += 1
|
||||
self._mBestGuessProber = None
|
||||
self._active_num += 1
|
||||
self._best_guess_prober = None
|
||||
|
||||
def get_charset_name(self):
|
||||
if not self._mBestGuessProber:
|
||||
@property
|
||||
def charset_name(self):
|
||||
if not self._best_guess_prober:
|
||||
self.get_confidence()
|
||||
if not self._mBestGuessProber:
|
||||
if not self._best_guess_prober:
|
||||
return None
|
||||
# self._mBestGuessProber = self._mProbers[0]
|
||||
return self._mBestGuessProber.get_charset_name()
|
||||
return self._best_guess_prober.charset_name
|
||||
|
||||
def feed(self, aBuf):
|
||||
for prober in self._mProbers:
|
||||
@property
|
||||
def language(self):
|
||||
if not self._best_guess_prober:
|
||||
self.get_confidence()
|
||||
if not self._best_guess_prober:
|
||||
return None
|
||||
return self._best_guess_prober.language
|
||||
|
||||
def feed(self, byte_str):
|
||||
for prober in self.probers:
|
||||
if not prober:
|
||||
continue
|
||||
if not prober.active:
|
||||
continue
|
||||
st = prober.feed(aBuf)
|
||||
if not st:
|
||||
state = prober.feed(byte_str)
|
||||
if not state:
|
||||
continue
|
||||
if st == constants.eFoundIt:
|
||||
self._mBestGuessProber = prober
|
||||
return self.get_state()
|
||||
elif st == constants.eNotMe:
|
||||
if state == ProbingState.FOUND_IT:
|
||||
self._best_guess_prober = prober
|
||||
return self.state
|
||||
elif state == ProbingState.NOT_ME:
|
||||
prober.active = False
|
||||
self._mActiveNum -= 1
|
||||
if self._mActiveNum <= 0:
|
||||
self._mState = constants.eNotMe
|
||||
return self.get_state()
|
||||
return self.get_state()
|
||||
self._active_num -= 1
|
||||
if self._active_num <= 0:
|
||||
self._state = ProbingState.NOT_ME
|
||||
return self.state
|
||||
return self.state
|
||||
|
||||
def get_confidence(self):
|
||||
st = self.get_state()
|
||||
if st == constants.eFoundIt:
|
||||
state = self.state
|
||||
if state == ProbingState.FOUND_IT:
|
||||
return 0.99
|
||||
elif st == constants.eNotMe:
|
||||
elif state == ProbingState.NOT_ME:
|
||||
return 0.01
|
||||
bestConf = 0.0
|
||||
self._mBestGuessProber = None
|
||||
for prober in self._mProbers:
|
||||
best_conf = 0.0
|
||||
self._best_guess_prober = None
|
||||
for prober in self.probers:
|
||||
if not prober:
|
||||
continue
|
||||
if not prober.active:
|
||||
if constants._debug:
|
||||
sys.stderr.write(prober.get_charset_name()
|
||||
+ ' not active\n')
|
||||
self.logger.debug('%s not active', prober.charset_name)
|
||||
continue
|
||||
cf = prober.get_confidence()
|
||||
if constants._debug:
|
||||
sys.stderr.write('%s confidence = %s\n' %
|
||||
(prober.get_charset_name(), cf))
|
||||
if bestConf < cf:
|
||||
bestConf = cf
|
||||
self._mBestGuessProber = prober
|
||||
if not self._mBestGuessProber:
|
||||
conf = prober.get_confidence()
|
||||
self.logger.debug('%s %s confidence = %s', prober.charset_name, prober.language, conf)
|
||||
if best_conf < conf:
|
||||
best_conf = conf
|
||||
self._best_guess_prober = prober
|
||||
if not self._best_guess_prober:
|
||||
return 0.0
|
||||
return bestConf
|
||||
# else:
|
||||
# self._mBestGuessProber = self._mProbers[0]
|
||||
# return self._mBestGuessProber.get_confidence()
|
||||
return best_conf
|
||||
|
||||
@@ -26,37 +26,120 @@
|
||||
# 02110-1301 USA
|
||||
######################### END LICENSE BLOCK #########################
|
||||
|
||||
from . import constants
|
||||
import logging
|
||||
import re
|
||||
|
||||
from .enums import ProbingState
|
||||
|
||||
class CharSetProber:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
class CharSetProber(object):
|
||||
|
||||
SHORTCUT_THRESHOLD = 0.95
|
||||
|
||||
def __init__(self, lang_filter=None):
|
||||
self._state = None
|
||||
self.lang_filter = lang_filter
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def reset(self):
|
||||
self._mState = constants.eDetecting
|
||||
self._state = ProbingState.DETECTING
|
||||
|
||||
def get_charset_name(self):
|
||||
@property
|
||||
def charset_name(self):
|
||||
return None
|
||||
|
||||
def feed(self, aBuf):
|
||||
def feed(self, buf):
|
||||
pass
|
||||
|
||||
def get_state(self):
|
||||
return self._mState
|
||||
@property
|
||||
def state(self):
|
||||
return self._state
|
||||
|
||||
def get_confidence(self):
|
||||
return 0.0
|
||||
|
||||
def filter_high_bit_only(self, aBuf):
|
||||
aBuf = re.sub(b'([\x00-\x7F])+', b' ', aBuf)
|
||||
return aBuf
|
||||
@staticmethod
|
||||
def filter_high_byte_only(buf):
|
||||
buf = re.sub(b'([\x00-\x7F])+', b' ', buf)
|
||||
return buf
|
||||
|
||||
def filter_without_english_letters(self, aBuf):
|
||||
aBuf = re.sub(b'([A-Za-z])+', b' ', aBuf)
|
||||
return aBuf
|
||||
@staticmethod
|
||||
def filter_international_words(buf):
|
||||
"""
|
||||
We define three types of bytes:
|
||||
alphabet: english alphabets [a-zA-Z]
|
||||
international: international characters [\x80-\xFF]
|
||||
marker: everything else [^a-zA-Z\x80-\xFF]
|
||||
|
||||
def filter_with_english_letters(self, aBuf):
|
||||
# TODO
|
||||
return aBuf
|
||||
The input buffer can be thought to contain a series of words delimited
|
||||
by markers. This function works to filter all words that contain at
|
||||
least one international character. All contiguous sequences of markers
|
||||
are replaced by a single space ascii character.
|
||||
|
||||
This filter applies to all scripts which do not use English characters.
|
||||
"""
|
||||
filtered = bytearray()
|
||||
|
||||
# This regex expression filters out only words that have at-least one
|
||||
# international character. The word may include one marker character at
|
||||
# the end.
|
||||
words = re.findall(b'[a-zA-Z]*[\x80-\xFF]+[a-zA-Z]*[^a-zA-Z\x80-\xFF]?',
|
||||
buf)
|
||||
|
||||
for word in words:
|
||||
filtered.extend(word[:-1])
|
||||
|
||||
# If the last character in the word is a marker, replace it with a
|
||||
# space as markers shouldn't affect our analysis (they are used
|
||||
# similarly across all languages and may thus have similar
|
||||
# frequencies).
|
||||
last_char = word[-1:]
|
||||
if not last_char.isalpha() and last_char < b'\x80':
|
||||
last_char = b' '
|
||||
filtered.extend(last_char)
|
||||
|
||||
return filtered
|
||||
|
||||
@staticmethod
|
||||
def filter_with_english_letters(buf):
|
||||
"""
|
||||
Returns a copy of ``buf`` that retains only the sequences of English
|
||||
alphabet and high byte characters that are not between <> characters.
|
||||
Also retains English alphabet and high byte characters immediately
|
||||
before occurrences of >.
|
||||
|
||||
This filter can be applied to all scripts which contain both English
|
||||
characters and extended ASCII characters, but is currently only used by
|
||||
``Latin1Prober``.
|
||||
"""
|
||||
filtered = bytearray()
|
||||
in_tag = False
|
||||
prev = 0
|
||||
|
||||
for curr in range(len(buf)):
|
||||
# Slice here to get bytes instead of an int with Python 3
|
||||
buf_char = buf[curr:curr + 1]
|
||||
# Check if we're coming out of or entering an HTML tag
|
||||
if buf_char == b'>':
|
||||
in_tag = False
|
||||
elif buf_char == b'<':
|
||||
in_tag = True
|
||||
|
||||
# If current character is not extended-ASCII and not alphabetic...
|
||||
if buf_char < b'\x80' and not buf_char.isalpha():
|
||||
# ...and we're not in a tag
|
||||
if curr > prev and not in_tag:
|
||||
# Keep everything after last non-extended-ASCII,
|
||||
# non-alphabetic character
|
||||
filtered.extend(buf[prev:curr])
|
||||
# Output a space to delimit stretch we kept
|
||||
filtered.extend(b' ')
|
||||
prev = curr + 1
|
||||
|
||||
# If we're not in a tag...
|
||||
if not in_tag:
|
||||
# Keep everything after last non-extended-ASCII, non-alphabetic
|
||||
# character
|
||||
filtered.extend(buf[prev:])
|
||||
|
||||
return filtered
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Script which takes one or more file paths and reports on their detected
|
||||
encodings
|
||||
|
||||
Example::
|
||||
|
||||
% chardetect somefile someotherfile
|
||||
somefile: windows-1252 with confidence 0.5
|
||||
someotherfile: ascii with confidence 1.0
|
||||
|
||||
If no paths are provided, it takes its input from stdin.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import, print_function, unicode_literals
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from chardet import __version__
|
||||
from chardet.compat import PY2
|
||||
from chardet.universaldetector import UniversalDetector
|
||||
|
||||
|
||||
def description_of(lines, name='stdin'):
|
||||
"""
|
||||
Return a string describing the probable encoding of a file or
|
||||
list of strings.
|
||||
|
||||
:param lines: The lines to get the encoding of.
|
||||
:type lines: Iterable of bytes
|
||||
:param name: Name of file or collection of lines
|
||||
:type name: str
|
||||
"""
|
||||
u = UniversalDetector()
|
||||
for line in lines:
|
||||
line = bytearray(line)
|
||||
u.feed(line)
|
||||
# shortcut out of the loop to save reading further - particularly useful if we read a BOM.
|
||||
if u.done:
|
||||
break
|
||||
u.close()
|
||||
result = u.result
|
||||
if PY2:
|
||||
name = name.decode(sys.getfilesystemencoding(), 'ignore')
|
||||
if result['encoding']:
|
||||
return '{0}: {1} with confidence {2}'.format(name, result['encoding'],
|
||||
result['confidence'])
|
||||
else:
|
||||
return '{0}: no result'.format(name)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
"""
|
||||
Handles command line arguments and gets things started.
|
||||
|
||||
:param argv: List of arguments, as if specified on the command-line.
|
||||
If None, ``sys.argv[1:]`` is used instead.
|
||||
:type argv: list of str
|
||||
"""
|
||||
# Get command line arguments
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Takes one or more file paths and reports their detected \
|
||||
encodings")
|
||||
parser.add_argument('input',
|
||||
help='File whose encoding we would like to determine. \
|
||||
(default: stdin)',
|
||||
type=argparse.FileType('rb'), nargs='*',
|
||||
default=[sys.stdin if PY2 else sys.stdin.buffer])
|
||||
parser.add_argument('--version', action='version',
|
||||
version='%(prog)s {0}'.format(__version__))
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
for f in args.input:
|
||||
if f.isatty():
|
||||
print("You are running chardetect interactively. Press " +
|
||||
"CTRL-D twice at the start of a blank line to signal the " +
|
||||
"end of your input. If you want help, run chardetect " +
|
||||
"--help\n", file=sys.stderr)
|
||||
print(description_of(f, f.name))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -25,37 +25,64 @@
|
||||
# 02110-1301 USA
|
||||
######################### END LICENSE BLOCK #########################
|
||||
|
||||
from .constants import eStart
|
||||
from .compat import wrap_ord
|
||||
import logging
|
||||
|
||||
from .enums import MachineState
|
||||
|
||||
|
||||
class CodingStateMachine:
|
||||
class CodingStateMachine(object):
|
||||
"""
|
||||
A state machine to verify a byte sequence for a particular encoding. For
|
||||
each byte the detector receives, it will feed that byte to every active
|
||||
state machine available, one byte at a time. The state machine changes its
|
||||
state based on its previous state and the byte it receives. There are 3
|
||||
states in a state machine that are of interest to an auto-detector:
|
||||
|
||||
START state: This is the state to start with, or a legal byte sequence
|
||||
(i.e. a valid code point) for character has been identified.
|
||||
|
||||
ME state: This indicates that the state machine identified a byte sequence
|
||||
that is specific to the charset it is designed for and that
|
||||
there is no other possible encoding which can contain this byte
|
||||
sequence. This will to lead to an immediate positive answer for
|
||||
the detector.
|
||||
|
||||
ERROR state: This indicates the state machine identified an illegal byte
|
||||
sequence for that encoding. This will lead to an immediate
|
||||
negative answer for this encoding. Detector will exclude this
|
||||
encoding from consideration from here on.
|
||||
"""
|
||||
def __init__(self, sm):
|
||||
self._mModel = sm
|
||||
self._mCurrentBytePos = 0
|
||||
self._mCurrentCharLen = 0
|
||||
self._model = sm
|
||||
self._curr_byte_pos = 0
|
||||
self._curr_char_len = 0
|
||||
self._curr_state = None
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self._mCurrentState = eStart
|
||||
self._curr_state = MachineState.START
|
||||
|
||||
def next_state(self, c):
|
||||
# for each byte we get its class
|
||||
# if it is first byte, we also get byte length
|
||||
# PY3K: aBuf is a byte stream, so c is an int, not a byte
|
||||
byteCls = self._mModel['classTable'][wrap_ord(c)]
|
||||
if self._mCurrentState == eStart:
|
||||
self._mCurrentBytePos = 0
|
||||
self._mCurrentCharLen = self._mModel['charLenTable'][byteCls]
|
||||
# from byte's class and stateTable, we get its next state
|
||||
curr_state = (self._mCurrentState * self._mModel['classFactor']
|
||||
+ byteCls)
|
||||
self._mCurrentState = self._mModel['stateTable'][curr_state]
|
||||
self._mCurrentBytePos += 1
|
||||
return self._mCurrentState
|
||||
byte_class = self._model['class_table'][c]
|
||||
if self._curr_state == MachineState.START:
|
||||
self._curr_byte_pos = 0
|
||||
self._curr_char_len = self._model['char_len_table'][byte_class]
|
||||
# from byte's class and state_table, we get its next state
|
||||
curr_state = (self._curr_state * self._model['class_factor']
|
||||
+ byte_class)
|
||||
self._curr_state = self._model['state_table'][curr_state]
|
||||
self._curr_byte_pos += 1
|
||||
return self._curr_state
|
||||
|
||||
def get_current_charlen(self):
|
||||
return self._mCurrentCharLen
|
||||
return self._curr_char_len
|
||||
|
||||
def get_coding_state_machine(self):
|
||||
return self._mModel['name']
|
||||
return self._model['name']
|
||||
|
||||
@property
|
||||
def language(self):
|
||||
return self._model['language']
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
######################## BEGIN LICENSE BLOCK ########################
|
||||
# Contributor(s):
|
||||
# Ian Cordasco - port to Python
|
||||
# Dan Blanchard
|
||||
# Ian Cordasco
|
||||
#
|
||||
# This library is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Lesser General Public
|
||||
@@ -22,13 +23,12 @@ import sys
|
||||
|
||||
|
||||
if sys.version_info < (3, 0):
|
||||
PY2 = True
|
||||
PY3 = False
|
||||
base_str = (str, unicode)
|
||||
text_type = unicode
|
||||
else:
|
||||
PY2 = False
|
||||
PY3 = True
|
||||
base_str = (bytes, str)
|
||||
|
||||
|
||||
def wrap_ord(a):
|
||||
if sys.version_info < (3, 0) and isinstance(a, base_str):
|
||||
return ord(a)
|
||||
else:
|
||||
return a
|
||||
text_type = str
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
######################## BEGIN LICENSE BLOCK ########################
|
||||
# The Original Code is Mozilla Universal charset detector code.
|
||||
#
|
||||
# The Initial Developer of the Original Code is
|
||||
# Netscape Communications Corporation.
|
||||
# Portions created by the Initial Developer are Copyright (C) 2001
|
||||
# the Initial Developer. All Rights Reserved.
|
||||
#
|
||||
# Contributor(s):
|
||||
# Mark Pilgrim - port to Python
|
||||
# Shy Shalom - original C code
|
||||
#
|
||||
# This library is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Lesser General Public
|
||||
# License as published by the Free Software Foundation; either
|
||||
# version 2.1 of the License, or (at your option) any later version.
|
||||
#
|
||||
# This library is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this library; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
|
||||
# 02110-1301 USA
|
||||
######################### END LICENSE BLOCK #########################
|
||||
|
||||
_debug = 0
|
||||
|
||||
eDetecting = 0
|
||||
eFoundIt = 1
|
||||
eNotMe = 2
|
||||
|
||||
eStart = 0
|
||||
eError = 1
|
||||
eItsMe = 2
|
||||
|
||||
SHORTCUT_THRESHOLD = 0.95
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user