Compare commits
2389 Commits
1.1-beta3
...
2.5.3.2422
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| abb7cd3bfa | |||
| bfa06f3989 | |||
| c63529939d | |||
| 2814f57e89 | |||
| 70476883c6 | |||
| b5ed209453 | |||
| be7687f15d | |||
| b7fb8e1e76 |
@@ -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
+872
@@ -0,0 +1,872 @@
|
||||
|
||||
|
||||
2.5.3.2414
|
||||
|
||||
- core: expand user agent list
|
||||
- core: update subliminal to 4ad5d31
|
||||
- core: treat 23.976, 23.98, 24.0 fps as equal
|
||||
- core: correctly skip blacklist entries when iterating through currently known subs
|
||||
- core: fix unpacking of packs without asked-for-release-group
|
||||
- core: fix embedded subtitle language detection; add debug log
|
||||
- core: treat embedded subtitle containing "forced" in its title as forced
|
||||
- core: improve embedded subtitles detection
|
||||
- core: store extracted embedded forced subtitles with the "forced" suffix (e.g.: video.en.forced.srt)
|
||||
- core: don't bother trying to extract embedded subtitle if transcoder wasn't found
|
||||
- core: fix automatic extraction of unknown embedded subtitle streams
|
||||
- core: skip immediately searching for new subtitle after successfully extracting embedded
|
||||
- core: extract embedded ASS: don't transcode to SRT using ffmpeg (Plex Transcoder), do the transcoding later using pysubs2; fixes offset issues
|
||||
- core: extract embedded: let ffmpeg auto convert mov_text/tx3g to srt
|
||||
- core: fix transcoder detection; add fallback #460
|
||||
- core: remove LD_LIBRARY_PATH from environment before calling notification executable
|
||||
- core: auto extract embedded subtitles in a separate thread
|
||||
- core: reduce encoding change log spam
|
||||
- core: only allow one automatic extraction at a time; add optional advanced settings "auto_extract_multithread"
|
||||
- core: add minimum score a subtitle has to have when considered by the find better subtitles task, when the current subtitle is an extracted embedded one; add advanced_settings entries
|
||||
- core/config: automatic extraction: add config setting to indicate whether there should be an immediate search for available subtitles after extraction or not (default: off)
|
||||
- core/menu/submod: add reverse_rtl modification for Hebrew; fixes #409
|
||||
- core: scoring: assume title match on tvdb_id match
|
||||
- tasks: search all recently added missing: fix attribute access on missing stored subtitle info
|
||||
- providers: add hosszupuska (hungarian, thanks morpheus133 for the basic implementation)
|
||||
- providers: add argenteam (spanish, thanks mmiraglia for the basic implementation)
|
||||
- providers: addic7ed: use random user agent by default (enforce for existing configs)
|
||||
- providers: enable subscene by default
|
||||
- providers: opensubtitles: add fallback for dict based query response in contrast to list/array based
|
||||
- advanced settings: make text-based-subtitle-formats configurable
|
||||
- menu: submod: inverse-reverse subtitle timing time-choices for better accessibility
|
||||
- submod: reduce log spam in case of debug logs enabled
|
||||
- submod: style tags could result in no output at all
|
||||
- submod: fix empty content if only non-line-mods were used, no line-mods; fixes #449
|
||||
- submod: HI: correctly handle style tags when checking for brackets
|
||||
- submod: HI: don't remove anything that's surrounded by quotes
|
||||
- submod: HI: double or triple dash is em dash
|
||||
- submod: HI: HI_before_colon_noncaps, don't assume single quotes are sentence enders
|
||||
- submod: common: don't uppercase after abbreviations
|
||||
- submod: common: don't break phone numbers (more than one spaced number pair found)
|
||||
- submod: common: also count lines only consisting of dots as removable
|
||||
- submod: common: replace more than 3 consecutive dots with 3 dots
|
||||
- submod: OCR: "H i." = "Hi."
|
||||
|
||||
|
||||
2.5.0.2287
|
||||
|
||||
- core: reduce main icon size
|
||||
- core: fix usage on NVIDIA SHIELD (hopefully, please report back), #441
|
||||
- core: add scandir fallback to listdir in case of badly configured locale in environment, #441, #440
|
||||
- core: get subtitles from archive: don't assume an episode match
|
||||
- core: get subtitles from archive: don't assume any attributes in guess
|
||||
- core: improve release group detection for drone/filebot/file_info refiners
|
||||
- core: fix language detection for embedded subtitle streams
|
||||
- core: support extraction of embedded mov_text subtitles in mp4 video files
|
||||
- refiners: drone: add http:// to url if not given
|
||||
- providers: opensubtitles: retry/reinitialize request when encountering ResponseNotReady
|
||||
- config: clarify subscene being only enabled for TV series by default
|
||||
- menu: when encountering permission errors when scanning media files, warn in the menu about them
|
||||
- submod: common: don't break -- addic7ed --
|
||||
- submod: common: remove lines that consist only of dash, underscore
|
||||
- submod: OCR: fix Ls = Is
|
||||
- submod: OCR: fix bad HI colons (ANNOUNCER; instead of ANNOUNCER:)
|
||||
- submod: common: fix lines consisting only of bad music symbols (*#¶ = ♪)
|
||||
- submod: HI: remove music-symbol-only-lines
|
||||
- submod: HI: be less aggressive about lines ending with a colon; please re-apply all your mods via advanced menu
|
||||
- submod: OCR: fix it'sjust, isn'tjust, Iam, Ican
|
||||
|
||||
|
||||
2.5.0.2247
|
||||
- fix ignoring by-hash-matched episodes
|
||||
|
||||
|
||||
2.5.0.2241
|
||||
|
||||
- fix issue when removing crap from filenames to not accidentally remove release group #436
|
||||
- fix initialization of soft ignore list after upgrade fron 2.0
|
||||
|
||||
|
||||
2.5.0.2221
|
||||
|
||||
- refiners: add support for retrieving original filename from
|
||||
- drone derivates: sonarr, radarr
|
||||
- filebot
|
||||
- symlinks
|
||||
- file_info meta file lists (see wiki)
|
||||
|
||||
- providers: add subscene (disabled by default to not flood subscene on release)
|
||||
- normal search
|
||||
- season pack search if season has concluded
|
||||
|
||||
- core: add provider subtitle-archive/pack cache for retrieving single subtitles from previously downloaded (season-) packs (subscene)
|
||||
- core/agent: massive performance improvements over 2.0
|
||||
- core/agent/background-tasks: reduce memory usage to a fraction of 2.0
|
||||
- core/providers: add dynamic provider throttling when certain events occur (ServiceUnavailable, too many downloads, ...), to lighten the provider-load
|
||||
- core/agent/config: automatically extract embedded subtitles (and use them if no current subtitle)
|
||||
- core: fix internal subtitle info storage issues
|
||||
- core: always store internal subtitle information even if no subtitle was downloaded (fixes SearchAllRecentlyAddedMissing)
|
||||
- core: fix internal subtitle info storage on windows (gzip handling is broken there)
|
||||
- core: don't fail on missing logfile paths
|
||||
- core: fix default encoding order for non-script-serbian
|
||||
- core: improve logging
|
||||
- core: add AsRequested to cleanup garbage names
|
||||
- core: treat SDTV and HDTV the same when searching for subtitles
|
||||
- core: parse_video: trust PMS season and episode numbers
|
||||
- core: parse_video: add series year information from PMS if none found
|
||||
- core: upgrade dependencies
|
||||
- core: update subliminal to 62cdb3c
|
||||
- core: add new file based cache mechanism, rendering DBM/memory backends obsolete
|
||||
- core: treat 23.980 fps as 23.976 and vice-versa
|
||||
- core: add HTTP proxy support for querying the providers (supports credentials)
|
||||
- core: only compute file hashes for enabled providers
|
||||
- core: massive speedup; refine only when needed, exit early otherwise
|
||||
- core: store last modified timestamp in subtitle info storage
|
||||
- core: only write to subtitle info storage if we haven't had one or any subtitle was downloaded
|
||||
- core: only clean up the sub-folder if a subtitle-sub-folder has been selected, and not the parent one also
|
||||
- core: support for CP437 encoded filenames in ZIP-Archives
|
||||
- core: use scandir library instead of os.listdir if possible, reducing performance-impact
|
||||
- core: archives: support multi-episode subtitles (partly)
|
||||
- core: subtitle cleanup: add support for hi, cc, sdh secondary filename tags; don't autoclean .txt
|
||||
- core: increase request timeout by three times in case a proxy is being used
|
||||
- core: fix language=Unknown in Plex when "Restrict to one language"-setting is set
|
||||
- core: refining: re-add old detected title as alternative title after re-refining with plex metadata's title; fixes #428
|
||||
- core: implement advanced_settings.json (see advanced_settings.json.template for reference, copy to "Plug-in Support/Data/com.plexapp.agents.subzero" to use it)
|
||||
- core/tasks: fix search all recently added missing (the total number of items will change in the menu while running), reduces memory usage
|
||||
- core/menu: add support for extracting embedded subtitles using the builtin plex transcoder
|
||||
- core/menu: skip wrong season or episode in returned subtitle results
|
||||
- core/config: fix language handling if treat undefined as first language is set
|
||||
- providers: remove shooter.cn
|
||||
- providers: add support for zip/rar archives containing more than one subtitle file
|
||||
- submod: common: remove redundant interpunction ("Hello !!!" -> "Hello!")
|
||||
- submod: skip provider hashing when applying mods
|
||||
- submod: correctly drop empty line (fixing broken display)
|
||||
- submod: OCR: fix F'xxxxx -> Fxxxxx
|
||||
- submod: HI: improve bracket matching
|
||||
- submod: OCR: fix l/L instead of I more aggressively
|
||||
- submod: common: fix uppercase I's in lowercase words more aggressively
|
||||
- submod: HI: improve HI_before_colon
|
||||
- submod: common: be more aggressive when fixing numbers; correctly space out spaced ellipses; don't break spaced ellipses; handle multiple spaces in numbers
|
||||
- menu: add support for extracting embedded subtitles for a whole season
|
||||
- menu: add reapply mods to current subtitle
|
||||
- menu: pad titles for more submenus, resulting in detail view in PlexWeb
|
||||
- menu: add subtitle selection submenu (if multiple subtitles are inside the subtitle info storage; e.g. previously downloaded ones or extracted embedded)
|
||||
- menu: advanced: add skip findbettersubtitles menu item, which sets the last_run to now (for debugging purposes)
|
||||
- menu: ignore: add more natural title for seasons and episodes (kills your old ignore lists!)
|
||||
- config: skip provider hashing on low impact mode
|
||||
- config: add limit by air date setting to consider for FindBetterSubtitles task (default: 1 year)
|
||||
- advanced settings: define enabled-for media types per provider
|
||||
- advanced settings: define enabled-for languages per provider
|
||||
- advanced settings: add deep-clean option (clean up the subtitle-sub-folder and the parent one)
|
||||
|
||||
|
||||
|
||||
2.0.33.1871
|
||||
- core: normalize line endings in subtitles to LF (\n)
|
||||
- core: add subtitle storage lock to avoid race condition
|
||||
- core: be more verbose about subtitle storage addition
|
||||
- core: fix MPL2 newline parsing, which resulted in broken subtitles
|
||||
- core: encoding change: reduce log spam
|
||||
- submod: common: fix CM_starting_spacedots
|
||||
- opensubtitles: fix request/response handling
|
||||
|
||||
|
||||
|
||||
2.0.33.1849
|
||||
- opensubtitles: add VIP server handling + preference; VIP benefits: 10€/year, ad-free subs, 1000 subs/day, no-cache VIP server, help SZ and subscribe via http://v.ht/osvip
|
||||
- opensubtitles: try to reuse previous token instead of logging in every time
|
||||
- core: add throttling between searches (10 seconds)
|
||||
- core: fix IETF handling for good
|
||||
- core: fix no subtitles being searched in certain situations (when an external subtitle without special tag exists)
|
||||
- core: add subtitle blacklist
|
||||
- core: fixes
|
||||
- core: fix detection of certain PMS media stream language tags ("FR" for example)
|
||||
- core: missing subtitles: correctly skip unwanted subtitle extensions
|
||||
- core: missing subtitles: honor "treat undefined as first language" option correctly
|
||||
- api: add blacklisting endpoints for quickly searching for new subtitls via bookmarklet
|
||||
- submod: colors: apply color mods at the end of processing modifications; fix color mods
|
||||
- submod: new remove_tags modification to remove all styling tags from subtitles
|
||||
- submod: HI: be more aggressive at handling brackets
|
||||
- submod: OCR: update en and hrv
|
||||
- submod: common: remove "torrent downloaded from ..." lines
|
||||
- submod: OCR: fix WholeWord handling, improving modification
|
||||
- submod: apply OCR fixes before HI
|
||||
- submod: OCR: fix broken HI tag colons (ANNOUNCER'. instead of ANNOUNCER:)
|
||||
- menu: advanced: speed up batch modifications
|
||||
- menu: add subtitle blacklist
|
||||
- menu: recently played: show only TV episodes and movies (music tracks were listed here as well)
|
||||
|
||||
|
||||
2.0.29.1767
|
||||
- core: fix internal subtitle storage issues
|
||||
- core: handle "embedded-forced" tag (futureproofing)
|
||||
- core: remove more garbage tags from release groups (nzbgeek, chamele0n, buymore, xpost, postbot)
|
||||
- submod: OCR fix: fix music icon = paragraph
|
||||
|
||||
|
||||
2.0.29.1756
|
||||
- core: don't fail on uppercase file extensions
|
||||
- core: don't re-download a subtitle if we already downloaded one, it still physically exists and external subtitles are configured to be ignored
|
||||
- core: fix VTT subtitle duplication
|
||||
- core: if forced subtitles not explicitly wanted, ignore existing forced subtitles when searching
|
||||
- core: add full IETF language support for `Treat languages with country attribute as ISO 639-1 (e.g. don't download pt-BR if pt subtitle exists)`-setting for embedded subtitles
|
||||
- menu: remove buggy dynamic permission-based channel icon introduced in 1715
|
||||
- menu: improve `Items with missing subtitles` menu usage and item display
|
||||
- menu: `Advanced -> Get my logs` handle custom domains without port
|
||||
- menu: correctly show country/script part of languages with such attributes (e.g. pt-BR)
|
||||
- config: rename `Scan:` settings; make them better understandable and translatable
|
||||
- config: rephrase IETF options as "languages with country attribute" (e.g. pt-BR)
|
||||
- config: separate IETF options into how to display languages with country attribute and how they should be handled when searching/scanning (e.g. pt-BR)
|
||||
- config: `Scheduler: Item age to be considered recent` now can go up to 12 weeks
|
||||
- config: `Scheduler: Periodically search for recent items with missing subtitles` added `every 2 hours`
|
||||
- submod: swe: add Ĺ to Å
|
||||
|
||||
|
||||
2.0.26.1715
|
||||
- core: submod: OCR fixes: swe: replace ĺ with å inside words
|
||||
- core: fix handling of non-existant PMS audio_codec info
|
||||
- core: filename matching ignored the strictness setting in certain global directory configurations (thanks @raduc)
|
||||
- core: don't fail on migration errors
|
||||
- provider titlovi: handle multiple subtitles per archive
|
||||
- provider addic7ed: reset default boost to 19 (was 21)
|
||||
- menu: add warning icon on missing permissions
|
||||
- menu: manual subtitle list sometimes listed duplicates (thanks @andreashoyer)
|
||||
- menu: don't request PMS metadata in item details menu twice
|
||||
- menu: don't fail badly on non existant PMS metadata in item details menu
|
||||
|
||||
|
||||
2.0.26.1695
|
||||
## ATTENTION: THIS RELEASE RESETS YOUR CONFIGURED LANGUAGES TO DEFAULT!
|
||||
- core: fix bug that caused SZ not to work for Windows users with special characters in their username
|
||||
- core: fix issues when logging failed manual download actions
|
||||
- core: update guessit to 2.1.4
|
||||
- core: fix issue causing the background task scheduler to stop after changing preferences
|
||||
- core: fix polish encoding (try windows-1250 first, then iso 8859-2)
|
||||
- core: remove subscenter provider as it now uses captchas
|
||||
- core: add titlovi as default provider (thanks viking!)
|
||||
- core: increase default PMS API request timeout to 15 (old: 10, max: 45); add preference for that
|
||||
- core: re-add separate legacy FindMissingSubtitles task and run it on the first run to prime SZ's internal subtitle storage
|
||||
- core: add "low impact mode" for people with remote filesystems (currently enabled for List LANGUAGE subtitles in detail menu); alleviates certain plexweb timeout issues
|
||||
- menu: change naming of find missing subtitles menu item
|
||||
- legendastv: fix multi value guessit issues
|
||||
- submod: OCR: update eng and hrv OCR replace dictionaries; fix ". L am huge"
|
||||
|
||||
|
||||
2.0.25.1635
|
||||
- core: update memory handling, possibly reduce memory problems of 2.0
|
||||
- core: support for MPL2 subtitle format
|
||||
- core: update task handling
|
||||
- core: re-enable NVIDIA SHIELD support by fixing rarfile behaviour
|
||||
- core: add SZ_UNRAR_TOOL environment variable for custom unrar location
|
||||
- core: disable SZ when no providers are enabled
|
||||
- core: only start activity monitor if channel or agent are enabled
|
||||
- core: improve custom provider integration
|
||||
- core: update eastern european encoding detection (especially Romanian)
|
||||
- tasks: reduce provider stress by introducing wait times between searches/downloads
|
||||
- windows: correctly ship UnRAR.exe
|
||||
- windows: skip DBM checks
|
||||
- addic7ed: fix Nip/Tuck
|
||||
- subscenter: use new domain
|
||||
|
||||
|
||||
2.0.24.1581
|
||||
- legendastv: ship unrar.exe for Windows users (fixes unrar issues)
|
||||
- addic7ed: fix TooManyRequests error
|
||||
- submod: OCR fixes NL: add custom dictionary data for malformed characters
|
||||
- submod: OCR fixes: update hrv/NL dictionaries
|
||||
- submod: common: remove spaces before punctuation
|
||||
- podnapisi: now returns more subtitles again
|
||||
ATTENTION: Sub-Zero is still broken on PMS for SHIELD. Help needed!
|
||||
|
||||
|
||||
2.0.24.1565
|
||||
- core: fix searchallrecentlymissing task erroring if item not found
|
||||
- core: fix non-plex-items appearing in and crashing the recently played list
|
||||
- core: add hybrid-plus activity setting (current media file and next episode)
|
||||
- podnapisi: fix by using correct guessit parameters
|
||||
|
||||
|
||||
2.0.24.1558
|
||||
- core: fix handling of broken RAR files from legendas
|
||||
|
||||
|
||||
2.0.24.1555
|
||||
- core: fix rare microdvd issue from OpenSubtitles by generally providing FPS info when encountering a microdvd subtitle
|
||||
|
||||
|
||||
2.0.24.1549
|
||||
Changes from 1.4
|
||||
- wiki: new wiki! (thanks @dane22!)
|
||||
- core: update subliminal to version 2
|
||||
- core: update all dependencies
|
||||
- core: add new providers: legendastv (pt-BR), napiprojekt (pl), shooter (cn), subscenter (heb)
|
||||
- core: rewritten all subliminal patches for version 2
|
||||
- core: use SSL again for opensubtitles
|
||||
- core: improved matching due to subliminal 2 (and SZ custom) tvdb/omdb refiners
|
||||
- core: improved matching by relying on existing metadata provided by the PMS
|
||||
- core: improved performance due to multithreaded provider-querying
|
||||
- core: improved performance due to less physical media file access (no more MKV metadata scanning)
|
||||
- core: VTT subtitle format output supported (for Chromecast)
|
||||
- core: rewrote and streamlined internal subtitle data storage format
|
||||
- core: support Cyrillic and Latin variants of Serbian language
|
||||
- core: simplified (custom) provider registration; add own provider registry
|
||||
- core: rewrote recently added missing task
|
||||
- core: automatically fix badly (re-) encoded unicode entities in subtitles
|
||||
- core: always store subtitles in proper UTF-8 encoding
|
||||
- core: add periodic internal subtitle data storage cleanup task
|
||||
- core: on non-windows systems, utilize a file-based cache database for provider media lists and subliminal refiner results
|
||||
- core: add manual and automatic subtitle modification framework (fix common OCR issues, remove hearing impaired etc.)
|
||||
- core: relieve some stress on providers by providing better fine-grained retry handling
|
||||
- menu: add icons for menu items; update main channel icon
|
||||
- menu: add subtitle modifications (subtitle content fixes, offset-based shifting, framerate conversion)
|
||||
- menu: add recently played menu
|
||||
- menu: add "Get my logs" function to the advanced menu, which zips up all necessary logs suitable for posting in the forums
|
||||
- menu: add generic "back to season" and "back to series" entries to item detail views to make navigation easier
|
||||
- config: all scores changed (defaults updated)
|
||||
- config: remove "Force UTF-8 when storing subtitles" (it's now always implied)
|
||||
- improve almost everything Sub-Zero did in 1.4 :)
|
||||
|
||||
|
||||
2.0.23.1464 RC10.1
|
||||
- core: huge bugfix; please check `Library/Application Support/Plex Media\ Server/Plug-in Support/Data/com.plexapp.agents.subzero/DataItems`
|
||||
for any `subs_XXXXX.json.gz` file bigger than 500kb and delete them
|
||||
|
||||
|
||||
2.0.23.1456 RC10
|
||||
- core: findBetterSubtitles: increase series cutoff by 2 (resolution match)
|
||||
- core: add VTT format
|
||||
- core: fix crashes regarding DBM/cache management
|
||||
- core: update rarfile.py
|
||||
- core: add missing encodings
|
||||
- core: full support for Serbian subtitles (Cyrillic and Latin)
|
||||
- podnapisi: fix pt-BR, srp-cyrl and srp-latn
|
||||
- core: implement own provider registry and ditch the subliminal one
|
||||
- core: use ftfy library to fix re-encoding errors inside subtitles introduced by the subtitle author
|
||||
- core: always store and save subtitles normalized to UTF-8
|
||||
- core: replace spaced dashes in movie/series names before re-refining with plex metadata info
|
||||
- submod: remove_HI: handle multiline brackets correctly
|
||||
|
||||
|
||||
2.0.20.1364 RC9
|
||||
- core: performance improvements
|
||||
- core: if info couldn't be guessed from the filename, fill missing info from PMS #270
|
||||
- submod: OCR: add more to the eng dictionary
|
||||
- submod: HI: fixed some issues with font style tags
|
||||
- core: don't ignore subtitles from providers that don't have hearing impaired info, when hearing impaired mode is set to "force non-HI"
|
||||
- legendastv/menu: fix manual subtitle selection issues in menu
|
||||
- core: improve specials matching on OpenSubtitles
|
||||
- core: update guessit
|
||||
|
||||
|
||||
2.0.19.1337 RC8
|
||||
- napiprojekt: fixed: couldn't convert microdvd to SRT in certain occasions
|
||||
- core: when normalize to UTF-8 is enabled, also store the subtitle in UTF-8 encoding in the internal storage
|
||||
- core: add more encodings for western/eastern/northern europe
|
||||
- submod: OCR: update dictionaries from SubtitleEdit
|
||||
- submod: common: be smarter about uppercase i's in words that should have lowercase L's
|
||||
- submod: fix unopened/unclosed font style tags after modification
|
||||
- core: re-enable OMDB support
|
||||
- core: update guessit for better matching
|
||||
- core: fix SearchAllRecentlyMissing (was broken since RC3)
|
||||
|
||||
|
||||
2.0.19.1299 RC7
|
||||
- submod: offset mods now get merged internally when applied multiple times (to avoid errors and increase performance)
|
||||
- submod: improve performance
|
||||
- submod: core mods (OCR, common, remove_HI) now are always applied in a fixed order internally, regardless of the order they were added in
|
||||
- submod: CM_spaces_in_numbers: don't break up ellipses (30... 29... 28...)
|
||||
- submod: CM_spaces_in_numbers: don't fix countdown numbers (30, 29, 28)
|
||||
- submod: remove_HI: make bracket removal more aggressive
|
||||
- submod: remove_HI: be less aggressive when removing text-before-colon
|
||||
- submod: remove_HI: remove all-uppercase-before-sentence (THIS IS ALL UPPERCASE And here starts a sentence -> And here starts a sentence)
|
||||
- submod: fix all character ranges to include non-ASCII characters
|
||||
- add new README for 2.0
|
||||
|
||||
|
||||
2.0.19.1267 RC6
|
||||
- core: add new SZ subtitle storage format
|
||||
- smaller data files and less cumbersome
|
||||
- it will auto migrate when old data is accessed - to speed this up, use "Trigger subtitle storage migration (expensive)" in advanced menu)
|
||||
- core: performance optimizations
|
||||
- addic7ed: when release group matches, assume the format matches, too (leftover change from RC5)
|
||||
- submod: fix patterns for beginlines/endlines
|
||||
- submod: add our own dictionaries to OCR fixes (english)
|
||||
- submod: hearing impaired: also remove full-caps with punctuation inside
|
||||
- submod: correctly handle partiallines
|
||||
- submod: in numbers with spaces (incorrect), also allow for some punctuation (,.:')
|
||||
|
||||
|
||||
2.0.18.1245 RC5
|
||||
- core: add more debug info
|
||||
- core: fix subtitle modifications (was broken in RC4, created non-usable subtitles)
|
||||
- submod: add ANSI colors
|
||||
- menu/submod: add color mod menu
|
||||
- submod: exclusive mods now are mutually exclusive and get cleaned on duplicate
|
||||
- menu/core: naming
|
||||
|
||||
For everyone who runs RC4: your subtitles are broken. Go to the advanced menu and trigger `Re-Apply mods of all stored subtitles` to fix them.
|
||||
|
||||
|
||||
2.0.17.1234 RC4
|
||||
- core: backport provider-download-retry implementation
|
||||
- core: implement custom user agent (for OpenSubtitles)
|
||||
- core/menu: correct handling of media with multiple files
|
||||
- core: fix SearchAllRecentlyMissing; also wait 5 seconds between searches
|
||||
- core: SearchAllRecentlyMissing: honor physical ignores
|
||||
- submod: pattern fixes
|
||||
- submod: better unicode handling
|
||||
- submod: add color mod (only automatic by now)
|
||||
|
||||
|
||||
2.0.15.1216 RC3
|
||||
- core: fixes
|
||||
- scheduler: revert some of the aggressive changes in RC2
|
||||
- submod: be smarter about WholeLine matches
|
||||
|
||||
|
||||
2.0.15.1209 RC2
|
||||
- core: fixes
|
||||
- core: submod-common: fix multiple dots at start of line
|
||||
- core/menu: add subtitle modification debug setting
|
||||
- core/menu: when manually listing available subtitles in menu, display those with wrong FPS also (opensubtitles), because you can fix them later
|
||||
- core/menu: advanced-menu: add apply-all-default-mods menu item; add re-apply all mods menu item
|
||||
- core: always look for currently (not-) existing subtitles when called; hopefully fixes #276
|
||||
- scheduler/menu: be faster; also launch scheduled tasks in threads, not just manually launched ones
|
||||
- core: don't delete subtitles with .custom or .embedded in their filenames when running auto cleanup, if the correct media file exists
|
||||
- menu: add back-to-previous menu items
|
||||
|
||||
|
||||
2.0.12.1180 RC1
|
||||
- core: update subliminal to version 2
|
||||
- core: update all dependencies
|
||||
- core: add new providers: legendastv (pt-BR), napiprojekt (pl), shooter (cn), subscenter (heb)
|
||||
- core: rewritten all subliminal patches for version 2
|
||||
- menu: add icons for menu items; update main channel icon
|
||||
- core: use SSL again for opensubtitles
|
||||
- core: improved matching due to subliminal 2 (and SZ custom) tvdb/omdb refiners
|
||||
- menu: add "Get my logs" function to the advanced menu, which zips up all necessary logs suitable for posting in the forums
|
||||
- core: on non-windows systems, utilize a file-based cache database for provider media lists and subliminal refiner results
|
||||
- core: add manual and automatic subtitle modification framework (fix common OCR issues, remove hearing impaired etc.)
|
||||
- menu: add subtitle modifications (subtitle content fixes, offset-based shifting, framerate conversion)
|
||||
- menu: add recently played menu
|
||||
- improve almost everything Sub-Zero did in 1.4 :)
|
||||
|
||||
|
||||
1.4.27.973
|
||||
- core: ignore "obfuscated" and "scrambled" tags in filenames when searching for subtitles
|
||||
- core: exotic embedded subtitles are now also considered when searching (and when the option is enabled); fixes #264
|
||||
|
||||
|
||||
1.4.27.967
|
||||
- core: remember the last 10 played items; only consider on_playback for "playing" state within the first 60 seconds of an item
|
||||
|
||||
|
||||
1.4.27.965
|
||||
- core: on_playback activity bugfixes
|
||||
|
||||
|
||||
1.4.27.957
|
||||
- core: correctly fall back to the next best subtitle if the current one couldn't be downloaded; hopefully fixes #231
|
||||
- core: add "Scan: which external subtitles should be picked up?"-setting
|
||||
- core: add optional on_playing activities. refresh currently playing movie, refresh next episode in season, both or none; fixes #259 #33
|
||||
- core: skip to next best subtitle if findbettersubtitles failed
|
||||
- core: add setting to treat undefined-language embedded subtitle as configured language1 #239
|
||||
- core: fix handling of inexistant addic7ed show id
|
||||
- core: fix regression issue breaking relative custom subtitle folder handling
|
||||
- core: fix loading of stored subtitle info data of now-non-existant items
|
||||
- core: re-add separate global subtitle folder handling
|
||||
- menu: remove obsolete actions from the advanced menu
|
||||
|
||||
|
||||
1.4.23.920
|
||||
- core: handle undecodable paths better #255
|
||||
- core: don't fail on unrecoverable data #257
|
||||
- core: increase default scores from 110 (series) and 23 (movies) to 116 and 33
|
||||
- core: fix global subtitle folder handling #234
|
||||
- core: better invoking of configured executable after subtitle addition #247
|
||||
|
||||
|
||||
1.4.22.908
|
||||
- core: hotfix for more robust migrations
|
||||
|
||||
|
||||
1.4.22.898
|
||||
- core: migrate history and subtitle storage to a better implementation, making it far more stable. subtitle storage now also stores the downloaded subtitle data for future usage, so it will be possible to switch between them
|
||||
- core/menu: manual subtitle download and the FindBetterSubtitles-task now also work with metadata storage (hi @ shield users)
|
||||
- core: optimize FindBetterSubtitles-task
|
||||
|
||||
|
||||
1.4.19.882
|
||||
- core: fix tasks for new users
|
||||
- core: double check pin correctness/existance when pin is enabled
|
||||
|
||||
|
||||
1.4.19.878
|
||||
- core/menu: fix a task's last runtime display
|
||||
- core: task optimizations
|
||||
- core: fix leftover subtitles cleanup handling in case of a custom subtitle folder #234
|
||||
- core: run the scheduler even if permissions for libraries are wrong ("fixes" #236)
|
||||
- core: store subtitle history data in a different data format; reduce used storage size drastically (#233)
|
||||
|
||||
|
||||
1.4.19.866
|
||||
- core: fix wrong usage of LogKit
|
||||
|
||||
|
||||
1.4.19.857
|
||||
|
||||
- core: add option to enable/disable channel and/or agent modes (fixes #220)
|
||||
- core: skip inexistent internal streams when scanning for internal subtitles (fixes #222)
|
||||
- core: fix filename encoding (fixes #223)
|
||||
- core: storage optimizations
|
||||
- menu: add pin-based channel menu locking (the whole channel or only the advanced menu)
|
||||
|
||||
|
||||
1.4.17.836
|
||||
- core: support for any media file that PMS supports (internal subtitles on mp4 for example)
|
||||
- core: fix broken ignore folders containing "subzero.ignore/.subzero.ignore/.nosz"
|
||||
- core: fix duplicate subtitles (lowercase/default case)
|
||||
- core: fix broken tasks queue due to oversight
|
||||
|
||||
|
||||
1.4.16.822
|
||||
- menu: add per-section recently added menu
|
||||
- menu: fix accidentally double-triggering a just triggered force-refresh
|
||||
- core: reorder settings in a more logical, grouped way
|
||||
- core: add simple automatic filesystem/external leftover subtitle cleaning (#133, #152)
|
||||
- core: fix force-refresh for big seasons/series
|
||||
- core: add setting to look for forced/foreign-only subtitles only (only works for opensubtitles and podnapisi)
|
||||
- core: fix custom subtitle folder was being ignored (#211)
|
||||
- core: only trust PMS for its movie name, not the series title (fixes #210)
|
||||
- core: full support (in filesystem/external mode) for forced/default/normal subtitle tags
|
||||
- core: ignore "non-standard" external subtitle files when scanning by default (everything but .srt, .ass, .ssa, fixes #192)
|
||||
- core: lower default max_recent_items_per_library to 500
|
||||
- core: skip forced/foreign-only subtitles if not specifically wanted
|
||||
- core: modify the task queue, hopefully helping #206
|
||||
- core: update anonymous usage collection
|
||||
|
||||
|
||||
1.4.11.781
|
||||
- core: cleanup, logging
|
||||
- core/menu: fix addic7ed display in manual subtitle list
|
||||
- core: use HTTP for OpenSubtitles instead of HTTPS because of current certificate errors
|
||||
- core: find better subtitles should now run smoothly even with replaced files (newer parts)
|
||||
|
||||
|
||||
1.4.10.769
|
||||
- core: hotfix for legacy intent storage regression
|
||||
|
||||
1.4.10.768
|
||||
- core: automatically find better subtitles (configurable)
|
||||
- menu: display how the subtitle was downloaded (auto, manual, auto-better), in history menu
|
||||
- menu/core: correctly handle subtitle list for multiple languages
|
||||
- core: lower minimum series score to list subtitles for to 66
|
||||
- core: better matching of garbage filenames; we trust Plex now for the series name/movie title fully
|
||||
- core: add setting to specifically set the file permissions (chmod)
|
||||
|
||||
|
||||
1.4.5.742
|
||||
- core: fix force-refresh in certain situations
|
||||
- menu: add history
|
||||
- menu: add manual subtitle selection
|
||||
- menu: run Items with missing subtitles in separate thread for big libraries
|
||||
- settings: add history list size option (default: 100)
|
||||
- settings: add new default scores (TV: 110); use input instead of dropdown
|
||||
- settings: increase default missing subtitles amount per library to 2000
|
||||
- core: generic rewrites and optimizations
|
||||
- core: better hash verification
|
||||
- core: add anonymous usage data (opt-out in settings)
|
||||
- core: fix pt-BR display (IETF) again
|
||||
- wiki: update (thanks @dane22!) - quick URL: http://v.ht/szwiki
|
||||
- wiki: add score explanation - quick URL: http://v.ht/szscores
|
||||
- core: add persian/farsi encoding support
|
||||
|
||||
|
||||
1.3.49.636
|
||||
- core/menu: fix force refreshing (again)
|
||||
- core/menu: fix redundant route calls
|
||||
|
||||
|
||||
1.3.49.630 (backported some changes of the develop-1.4 branch to 1.3)
|
||||
- core/menu: make addic7ed boost configurable; lower the default boost value massively (to 10)
|
||||
- core: fix force refreshing (hopefully)
|
||||
- core: add (thai) tis-620 subtitle encoding support
|
||||
- menu: lower letter based menu browsing from 200 to 80 items
|
||||
- core: support greek encodings (windows-1253, cp1253, cp737, iso8859_7, cp875, cp869, iso2022_jp_2, mac_greek); hopefully fixes badly saved greek subs
|
||||
- menu: add generic back-to-home button to the top of every container view
|
||||
- menu: warn the user when SZ isn't enabled for any sections/libraries
|
||||
- menu: always re-check permissions status and enabled sections when opening the main menu; no server restart necessary anymore
|
||||
|
||||
|
||||
1.3.46.606
|
||||
- core: hotfix for new users (who've never downloaded a subtitle with SZ before); fixes #169
|
||||
|
||||
|
||||
1.3.46.605
|
||||
|
||||
- add wiki (thanks @ukdtom / @dane22)
|
||||
- core: remove necessity of Plex credentials; fixes #148
|
||||
- core: fix non-SRT subtitle support; fixes #138
|
||||
- core: generic source overhaul in preparation for release 1.4
|
||||
- core: better filesystem encoding detection; may fix #159
|
||||
- core: add encoding handling for windows-1250 and windows-1251 encoding (eastern europe); fixes #162
|
||||
- core: overhaul ignore handling; fixes #164
|
||||
- core: implement ignore by path setting; fixes #134
|
||||
- core: add setting for optional fallback to metadata storage, if filesystem storage failed; fixes #100
|
||||
- core: add setting for notifying an executable after a subtitle has been downloaded (see Wiki); fixes #65
|
||||
- core: only handle sections for which Sub-Zero is enabled (in PMS agent settings); fixes #167
|
||||
- menu: add series/season force-refresh
|
||||
- menu: show item thumbnail/art where applicable
|
||||
- menu: mitigate PlexWeb behaviour of calling our handlers twice; fixes #168
|
||||
|
||||
|
||||
1.3.33.522
|
||||
|
||||
- core: fix library permission detection on windows; fixes #151
|
||||
- core: "Restrict to one language" now behaves like it should (one found subtitle of any language is treated as sufficient); fixes #149
|
||||
- core: add support for other subtitle formats such as ssa/ass/microdvd, convert to srt; fixes #138
|
||||
- core: hopefully more consistent force-refresh handling (intent); fixes #118
|
||||
|
||||
|
||||
1.3.31.513
|
||||
|
||||
- core: add option to only download one language again (and skip the addition of .lang to the subtitle filename) (default: off); fixes #126
|
||||
- core: add option to always encode saved subtitles to UTF-8 (default: on); fixes #128
|
||||
- core: add fallback encoding detection using bs4.UnicodeDammit; hopefully fixes #101
|
||||
- core: update libraries: chardet, beautifulsoup, six
|
||||
- menu/core: check Plex libraries for permission problems on plugin start and report them in the channel menu (option, default: on); fixes #143
|
||||
- menu: while a manual refresh takes place, add a refresh button to the top of the SZ menu for convenience
|
||||
- menu: move the "add/remove X to ignore list" menu item to the bottom of the list on item detail
|
||||
|
||||
|
||||
1.3.27.491
|
||||
|
||||
- menu/core: make Sub-Zero channel menu optional (setting: "Enable Sub-Zero channel (disabling doesn't affect the subtitle features)?")
|
||||
- OpenSubtitles: detect and match video/subtitle FPS (framerate) to reduce out of sync subtitle matches
|
||||
- core: internal fixes; add _markerlib library (rare)
|
||||
- core: don't score tvshow episode title matches, should improve episode subtitle matches quite a bit (and reduce out of sync subtitles)
|
||||
- OpenSubtitles: make tag/exact filename matches optional (setting: "I keep the exact (release-) filename of my media files")
|
||||
- menu: unicode video title errors fixed
|
||||
- TVSubtitles: correctly match certain show IDs (such as "Series Name (US)")
|
||||
- core: don't break subtitle evaluation on crashed guessing
|
||||
|
||||
|
||||
1.3.23.459
|
||||
|
||||
- core: slight code cleanup and fixes
|
||||
- core: add physical (filesystem) ignore mode (create files named `subzero.ignore`, `.subzero.ignore`, `.nosz` to ignore specific files/seasons/series/libraries)
|
||||
- core: fix guessit hinting of tv series with rare folder layout (e.g. series_name/a/S01E01.mkv)
|
||||
- core: remove "format" necessity from (opensubtitles) hash-validation
|
||||
- OpenSubtitles: dramatically improve matching: add tag (exact filename) matching and treat it just like hash matches
|
||||
- core: ignore embedded forced subtitles (fixes #106)
|
||||
- docs: update
|
||||
- settings: clarify
|
||||
|
||||
|
||||
1.3.20.422
|
||||
- tvsubtitles: show matching was partially broken
|
||||
- addic7ed: better show matching
|
||||
- core: correctly skip subtitles stored in filesystem if metadata storage was selected (Local Media Assets agent may still pick them up)
|
||||
- core: fix local API access (switch from HTTPS to HTTP)
|
||||
- core: fix handling of library names and media paths with non-ascii chars in it
|
||||
- core: fix bundle version to correctly display current bundle version
|
||||
- core: skip downloading multi-CD subtitle
|
||||
- settings: clarify
|
||||
|
||||
|
||||
1.3.20.403
|
||||
- core: handle & and - ("and" and dash) in names
|
||||
- core: fixed handling of internal metadata subtitles
|
||||
- re-upped the minimum tv score to 85 (may be even higher in the future)
|
||||
- opensubtitles: possibly significantly better movie matching (now also query for movie title, instead of only querying for video hash)
|
||||
|
||||
|
||||
1.3.20.396
|
||||
- core: fix logging handlers (when saving log_level settings loggers got duplicated)
|
||||
- core: better movie matching by only hinting the filename and the last subdirectory to guessit (instead of the full path)
|
||||
- core: don't fail on wrong detection/scanning of media file
|
||||
- lower minimum tv series score from 85 to 67 (removed title; composed of: series=44 + season=11 + episode=11 + hearing_impaired=1)
|
||||
|
||||
|
||||
1.3.19.379
|
||||
- core: new recent items implementation (used in "Items with missing subtitles"), now really picking up everything instead of using Plex's recently_added API endpoint
|
||||
- core: be more strict about title matching - a matched title doesn't automatically mean season and episode are correct, too
|
||||
- core: rewrote the hash matching algorithm to not blindly trust hash matches anymore, but instead episodes have to match the series name, season number, episode number and format (BluRay, HDTV...); movie have to at least match the title, format and codec for the hash to be considered
|
||||
- core: remove TheSubDB support for now, as it only supports hash-based matching
|
||||
- scheduler: more robust item-fail-handling (fixes #81)
|
||||
- config: "Scan: include embedded subtitles" now by default is off, as embedded subs have proven to be pretty unreliable
|
||||
- config: add configuration option for how many items per library are to be considered recent (default: 200)
|
||||
- config: make logging verbosity configurable, default: WARNING - log files should be considerably smaller now
|
||||
- config: make console logging optional, default: off - good for development/debugging
|
||||
- config: removed the ignore lists
|
||||
- menu: added "Browse all items", where you can browse all your libraries and manage your ignore list (add/remove sections/series/items)
|
||||
- menu: added "Display ignore list", where you can manage your ignored sections, series and items
|
||||
- menu: the submenu titles are now dynamically composed of a breadcrumb-style tree so you see where you are
|
||||
- menu: show the current and past state of the important menu actions such as (force)-refresh an item or refreshing the menu, on the Refresh-button's description
|
||||
- plugin now isn't in the dev mode by default and has logging to the console off (in certain configurations this resulted in huge syslogs)
|
||||
|
||||
|
||||
1.3.6.316
|
||||
- scheduler: missing subtitles task now able to handle huge libraries (thanks @chopeta, @comrade)
|
||||
- scheduler: detect item-stalling, add wait and retry logic to make missing subtitles task more robust
|
||||
- scheduler: report failed items to logs after task run completion
|
||||
- hint series name and episode title, or movie title to guessit to make detection way better (e.g. for Mr. Robot)
|
||||
|
||||
1.3.6.304
|
||||
- scheduler: correct the recent-determination of the search for missing subtitles in recently_added task
|
||||
- scheduler: rewrote search for missing subtitles task; it now requests refreshes one by one and not in bulk anymore (hopefully fixes stalling)
|
||||
- handle rare cases of weird file system encodings (ANSI_X3.4-1968 for example)
|
||||
- fix simplejson warning on startup
|
||||
|
||||
1.3.6.297
|
||||
- rename Sub-Zero to Sub-Zero.bundle (requirement for adding Sub-Zero to the Plex channel directory)
|
||||
- channel: add logging actions for the internal storage to the advanced menu
|
||||
- channel: handle item titles with foreign characters in them correctly
|
||||
- (hopefully) fix handling file names with foreign characters in them when scanning for local media
|
||||
- reformat the whole project, mostly honoring pep8
|
||||
- scheduler: fixed some serious bugs; broken tasks (stalled) and some errors many of you have seen should be gone now
|
||||
- scheduler: partly rewritten to be more robust, again
|
||||
- settings: move Plex.tv credentials to the top
|
||||
|
||||
1.3.5.281
|
||||
- fix tasks broken for 1.2 -> 1.3.5 upgraders
|
||||
|
||||
1.3.5.273 (same build as Beta Release 1.3.0.273) - changes from previous stable 1.2.11.180
|
||||
- add a channel menu, making this plugin a hybrid (Agent+Channel)
|
||||
- add a generic background task scheduler
|
||||
- add a task to search for subtitles for items with missing subtitles (manually triggered and automatic)
|
||||
- add artwork
|
||||
- add Plex.tv credentials/token-generation support (needed for Plex Home users for the API to work)
|
||||
- addic7ed: improve show name matching again
|
||||
- channel: able to browse current on-deck and recently-added items, and refresh or force-refresh (search for new subtitles) single items
|
||||
- add library/series/video blacklist for items which should be skipped in "Search for missing subtitles"-task
|
||||
- add donation links
|
||||
- change the license to The Unlicense (while keeping the original MIT license from subliminal.bundle intact)
|
||||
- store subtitle information in internal plugin storage (for later usage)
|
||||
- many internal code improvements
|
||||
- update documentation
|
||||
|
||||
1.3.0.273
|
||||
- more robust update functionality
|
||||
- menu: add refresh button to menu (to see the task state updating)
|
||||
- scheduler: actually skip a task if it's already running
|
||||
- scheduler: better behaviour when a task is running and a single item is refreshed at the same time
|
||||
- menu: enforce ascii on item titles
|
||||
|
||||
1.3.0.261
|
||||
- removed localization again
|
||||
|
||||
1.3.0.259
|
||||
- forgot locale-data
|
||||
|
||||
1.3.0.256
|
||||
- fix force-refresh single items to actually force-refresh
|
||||
- re-add babel library
|
||||
|
||||
1.3.0.253
|
||||
- rewrote background tasks subsystem
|
||||
- keep track of the status of a task and its runtime
|
||||
- add task state in channel menu to "Search for missing subtitles"
|
||||
- add date/time localization to channel menu
|
||||
- hide plex token from logs, when requesting
|
||||
- fix addic7ed show id parsing for shows with year set
|
||||
- test PMS API connectivity and fail miserably if needed (channel disabled, scheduler disabled)
|
||||
- feature-freeze for 1.3.0 final
|
||||
|
||||
1.3.0.245
|
||||
- add the option to buy me a beer
|
||||
- clarify menu items
|
||||
- more robust scheduler handling (should fix the issues of scheduler runs in the past)
|
||||
- internal cleanups
|
||||
- add date_added to stored subtitle info (all of the 1.3.0 testers: please delete your internal subtitle storage using the channel->advanced menu)
|
||||
|
||||
1.3.0.232
|
||||
- integrate plex.tv authentication for plex home users (test phase)
|
||||
- menu cleanup
|
||||
- more info in the menu (scheduler last and next run for example)
|
||||
- hopefully fixed intent handling (should throw less errors now)
|
||||
- fix version display in agent names
|
||||
|
||||
1.3.0.222
|
||||
- bugfix for search missing subtitles
|
||||
- schedduler: honor "never"
|
||||
|
||||
1.3.0.216
|
||||
- add channel menu
|
||||
- add generic task scheduler
|
||||
- add functionality to search for missing subtitles (via recently added items)
|
||||
- add artwork
|
||||
- change license to The Unlicense
|
||||
- ...
|
||||
|
||||
1.2.11.180
|
||||
- fix #49 (metadata storage didn't work)
|
||||
- add better detection for existing subtitles stored in metadata
|
||||
|
||||
1.2.11.177
|
||||
- updated naming scheme to reflect rewrite.major.minor.build (this release is the same as 1.1.0.5)
|
||||
|
||||
1.1.0.5
|
||||
- addic7ed: fixed error in show id search
|
||||
- addic7ed: even better show matching
|
||||
- adjusted default scores: TV: 85, movies: 23
|
||||
- add support for com.plexapp.agents.xbmcnfo/xbmcnfotv (proposed to the author [here](https://github.com/gboudreau/XBMCnfoMoviesImporter.bundle/pull/63) and [here](https://github.com/gboudreau/XBMCnfoTVImporter.bundle/pull/70))
|
||||
|
||||
1.1.0.3
|
||||
- addic7ed/tvsubtitles: be way smarter about punctuation in series names (*A.G.E.N.T.S. ...*)
|
||||
- ditch LocalMediaExtended and incorporate the functionality in Sub-Zero (**RC-users: delete LocalMediaExtended.bundle and re-enable LocalMedia!**)
|
||||
- remove (unused) setting "Restrict to one language"
|
||||
- add "Treat IETF language tags as ISO 639-1 (e.g. pt-BR = pt)" setting (default: true)
|
||||
- change default external storage to "current folder" instead of "/subs"
|
||||
- adjust default scores
|
||||
|
||||
RC-5.2
|
||||
- revert back to /plexinc-agents/LocalMedia.bundle/tree/dist instead of /plexinc-agents/LocalMedia.bundle/tree/master, as the current public PMS version is too old for that
|
||||
|
||||
RC-5.1
|
||||
- make hearing_impaired option more configurable and clear (see #configuration-)
|
||||
|
||||
RC-5
|
||||
- fix wrong video type matching by hinting video type to guessit
|
||||
- update to newest LocalMediaExtended.bundle (incorporated plex-inc's changes)
|
||||
- show page links for subtitles in log file instead of subtitle ID
|
||||
- add custom language setting in addition to the three hardcoded ones
|
||||
- if a subtitle doesn't match our hearing_impaired setting, ignore it
|
||||
- add an optional boost for addic7ed subtitles, if their series, season, episode, year, and format (e.g. WEB-DL) matches
|
||||
|
||||
RC-4
|
||||
- rename project to Sub-Zero
|
||||
- incorporate LocalMediaExtended.bundle
|
||||
- making this a multi-bundle plugin
|
||||
- update default scores
|
||||
- add icon
|
||||
|
||||
RC-3
|
||||
- addic7ed/tvsubtitles: punctuation fixes (correctly get show ids for series like "Mr. Poopster" now)
|
||||
- podnapisi: fix logging
|
||||
- opensubtitles: add login credentials (for VIPs)
|
||||
- add retry functionality to retry failed subtitle downloads, including configurable amount of retries until discarding of provider
|
||||
- move possibly not needed setting "Restrict to one language" to the bottom
|
||||
- more detailed logging
|
||||
- some cleanup
|
||||
|
||||
RC-2
|
||||
- fix empty custom subtitle folder creation
|
||||
- fix detection of existing embedded subtitles (switch to https://github.com/tonswieb/enzyme)
|
||||
- better logging
|
||||
- set default TV score to 15; movie score to 30
|
||||
|
||||
RC-1
|
||||
- fix subliminal's logging error on min_score not met (fixes #15)
|
||||
- separated tv and movies subtitle scores settings (fixes #16)
|
||||
- add option to save only one subtitle per video (skipping the ".lang." naming scheme plex supports) (fixes #3)
|
||||
|
||||
beta5
|
||||
- fix storing subtitles besides the actual video file, not subfolder (fixes #14)
|
||||
- "custom folder" setting now always used if given (properly overrides "subtitle folder" setting)
|
||||
- also scan (custom) given subtitle folders for existing subtitles instead of redownloading them on every refresh (fixes #9, #2)
|
||||
|
||||
beta4
|
||||
- ~~increased score of addic7ed subtitles a bit~~ (not existing currently)
|
||||
- **support for newest Subliminal ([1.0.1](27a6e51cd36ffb2910cd9a7add6d797a2c6469b7)) and guessit ([0.11.0](2814f57e8999dcc31575619f076c0c1a63ce78f2))**
|
||||
- **plugin now also [works with com.plexapp.agents.thetvdbdvdorder](924470d2c0db3a71529278bce4b7247eaf2f85b8)**
|
||||
- providers fixed for subliminal 1.0.1 ([at least addic7ed](131504e7eed8b3400c457fbe49beea3b115bc916))
|
||||
- providers [don't simply fail and get excluded on non-detected language](1a779020792e0201ad689eefbf5a126155e89c97)
|
||||
- support for addic7ed languages: [French (Canadian)](b11a051c233fd72033f0c3b5a8c1965260e7e19f)
|
||||
- support for additional languages: [pt-br (Portuguese (Brasil)), fa (Persian (Farsi))](131504e7eed8b3400c457fbe49beea3b115bc916)
|
||||
- support for [three (two optional) subtitle languages](e543c927cf49c264eaece36640c99d67a99c7da2)
|
||||
- optionally use [random user agent for addic7ed provider](83ace14faf75fbd75313f0ceda9b78161895fbcf) (should not be needed)
|
||||
Regular → Executable
+295
-126
@@ -1,154 +1,323 @@
|
||||
# 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, PERSONAL_MEDIA_IDENTIFIER
|
||||
from interface.menu import *
|
||||
from support.plex_media import media_to_videos, get_media_item_ids
|
||||
from support.scanning import scan_videos
|
||||
from support.storage import save_subtitles, store_subtitle_info, get_subtitle_storage
|
||||
from support.items import is_ignored
|
||||
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
|
||||
|
||||
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)
|
||||
# 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 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
|
||||
# run migrations
|
||||
if "subs" in Dict or "history" in Dict:
|
||||
Thread.Create(dispatch_migrate)
|
||||
|
||||
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
|
||||
# clear old task data
|
||||
scheduler.clear_task_data()
|
||||
|
||||
def scanMovieMedia(media):
|
||||
videos = {}
|
||||
for item in media.items:
|
||||
for part in item.parts:
|
||||
scannedVideo = scanVideo(part)
|
||||
videos[scannedVideo] = part
|
||||
return videos
|
||||
# init defaults; perhaps not the best idea to use ValidatePrefs here, but we'll see
|
||||
ValidatePrefs()
|
||||
Log.Debug(config.full_version)
|
||||
|
||||
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))
|
||||
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))
|
||||
|
||||
# run task scheduler
|
||||
scheduler.run()
|
||||
|
||||
# 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(metadata, media, media_type="movies"):
|
||||
# Look for subtitles
|
||||
if media_type == "movies":
|
||||
for item in media.items:
|
||||
for part in item.parts:
|
||||
support.localmedia.find_subtitles(part)
|
||||
return
|
||||
|
||||
# Look for subtitles for each episode.
|
||||
for s in media.seasons:
|
||||
# If we've got a date based season, ignore it for now, otherwise it'll collide with S/E folders/XML and PMS
|
||||
# prefers date-based (why?)
|
||||
if int(s) < 1900 or metadata.guid.startswith(PERSONAL_MEDIA_IDENTIFIER):
|
||||
for e in media.seasons[s].episodes:
|
||||
for i in media.seasons[s].episodes[e].items:
|
||||
|
||||
# Look for subtitles.
|
||||
for part in i.parts:
|
||||
support.localmedia.find_subtitles(part)
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
def agent_extract_embedded(video_part_map):
|
||||
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")
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
|
||||
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())
|
||||
to_extract = []
|
||||
item_count = 0
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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
|
||||
for plexapi_part in get_all_parts(plexapi_item):
|
||||
item_count = item_count + 1
|
||||
for requested_language in config.lang_list:
|
||||
embedded_subs = stored_subs.get_by_provider(plexapi_part.id, requested_language, "embedded")
|
||||
current = stored_subs.get_any(plexapi_part.id, requested_language)
|
||||
if not embedded_subs:
|
||||
stream_data = get_embedded_subtitle_streams(plexapi_part, requested_language=requested_language,
|
||||
get_forced=config.forced_only)
|
||||
|
||||
if stream_data:
|
||||
stream = stream_data[0]["stream"]
|
||||
|
||||
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"]):
|
||||
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)
|
||||
Thread.Create(multi_extract_embedded, stream_list=to_extract, refresh=True, with_mods=True,
|
||||
single_thread=not config.advanced.auto_extract_multithread)
|
||||
except:
|
||||
Log.Error("Something went wrong when auto-extracting subtitles, continuing: %s", traceback.format_exc())
|
||||
|
||||
|
||||
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))
|
||||
intent = get_intent()
|
||||
|
||||
if not media:
|
||||
Log.Error("Called with empty media, something is really wrong with your setup!")
|
||||
return
|
||||
|
||||
item_ids = []
|
||||
try:
|
||||
config.init_subliminal_patches()
|
||||
videos = media_to_videos(media, kind=self.agent_type)
|
||||
|
||||
# find local media
|
||||
update_local_media(metadata, media, media_type=self.agent_type)
|
||||
|
||||
# media ignored?
|
||||
use_any_parts = False
|
||||
for video in videos:
|
||||
if is_ignored(video["id"]):
|
||||
Log.Debug(u"Ignoring %s" % video)
|
||||
continue
|
||||
use_any_parts = True
|
||||
|
||||
if not use_any_parts:
|
||||
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.Warning("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:
|
||||
item_title = get_title_for_video_metadata(video.plexapi_metadata, add_section_title=False)
|
||||
history = get_history()
|
||||
history.add(item_title, video.id, section_title=video.plexapi_metadata["section"],
|
||||
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(metadata, media, media_type=self.agent_type)
|
||||
|
||||
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']
|
||||
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,391 @@
|
||||
# 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
|
||||
|
||||
|
||||
@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(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(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')
|
||||
def Restart():
|
||||
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 + '/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):
|
||||
storage = get_subtitle_storage()
|
||||
subs_applied = 0
|
||||
for fn in storage.get_all_files():
|
||||
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
|
||||
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 %s" % (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,27 @@
|
||||
# coding=utf-8
|
||||
|
||||
from support.config import config
|
||||
|
||||
|
||||
def enable_channel_wrapper(func):
|
||||
"""
|
||||
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]
|
||||
|
||||
return inner
|
||||
|
||||
def wrap(*args, **kwargs):
|
||||
enforce_route = kwargs.pop("enforce_route", None)
|
||||
return (func if (config.enable_channel or enforce_route) else noop)(*args, **kwargs)
|
||||
|
||||
return wrap
|
||||
@@ -0,0 +1,640 @@
|
||||
# coding=utf-8
|
||||
import os
|
||||
|
||||
from subzero.language import Language
|
||||
|
||||
from sub_mod import SubtitleModificationsMenu
|
||||
from menu_helpers import debounce, SubFolderObjectContainer, default_thumb, add_ignore_options, get_item_task_data, \
|
||||
set_refresh_menu_state, route, 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
|
||||
from support.scanning import scan_videos
|
||||
from support.scheduler import scheduler
|
||||
from support.storage import get_subtitle_storage
|
||||
|
||||
|
||||
# fixme: needs kwargs cleanup
|
||||
|
||||
@route(PREFIX + '/item/{rating_key}/actions')
|
||||
@debounce
|
||||
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 IgnoreMenu
|
||||
|
||||
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 %s, possibly searching for missing and picking up new subtitles on disk" % 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: %s" % 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
|
||||
|
||||
part_id = str(part.id)
|
||||
part_index += 1
|
||||
|
||||
part_index_addon = ""
|
||||
part_summary_addon = ""
|
||||
if has_multiple_parts:
|
||||
part_index_addon = u"File %s: " % part_index
|
||||
part_summary_addon = "%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"%sNo current subtitle in storage" % 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"%sCurrent subtitle: %s (added: %s, %s), Language: %s, Score: %i, Storage: %s" % \
|
||||
(part_summary_addon, current_sub.provider_name, df(current_sub.date_added),
|
||||
current_sub.mode_verbose, display_language(lang), current_sub.score,
|
||||
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"%sManage %s subtitle" % (part_index_addon, 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"%sList %s subtitles" % (part_index_addon, 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)
|
||||
|
||||
if not lang and config.treat_und_as_first:
|
||||
lang = list(config.lang_list)[0]
|
||||
|
||||
if lang:
|
||||
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"%sEmbedded subtitles (%s)" % (part_index_addon, ", ".join(display_language(l) for l in
|
||||
set(embedded_langs))),
|
||||
summary=u"Extract and activate embedded subtitle streams"
|
||||
))
|
||||
|
||||
ignore_title = item_title
|
||||
if current_kind == "episode":
|
||||
ignore_title = get_item_title(item)
|
||||
add_ignore_options(oc, "videos", title=ignore_title, rating_key=rating_key, callback_menu=IgnoreMenu)
|
||||
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 = 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=kwargs["current_data"],
|
||||
thumb=default_thumb
|
||||
))
|
||||
if subs_count:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ListStoredSubsForItemMenu, randomize=timestamp(), **kwargs),
|
||||
title=u"Select active %s subtitle" % kwargs["language_name"],
|
||||
summary=u"%d subtitles in storage" % subs_count
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ListAvailableSubsForItemMenu, randomize=timestamp(), **kwargs),
|
||||
title=u"List available %s subtitles" % kwargs["language_name"],
|
||||
summary=kwargs["current_data"]
|
||||
))
|
||||
if current_sub:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleModificationsMenu, randomize=timestamp(), **kwargs),
|
||||
title=u"Modify current %s subtitle" % kwargs["language_name"],
|
||||
summary=u"Currently applied mods: %s" % (", ".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 %s subtitle and search for a new one" % 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 (%s contained)" % 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: %s, %s, Language: %s, Score: %i, Storage: %s" % \
|
||||
(df(subtitle.date_added),
|
||||
subtitle.mode_verbose, display_language(language), subtitle.score,
|
||||
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"%s%s, Score: %s" % ("Current: " if is_current else "Stored: ", sub_name,
|
||||
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]
|
||||
|
||||
subtitles["current"] = sub_key
|
||||
|
||||
save_stored_sub(subtitle, rating_key, part_id, language, item_type, plex_item=plex_item, storage=storage,
|
||||
stored_subs=stored_subs)
|
||||
|
||||
storage.destroy()
|
||||
|
||||
kwargs.pop("randomize")
|
||||
|
||||
kwargs["header"] = 'Success'
|
||||
kwargs["message"] = 'Subtitle saved to disk'
|
||||
|
||||
return SubtitleOptionsMenu(randomize=timestamp(), **kwargs)
|
||||
|
||||
|
||||
@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_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=kwargs["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"%s, %s (added: %s, %s), Language: " \
|
||||
u"%s, Score: %i, Storage: %s" % (provider_name, subtitle_id, df(data["date_added"]),
|
||||
current_sub.get_mode_verbose(data["mode"]),
|
||||
display_language(Language.fromietf(language)), data["score"],
|
||||
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)
|
||||
@debounce
|
||||
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)
|
||||
|
||||
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(u"by %s" % video.release_group)
|
||||
video_display_data = " ".join(video_display_data)
|
||||
else:
|
||||
video_display_data = metadata["filename"]
|
||||
|
||||
current_display = (u"Current: %s (%s) " % (current_provider, 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 %s subs (%s)" % (get_language(language).name, video_display_data),
|
||||
summary=u"%sFilename: %s" % (current_display, 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"%sFilename: %s" % (current_display, 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 %s subs (%s), refresh here ..." % (display_language(get_language(language)),
|
||||
video_display_data),
|
||||
summary=u"%sFilename: %s" % (current_display, 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 = ""
|
||||
if subtitle.wrong_fps:
|
||||
if plex_part:
|
||||
wrong_fps_addon = " (wrong FPS, sub: %s, media: %s)" % (subtitle.fps, plex_part.fps)
|
||||
else:
|
||||
wrong_fps_addon = " (wrong FPS, sub: %s, media: unknown, low impact mode)" % subtitle.fps
|
||||
|
||||
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"%s%s: %s, score: %s%s" % (bl_addon, "Available" if current_id != subtitle.id else "Current",
|
||||
subtitle.provider_name, subtitle.score, wrong_fps_addon),
|
||||
summary=u"Release: %s, Matches: %s" % (subtitle.release_info, ", ".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 %s" % 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=u"< 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"]
|
||||
|
||||
if language:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(TriggerExtractEmbeddedSubForItemMenu, randomize=timestamp(),
|
||||
stream_index=str(stream.index), language=language, with_mods=True, **kwargs),
|
||||
title=u"Extract stream %s, "
|
||||
u"%s%s%s%s with default mods" % (stream.index, display_language(language),
|
||||
" (unknown)" if is_unknown else "",
|
||||
" (forced)" if is_forced else "",
|
||||
" (\"%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 %s, %s%s%s%s" % (stream.index, display_language(language),
|
||||
" (unknown)" if is_unknown else "",
|
||||
" (forced)" if is_forced else "",
|
||||
" (\"%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, **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,438 @@
|
||||
# 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 ignore_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_ignore_options, \
|
||||
ObjectContainer, route, handler
|
||||
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 %s, folder: %s" % (title, 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 %i 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: %s/%s (%s%%)" % (task.items_done, task.items_searching, 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")
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(IgnoreListMenu),
|
||||
title="Display ignore list (%d)" % len(ignore_list),
|
||||
summary="Show the current ignore list (mainly used for the automatic tasks)",
|
||||
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("%s until %s (%s)" % (provider, until.strftime("%y/%m/%d %H:%M"), 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(
|
||||
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 + '/ignore/set/{kind}/{rating_key}/{todo}/sure={sure}', kind=str, rating_key=str, todo=str, sure=bool)
|
||||
def IgnoreMenu(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:
|
||||
"""
|
||||
is_ignored = rating_key in ignore_list[kind]
|
||||
if not sure:
|
||||
oc = SubFolderObjectContainer(no_history=True, replace_parent=True, title1="%s %s %s %s the ignore list" % (
|
||||
"Add" if not is_ignored else "Remove", ignore_list.verbose(kind), title,
|
||||
"to" if not is_ignored else "from"), title2="Are you sure?")
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(IgnoreMenu, kind=kind, rating_key=rating_key, title=title, sure=True,
|
||||
todo="add" if not is_ignored else "remove"),
|
||||
title=pad_title("Are you sure?"),
|
||||
))
|
||||
return oc
|
||||
|
||||
rel = ignore_list[kind]
|
||||
dont_change = False
|
||||
if todo == "remove":
|
||||
if not is_ignored:
|
||||
dont_change = True
|
||||
else:
|
||||
rel.remove(rating_key)
|
||||
Log.Info("Removed %s (%s) from the ignore list", title, rating_key)
|
||||
ignore_list.remove_title(kind, rating_key)
|
||||
ignore_list.save()
|
||||
state = "removed from"
|
||||
elif todo == "add":
|
||||
if is_ignored:
|
||||
dont_change = True
|
||||
else:
|
||||
rel.append(rating_key)
|
||||
Log.Info("Added %s (%s) to the ignore list", title, rating_key)
|
||||
ignore_list.add_title(kind, rating_key, title)
|
||||
ignore_list.save()
|
||||
state = "added to"
|
||||
else:
|
||||
dont_change = True
|
||||
|
||||
if dont_change:
|
||||
return fatality(force_title=" ", header="Didn't change the ignore list", no_history=True)
|
||||
|
||||
return fatality(force_title=" ", header="%s %s the ignore list" % (title, state), 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_ignore_options(oc, "sections", title=section_title, rating_key=rating_key, callback_menu=IgnoreMenu)
|
||||
|
||||
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_ignore_options(oc, "sections", title=section_title, rating_key=rating_key, callback_menu=IgnoreMenu)
|
||||
|
||||
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,401 @@
|
||||
# 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_ignore_options, dig_tree, set_refresh_menu_state, \
|
||||
default_thumb, debounce, ObjectContainer, SubFolderObjectContainer, route, \
|
||||
extract_embedded_sub
|
||||
from main import fatality, IgnoreMenu
|
||||
from advanced import DispatchRestart
|
||||
from subzero.constants import ART, PREFIX, DEPENDENCY_MODULE_NAMES
|
||||
from support.plex_media import get_all_parts, get_embedded_subtitle_streams
|
||||
from support.scheduler import scheduler
|
||||
from support.config import config
|
||||
from support.helpers import timestamp, df, display_language
|
||||
from support.ignore import ignore_list
|
||||
from support.items import get_all_items, get_items_info, get_item_kind_from_rating_key, get_item, MI_KEY, get_item_title
|
||||
from support.storage import get_subtitle_storage
|
||||
|
||||
# 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_ignore_options(oc, current_kind, title=sub_title, rating_key=rating_key, callback_menu=IgnoreMenu)
|
||||
|
||||
# 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 %s embedded subtitles" % 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 %s embedded subtitles" % 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 %s, possibly searching for missing and picking up new subtitles on disk" % 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)
|
||||
|
||||
|
||||
def multi_extract_embedded(stream_list, refresh=False, with_mods=False, single_thread=True):
|
||||
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)
|
||||
|
||||
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,
|
||||
get_forced=config.forced_only)
|
||||
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)
|
||||
finally:
|
||||
subtitle_storage.destroy()
|
||||
|
||||
|
||||
@route(PREFIX + '/ignore_list')
|
||||
def IgnoreListMenu():
|
||||
oc = SubFolderObjectContainer(title2="Ignore list", replace_parent=True)
|
||||
for key in ignore_list.key_order:
|
||||
values = ignore_list[key]
|
||||
for value in values:
|
||||
add_ignore_options(oc, key, title=ignore_list.get_title(key, value), rating_key=value,
|
||||
callback_menu=IgnoreMenu)
|
||||
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:
|
||||
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))
|
||||
))
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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("Channel features %s, restarting plugin", "enabled" if config.enable_channel else "disabled")
|
||||
update_dict = True
|
||||
restart = True
|
||||
|
||||
if "plugin_pin_mode" not in Dict:
|
||||
update_dict = True
|
||||
|
||||
elif Dict["plugin_pin_mode"] != Prefs["plugin_pin_mode"]:
|
||||
update_dict = True
|
||||
restart = True
|
||||
|
||||
if update_dict:
|
||||
Dict["channel_enabled"] = config.enable_channel
|
||||
Dict["plugin_pin_mode"] = Prefs["plugin_pin_mode"]
|
||||
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", "new_style_cache", "dbm_supported", "lang_list", "providers",
|
||||
"plex_transcoder", "refiner_settings"]:
|
||||
|
||||
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
|
||||
|
||||
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])
|
||||
|
||||
# debug drone
|
||||
if "sonarr" in config.refiner_settings or "radarr" in config.refiner_settings:
|
||||
Log.Debug("----- Connections -----")
|
||||
try:
|
||||
from subliminal_patch.refiners.drone import SonarrClient, RadarrClient
|
||||
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()
|
||||
except HTTPError, e:
|
||||
if e.response.status_code == 401:
|
||||
Log.Debug("%s: NOT WORKING - BAD API KEY", cname)
|
||||
else:
|
||||
Log.Debug("%s: NOT WORKING - %s", cname, traceback.format_exc())
|
||||
except:
|
||||
Log.Debug("%s: NOT WORKING - %s", cname, traceback.format_exc())
|
||||
else:
|
||||
if status and status["version"]:
|
||||
Log.Debug("%s: OK - %s", cname, status["version"])
|
||||
else:
|
||||
Log.Debug("%s: NOT WORKING - %s", cname)
|
||||
except:
|
||||
Log.Debug("Something went really wrong when evaluating Sonarr/Radarr: %s", traceback.format_exc())
|
||||
|
||||
# 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,268 @@
|
||||
# coding=utf-8
|
||||
import traceback
|
||||
import types
|
||||
import datetime
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
from func import enable_channel_wrapper
|
||||
from subzero.language import Language
|
||||
from support.items import get_kind, get_item_thumb, get_item, get_item_kind_from_item, refresh_item
|
||||
from support.helpers import get_video_display_title, pad_title, display_language, quote_args, is_stream_forced
|
||||
from support.ignore import ignore_list
|
||||
from support.lib import get_intent
|
||||
from support.config import config
|
||||
from subzero.constants import ICON_SUB, ICON
|
||||
from support.plex_media import get_part, get_plex_metadata
|
||||
from support.scheduler import scheduler
|
||||
from support.scanning import scan_videos
|
||||
from support.storage import save_subtitles
|
||||
|
||||
from subliminal_patch.subtitle import ModifiedSubtitle
|
||||
|
||||
default_thumb = R(ICON_SUB)
|
||||
main_icon = ICON if not config.is_development else "icon-dev.jpg"
|
||||
|
||||
# noinspection PyUnboundLocalVariable
|
||||
route = enable_channel_wrapper(route)
|
||||
# noinspection PyUnboundLocalVariable
|
||||
handler = enable_channel_wrapper(handler)
|
||||
|
||||
|
||||
def add_ignore_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
|
||||
if kind not in ignore_list:
|
||||
use_kind = ignore_list.translate_key(kind)
|
||||
if not use_kind or use_kind not in ignore_list:
|
||||
return
|
||||
|
||||
in_list = rating_key in ignore_list[use_kind]
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(callback_menu, kind=use_kind, rating_key=rating_key, title=title),
|
||||
title=u"%s %s \"%s\"" % (
|
||||
"Un-Ignore" if in_list else "Ignore", ignore_list.verbose(kind) if add_kind else "", 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
|
||||
return
|
||||
|
||||
if isinstance(state_or_media, types.StringTypes):
|
||||
Dict["current_refresh_state"] = state_or_media
|
||||
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)
|
||||
|
||||
Dict["current_refresh_state"] = u"%sRefreshing %s" % ("Force-" if force_refresh else "", unicode(title))
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
def get_lookup_key(args, kwargs):
|
||||
func_name = list(args).pop(0).__name__
|
||||
return tuple([func_name] + [(key, value) for key, value in kwargs.iteritems()])
|
||||
|
||||
def wrap(*args, **kwargs):
|
||||
if "randomize" in kwargs:
|
||||
if "menu_history" not in Dict:
|
||||
Dict["menu_history"] = {}
|
||||
|
||||
key = get_lookup_key([func] + list(args), kwargs)
|
||||
if key in Dict["menu_history"]:
|
||||
Log.Debug("not triggering %s twice with %s, %s" % (func, args, kwargs))
|
||||
return ObjectContainer()
|
||||
else:
|
||||
Dict["menu_history"][key] = datetime.datetime.now() + datetime.timedelta(hours=6)
|
||||
try:
|
||||
Dict.Save()
|
||||
except TypeError:
|
||||
Log.Error("Can't save menu history for: %r", key)
|
||||
del Dict["menu_history"][key]
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
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)
|
||||
|
||||
any_successful = False
|
||||
|
||||
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)
|
||||
|
||||
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 %s of %s" % (stream_index, bn))
|
||||
Log.Info(u"Extracting stream %s (%s) of %s", stream_index, display_language(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, "-"
|
||||
]
|
||||
output = None
|
||||
try:
|
||||
output = subprocess.check_output(quote_args(args), 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
|
||||
save_successful = save_subtitles(scanned_videos, {scanned_videos.keys()[0]: [subtitle]}, mode="m",
|
||||
set_current=set_current, is_forced=is_forced)
|
||||
set_refresh_menu_state(None)
|
||||
|
||||
if save_successful and refresh:
|
||||
refresh_item(rating_key)
|
||||
|
||||
any_successful = True
|
||||
|
||||
return any_successful
|
||||
|
||||
|
||||
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,23 @@
|
||||
# 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
|
||||
|
||||
|
||||
@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:
|
||||
set_refresh_menu_state(u"Triggering %sRefresh for %s" % ("Force-" if force else "", 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))
|
||||
|
||||
header = u"%s of item %s triggered" % ("Refresh" if not force else "Forced-refresh", rating_key)
|
||||
return fatality(randomize=timestamp(), header=header, replace_parent=True)
|
||||
@@ -0,0 +1,281 @@
|
||||
# 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
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_mods/{rating_key}/{part_id}', force=bool)
|
||||
@debounce
|
||||
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=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: %s" % (", ".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: %s" % (", ".join(current_mods))
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleReapplyMods, randomize=timestamp(), **kwargs),
|
||||
title=pad_title("Reapply applied mods"),
|
||||
summary=u"Currently applied mods: %s" % (", ".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: %s" % (", ".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="%s fps -> %s fps (%s)" % (fps, target_fps, 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 %s" % 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: %s" % 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,66 @@
|
||||
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 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 tasks
|
||||
|
||||
sys.modules["support.tasks"] = tasks
|
||||
|
||||
import ignore
|
||||
|
||||
sys.modules["support.ignore"] = ignore
|
||||
|
||||
import history
|
||||
|
||||
sys.modules["support.history"] = history
|
||||
|
||||
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 == "hybrid":
|
||||
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
|
||||
@@ -0,0 +1,859 @@
|
||||
# coding=utf-8
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
import inspect
|
||||
import sys
|
||||
import rarfile
|
||||
import jstyleson
|
||||
import datetime
|
||||
|
||||
import subliminal
|
||||
import subliminal_patch
|
||||
import subzero.constants
|
||||
import lib
|
||||
from subliminal.exceptions import ServiceUnavailable, DownloadLimitExceeded
|
||||
|
||||
from subliminal_patch.core import is_windows_special_path
|
||||
from whichdb import whichdb
|
||||
|
||||
from subliminal_patch.exceptions import TooManyRequests
|
||||
from subzero.language import Language
|
||||
from subliminal.cli import MutexLock
|
||||
from subzero.lib.io import FileIO, get_viable_encoding
|
||||
from subzero.lib.dict import Dicked
|
||||
from subzero.util import get_root_path
|
||||
from subzero.constants import PLUGIN_NAME, PLUGIN_IDENTIFIER, MOVIE, SHOW, MEDIA_TYPE_TO_STRING
|
||||
from dogpile.cache.region import register_backend as register_cache_backend
|
||||
from lib import Plex
|
||||
from helpers import check_write_permissions, cast_bool, cast_int, mswindows
|
||||
|
||||
register_cache_backend(
|
||||
"subzero.cache.file", "subzero.cache_backends.file", "SZFileBackend")
|
||||
|
||||
SUBTITLE_EXTS_BASE = ['utf', 'utf8', 'utf-8', 'srt', 'smi', 'rt', 'ssa', 'aqt', 'jss', 'ass', 'idx', 'sub', 'psb',
|
||||
'vtt']
|
||||
SUBTITLE_EXTS = SUBTITLE_EXTS_BASE + ["txt"]
|
||||
|
||||
TEXT_SUBTITLE_EXTS = ("srt", "ass", "ssa", "vtt", "mov_text")
|
||||
VIDEO_EXTS = ['3g2', '3gp', 'asf', 'asx', 'avc', 'avi', 'avs', 'bivx', 'bup', 'divx', 'dv', 'dvr-ms', 'evo', 'fli',
|
||||
'flv',
|
||||
'm2t', 'm2ts', 'm2v', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'mts', 'nsv', 'nuv', 'ogm', 'ogv', 'tp',
|
||||
'pva', 'qt', 'rm', 'rmvb', 'sdp', 'svq3', 'strm', 'ts', 'ty', 'vdr', 'viv', 'vob', 'vp3', 'wmv', 'wpl',
|
||||
'wtv', 'xsp', 'xvid',
|
||||
'webm']
|
||||
|
||||
IGNORE_FN = ("subzero.ignore", ".subzero.ignore", ".nosz")
|
||||
|
||||
VERSION_RE = re.compile(ur'CFBundleVersion.+?<string>([0-9\.]+)</string>', re.DOTALL)
|
||||
DEV_RE = re.compile(ur'PlexPluginDevMode.+?<string>([01]+)</string>', re.DOTALL)
|
||||
|
||||
|
||||
def int_or_default(s, default):
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
VALID_THROTTLE_EXCEPTIONS = (TooManyRequests, DownloadLimitExceeded, ServiceUnavailable)
|
||||
|
||||
PROVIDER_THROTTLE_MAP = {
|
||||
"default": {
|
||||
TooManyRequests: (datetime.timedelta(hours=1), "1 hour"),
|
||||
DownloadLimitExceeded: (datetime.timedelta(hours=3), "3 hours"),
|
||||
ServiceUnavailable: (datetime.timedelta(minutes=20), "20 minutes"),
|
||||
},
|
||||
"opensubtitles": {
|
||||
TooManyRequests: (datetime.timedelta(hours=3), "3 hours"),
|
||||
DownloadLimitExceeded: (datetime.timedelta(hours=6), "6 hours"),
|
||||
},
|
||||
"addic7ed": {
|
||||
DownloadLimitExceeded: (datetime.timedelta(hours=24), "24 hours"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Config(object):
|
||||
libraries_root = None
|
||||
plugin_info = ""
|
||||
version = None
|
||||
full_version = None
|
||||
plugin_log_path = None
|
||||
server_log_path = None
|
||||
app_support_path = None
|
||||
data_path = None
|
||||
data_items_path = None
|
||||
universal_plex_token = None
|
||||
plex_token = None
|
||||
is_development = False
|
||||
dbm_supported = False
|
||||
pms_request_timeout = 15
|
||||
low_impact_mode = False
|
||||
new_style_cache = False
|
||||
pack_cache_dir = None
|
||||
advanced = None
|
||||
|
||||
enable_channel = True
|
||||
enable_agent = True
|
||||
pin = None
|
||||
lock_menu = False
|
||||
lock_advanced_menu = False
|
||||
locked = False
|
||||
pin_valid_minutes = 10
|
||||
subtitle_destination_folder = None
|
||||
subtitle_formats = None
|
||||
max_recent_items_per_library = 200
|
||||
permissions_ok = False
|
||||
missing_permissions = None
|
||||
ignore_sz_files = False
|
||||
ignore_paths = None
|
||||
fs_encoding = None
|
||||
notify_executable = None
|
||||
sections = None
|
||||
enabled_sections = None
|
||||
remove_hi = False
|
||||
remove_tags = False
|
||||
fix_ocr = False
|
||||
fix_common = False
|
||||
reverse_rtl = False
|
||||
colors = ""
|
||||
chmod = None
|
||||
forced_only = False
|
||||
exotic_ext = False
|
||||
treat_und_as_first = False
|
||||
subtitle_sub_dir = None, None
|
||||
ext_match_strictness = False
|
||||
default_mods = None
|
||||
debug_mods = False
|
||||
react_to_activities = False
|
||||
activity_mode = None
|
||||
no_refresh = False
|
||||
plex_transcoder = None
|
||||
refiner_settings = None
|
||||
exact_filenames = False
|
||||
only_one = False
|
||||
embedded_auto_extract = False
|
||||
ietf_as_alpha3 = False
|
||||
|
||||
store_recently_played_amount = 40
|
||||
|
||||
initialized = False
|
||||
|
||||
def initialize(self):
|
||||
self.libraries_root = os.path.abspath(os.path.join(get_root_path(), ".."))
|
||||
self.init_libraries()
|
||||
|
||||
if is_windows_special_path:
|
||||
Log.Warn("The Plex metadata folder is residing inside a folder with special characters. "
|
||||
"Multithreading and playback activities will be disabled.")
|
||||
|
||||
self.fs_encoding = get_viable_encoding()
|
||||
self.plugin_info = self.get_plugin_info()
|
||||
self.is_development = self.get_dev_mode()
|
||||
self.version = self.get_version()
|
||||
self.full_version = u"%s %s" % (PLUGIN_NAME, self.version)
|
||||
self.set_log_paths()
|
||||
self.app_support_path = Core.app_support_path
|
||||
self.data_path = getattr(Data, "_core").storage.data_path
|
||||
self.data_items_path = os.path.join(self.data_path, "DataItems")
|
||||
self.universal_plex_token = self.get_universal_plex_token()
|
||||
self.plex_token = os.environ.get("PLEXTOKEN", self.universal_plex_token)
|
||||
subzero.constants.DEFAULT_TIMEOUT = lib.DEFAULT_TIMEOUT = self.pms_request_timeout = \
|
||||
min(cast_int(Prefs['pms_request_timeout'], 15), 45)
|
||||
self.low_impact_mode = cast_bool(Prefs['low_impact_mode'])
|
||||
self.new_style_cache = cast_bool(Prefs['new_style_cache'])
|
||||
self.pack_cache_dir = self.get_pack_cache_dir()
|
||||
self.advanced = self.get_advanced_config()
|
||||
|
||||
os.environ["SZ_USER_AGENT"] = self.get_user_agent()
|
||||
|
||||
self.setup_proxies()
|
||||
self.set_plugin_mode()
|
||||
self.set_plugin_lock()
|
||||
self.set_activity_modes()
|
||||
self.parse_rename_mode()
|
||||
|
||||
self.subtitle_destination_folder = self.get_subtitle_destination_folder()
|
||||
self.subtitle_formats = self.get_subtitle_formats()
|
||||
self.forced_only = cast_bool(Prefs["subtitles.only_foreign"])
|
||||
self.max_recent_items_per_library = int_or_default(Prefs["scheduler.max_recent_items_per_library"], 2000)
|
||||
self.sections = list(Plex["library"].sections())
|
||||
self.missing_permissions = []
|
||||
self.ignore_sz_files = cast_bool(Prefs["subtitles.ignore_fs"])
|
||||
self.ignore_paths = self.parse_ignore_paths()
|
||||
self.enabled_sections = self.check_enabled_sections()
|
||||
self.permissions_ok = self.check_permissions()
|
||||
self.notify_executable = self.check_notify_executable()
|
||||
self.remove_hi = cast_bool(Prefs['subtitles.remove_hi'])
|
||||
self.remove_tags = cast_bool(Prefs['subtitles.remove_tags'])
|
||||
self.fix_ocr = cast_bool(Prefs['subtitles.fix_ocr'])
|
||||
self.fix_common = cast_bool(Prefs['subtitles.fix_common'])
|
||||
self.reverse_rtl = cast_bool(Prefs['subtitles.reverse_rtl'])
|
||||
self.colors = Prefs['subtitles.colors'] if Prefs['subtitles.colors'] != "don't change" else None
|
||||
self.chmod = self.check_chmod()
|
||||
self.exotic_ext = cast_bool(Prefs["subtitles.scan.exotic_ext"])
|
||||
self.treat_und_as_first = cast_bool(Prefs["subtitles.language.treat_und_as_first"])
|
||||
self.subtitle_sub_dir = self.get_subtitle_sub_dir()
|
||||
self.ext_match_strictness = self.determine_ext_sub_strictness()
|
||||
self.default_mods = self.get_default_mods()
|
||||
self.debug_mods = cast_bool(Prefs['log_debug_mods'])
|
||||
self.no_refresh = os.environ.get("SZ_NO_REFRESH", False)
|
||||
self.plex_transcoder = self.get_plex_transcoder()
|
||||
self.only_one = cast_bool(Prefs['subtitles.only_one'])
|
||||
self.embedded_auto_extract = cast_bool(Prefs["subtitles.embedded.autoextract"])
|
||||
self.ietf_as_alpha3 = cast_bool(Prefs["subtitles.language.ietf_normalize"])
|
||||
self.initialized = True
|
||||
|
||||
def init_libraries(self):
|
||||
if Core.runtime.os == "Windows":
|
||||
unrar_exe = os.path.abspath(os.path.join(self.libraries_root, "Windows", "i386", "UnRAR", "UnRAR.exe"))
|
||||
if os.path.isfile(unrar_exe):
|
||||
rarfile.UNRAR_TOOL = unrar_exe
|
||||
Log.Info("Using UnRAR from: %s", unrar_exe)
|
||||
|
||||
custom_unrar = os.environ.get("SZ_UNRAR_TOOL")
|
||||
if custom_unrar and os.path.isfile(custom_unrar):
|
||||
rarfile.UNRAR_TOOL = custom_unrar
|
||||
Log.Info("Using UnRAR from: %s", custom_unrar)
|
||||
|
||||
def init_cache(self):
|
||||
if self.new_style_cache:
|
||||
subliminal.region.configure('subzero.cache.file', expiration_time=datetime.timedelta(days=30),
|
||||
arguments={'appname': "sz_cache",
|
||||
'app_cache_dir': self.data_path})
|
||||
Log.Info("Using new style file based cache!")
|
||||
return
|
||||
|
||||
names = ['dbhash', 'gdbm', 'dbm']
|
||||
dbfn = None
|
||||
self.dbm_supported = False
|
||||
|
||||
# try importing dbm modules
|
||||
if Core.runtime.os != "Windows":
|
||||
impawrt = None
|
||||
try:
|
||||
impawrt = getattr(sys.modules['__main__'], "__builtins__").get("__import__")
|
||||
except:
|
||||
pass
|
||||
|
||||
if impawrt:
|
||||
for name in names:
|
||||
try:
|
||||
impawrt(name)
|
||||
except:
|
||||
continue
|
||||
if not self.dbm_supported:
|
||||
self.dbm_supported = name
|
||||
break
|
||||
|
||||
if self.dbm_supported:
|
||||
# anydbm checks; try guessing the format and importing the correct module
|
||||
dbfn = os.path.join(config.data_items_path, 'subzero.dbm')
|
||||
db_which = whichdb(dbfn)
|
||||
if db_which is not None and db_which != "":
|
||||
try:
|
||||
impawrt(db_which)
|
||||
except ImportError:
|
||||
self.dbm_supported = False
|
||||
|
||||
if self.dbm_supported:
|
||||
try:
|
||||
subliminal.region.configure('dogpile.cache.dbm', expiration_time=datetime.timedelta(days=30),
|
||||
arguments={'filename': dbfn,
|
||||
'lock_factory': MutexLock})
|
||||
Log.Info("Using file based cache!")
|
||||
return
|
||||
except:
|
||||
self.dbm_supported = False
|
||||
|
||||
Log.Warn("Not using file based cache!")
|
||||
subliminal.region.configure('dogpile.cache.memory')
|
||||
|
||||
def sync_cache(self):
|
||||
if not self.new_style_cache:
|
||||
return
|
||||
Log.Debug("Syncing cache")
|
||||
subliminal.region.backend.sync()
|
||||
|
||||
def get_pack_cache_dir(self):
|
||||
pack_cache_dir = os.path.join(config.data_path, "pack_cache")
|
||||
if not os.path.isdir(pack_cache_dir):
|
||||
os.makedirs(pack_cache_dir)
|
||||
|
||||
return pack_cache_dir
|
||||
|
||||
def get_advanced_config(self):
|
||||
path = os.path.join(config.data_path, "advanced_settings.json")
|
||||
if os.path.isfile(path):
|
||||
data = FileIO.read(path, "r")
|
||||
|
||||
return Dicked(**jstyleson.loads(data))
|
||||
|
||||
return Dicked()
|
||||
|
||||
def set_log_paths(self):
|
||||
# find log handler
|
||||
for handler in Core.log.handlers:
|
||||
cls_name = getattr(getattr(handler, "__class__"), "__name__")
|
||||
if cls_name in ('FileHandler', 'RotatingFileHandler', 'TimedRotatingFileHandler'):
|
||||
plugin_log_file = handler.baseFilename
|
||||
if cls_name in ("RotatingFileHandler", "TimedRotatingFileHandler"):
|
||||
handler.backupCount = int_or_default(Prefs['log_rotate_keep'], 5)
|
||||
|
||||
if os.path.isfile(os.path.realpath(plugin_log_file)):
|
||||
self.plugin_log_path = plugin_log_file
|
||||
|
||||
if plugin_log_file:
|
||||
server_log_file = os.path.realpath(os.path.join(plugin_log_file, "../../Plex Media Server.log"))
|
||||
if os.path.isfile(server_log_file):
|
||||
self.server_log_path = server_log_file
|
||||
|
||||
def get_universal_plex_token(self):
|
||||
# thanks to: https://forums.plex.tv/discussion/247136/read-current-x-plex-token-in-an-agent-ensure-that-a-http-request-gets-executed-exactly-once#latest
|
||||
pref_path = os.path.join(self.app_support_path, "Preferences.xml")
|
||||
if os.path.exists(pref_path):
|
||||
try:
|
||||
global_prefs = Core.storage.load(pref_path)
|
||||
return XML.ElementFromString(global_prefs).xpath('//Preferences/@PlexOnlineToken')[0]
|
||||
except:
|
||||
Log.Warn("Couldn't determine Plex Token")
|
||||
else:
|
||||
Log.Warn("Did NOT find Preferences file - most likely Windows OS. Otherwise please check logfile and hierarchy.")
|
||||
|
||||
# fixme: windows
|
||||
|
||||
def set_plugin_mode(self):
|
||||
self.enable_agent = True
|
||||
self.enable_channel = True
|
||||
|
||||
# any provider enabled?
|
||||
if not self.providers:
|
||||
self.enable_agent = False
|
||||
self.enable_channel = False
|
||||
Log.Warn("No providers enabled, disabling agent and channel!")
|
||||
return
|
||||
|
||||
if Prefs["plugin_mode"] == "only agent":
|
||||
self.enable_channel = False
|
||||
elif Prefs["plugin_mode"] == "only channel":
|
||||
self.enable_agent = False
|
||||
|
||||
def set_plugin_lock(self):
|
||||
if Prefs["plugin_pin_mode"] in ("channel menu", "advanced menu"):
|
||||
# check pin
|
||||
pin = Prefs["plugin_pin"]
|
||||
if not pin or not len(pin):
|
||||
Log.Warn("PIN enabled but not set, disabling PIN!")
|
||||
return
|
||||
|
||||
pin = pin.strip()
|
||||
try:
|
||||
int(pin)
|
||||
except ValueError:
|
||||
Log.Warn("PIN has to be an integer (0-9)")
|
||||
self.pin = pin
|
||||
self.lock_advanced_menu = Prefs["plugin_pin_mode"] == "advanced menu"
|
||||
self.lock_menu = Prefs["plugin_pin_mode"] == "channel menu"
|
||||
|
||||
try:
|
||||
self.pin_valid_minutes = int(Prefs["plugin_pin_valid_for"].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def pin_correct(self):
|
||||
if isinstance(Dict["pin_correct_time"], datetime.datetime) \
|
||||
and Dict["pin_correct_time"] + datetime.timedelta(
|
||||
minutes=self.pin_valid_minutes) > datetime.datetime.now():
|
||||
return True
|
||||
|
||||
def refresh_permissions_status(self):
|
||||
self.permissions_ok = self.check_permissions()
|
||||
|
||||
def check_permissions(self):
|
||||
if not cast_bool(Prefs["subtitles.save.filesystem"]) or not cast_bool(Prefs["check_permissions"]):
|
||||
return True
|
||||
|
||||
self.missing_permissions = []
|
||||
use_ignore_fs = Prefs["subtitles.ignore_fs"]
|
||||
all_permissions_ok = True
|
||||
for section in self.sections:
|
||||
if section.key not in self.enabled_sections:
|
||||
continue
|
||||
|
||||
title = section.title
|
||||
for location in section:
|
||||
path_str = location.path
|
||||
if isinstance(path_str, unicode):
|
||||
path_str = path_str.encode(self.fs_encoding)
|
||||
|
||||
if not os.path.exists(path_str):
|
||||
continue
|
||||
|
||||
if use_ignore_fs:
|
||||
# check whether we've got an ignore file inside the section path
|
||||
if self.is_physically_ignored(path_str):
|
||||
continue
|
||||
|
||||
if self.is_path_ignored(path_str):
|
||||
# is the path in our ignored paths setting?
|
||||
continue
|
||||
|
||||
# section not ignored, check for write permissions
|
||||
if not check_write_permissions(path_str):
|
||||
# not enough permissions
|
||||
self.missing_permissions.append((title, location.path))
|
||||
all_permissions_ok = False
|
||||
|
||||
return all_permissions_ok
|
||||
|
||||
def get_version(self):
|
||||
return self.get_bare_version() + ("" if not self.is_development else " DEV")
|
||||
|
||||
def get_bare_version(self):
|
||||
result = VERSION_RE.search(self.plugin_info)
|
||||
|
||||
if result:
|
||||
return result.group(1)
|
||||
return "2.x.x.x"
|
||||
|
||||
def get_user_agent(self):
|
||||
return "Sub-Zero/%s" % (self.get_bare_version() + ("" if not self.is_development else "-dev"))
|
||||
|
||||
def get_dev_mode(self):
|
||||
dev = DEV_RE.search(self.plugin_info)
|
||||
if dev and dev.group(1) == "1":
|
||||
return True
|
||||
|
||||
def get_plugin_info(self):
|
||||
curDir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
info_file_path = os.path.abspath(os.path.join(curDir, "..", "..", "Info.plist"))
|
||||
return FileIO.read(info_file_path)
|
||||
|
||||
def parse_ignore_paths(self):
|
||||
paths = Prefs["subtitles.ignore_paths"]
|
||||
if paths:
|
||||
try:
|
||||
return [path.strip() for path in paths.split(",")]
|
||||
except:
|
||||
Log.Error("Couldn't parse your ignore paths settings: %s" % paths)
|
||||
return []
|
||||
|
||||
def is_physically_ignored(self, folder):
|
||||
# check whether we've got an ignore file inside the path
|
||||
for ifn in IGNORE_FN:
|
||||
if os.path.isfile(os.path.join(folder, ifn)):
|
||||
Log.Info(u'Ignoring "%s" because "%s" exists', folder, ifn)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_path_ignored(self, fn):
|
||||
for path in self.ignore_paths:
|
||||
if fn.startswith(path):
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_notify_executable(self):
|
||||
fn = Prefs["notify_executable"]
|
||||
if not fn:
|
||||
return
|
||||
|
||||
splitted_fn = fn.split()
|
||||
exe_fn = splitted_fn[0]
|
||||
arguments = [arg.strip() for arg in splitted_fn[1:]]
|
||||
|
||||
if os.path.isfile(exe_fn) and os.access(exe_fn, os.X_OK):
|
||||
return exe_fn, arguments
|
||||
Log.Error("Notify executable not existing or not executable: %s" % exe_fn)
|
||||
|
||||
def refresh_enabled_sections(self):
|
||||
self.enabled_sections = self.check_enabled_sections()
|
||||
|
||||
def check_enabled_sections(self):
|
||||
enabled_for_primary_agents = {"movie": [], "show": []}
|
||||
enabled_sections = {}
|
||||
|
||||
# find which agents we're enabled for
|
||||
for agent in Plex.agents():
|
||||
if not agent.primary:
|
||||
continue
|
||||
|
||||
for t in list(agent.media_types):
|
||||
if t.media_type in (MOVIE, SHOW):
|
||||
related_agents = Plex.primary_agent(agent.identifier, t.media_type)
|
||||
for a in related_agents:
|
||||
if a.identifier == PLUGIN_IDENTIFIER and a.enabled:
|
||||
enabled_for_primary_agents[MEDIA_TYPE_TO_STRING[t.media_type]].append(agent.identifier)
|
||||
|
||||
# find the libraries that use them
|
||||
for library in self.sections:
|
||||
if library.agent in enabled_for_primary_agents.get(library.type, []):
|
||||
enabled_sections[library.key] = library
|
||||
|
||||
Log.Debug(u"I'm enabled for: %s" % [lib.title for key, lib in enabled_sections.iteritems()])
|
||||
return enabled_sections
|
||||
|
||||
# Prepare a list of languages we want subs for
|
||||
def get_lang_list(self, provider=None):
|
||||
# advanced settings
|
||||
if provider and self.advanced.providers and provider in self.advanced.providers:
|
||||
adv_languages = self.advanced.providers[provider].get("languages", None)
|
||||
if adv_languages:
|
||||
adv_out = set()
|
||||
for adv_lang in adv_languages:
|
||||
adv_lang = adv_lang.strip()
|
||||
try:
|
||||
real_lang = Language.fromietf(adv_lang)
|
||||
except:
|
||||
try:
|
||||
real_lang = Language.fromname(adv_lang)
|
||||
except:
|
||||
continue
|
||||
adv_out.update({real_lang})
|
||||
|
||||
# fallback to default languages if no valid language was found in advanced settings
|
||||
if adv_out:
|
||||
return adv_out
|
||||
|
||||
l = {Language.fromietf(Prefs["langPref1a"])}
|
||||
lang_custom = Prefs["langPrefCustom"].strip()
|
||||
|
||||
if Prefs['subtitles.only_one']:
|
||||
return l
|
||||
|
||||
if Prefs["langPref2a"] != "None":
|
||||
try:
|
||||
l.update({Language.fromietf(Prefs["langPref2a"])})
|
||||
except:
|
||||
pass
|
||||
|
||||
if Prefs["langPref3a"] != "None":
|
||||
try:
|
||||
l.update({Language.fromietf(Prefs["langPref3a"])})
|
||||
except:
|
||||
pass
|
||||
|
||||
if len(lang_custom) and lang_custom != "None":
|
||||
for lang in lang_custom.split(u","):
|
||||
lang = lang.strip()
|
||||
try:
|
||||
real_lang = Language.fromietf(lang)
|
||||
except:
|
||||
try:
|
||||
real_lang = Language.fromname(lang)
|
||||
except:
|
||||
continue
|
||||
l.update({real_lang})
|
||||
|
||||
return l
|
||||
|
||||
lang_list = property(get_lang_list)
|
||||
|
||||
def get_subtitle_destination_folder(self):
|
||||
if not Prefs["subtitles.save.filesystem"]:
|
||||
return
|
||||
|
||||
fld_custom = Prefs["subtitles.save.subFolder.Custom"].strip() if cast_bool(
|
||||
Prefs["subtitles.save.subFolder.Custom"]) else None
|
||||
return fld_custom or (
|
||||
Prefs["subtitles.save.subFolder"] if Prefs["subtitles.save.subFolder"] != "current folder" else None)
|
||||
|
||||
def get_subtitle_formats(self):
|
||||
formats = Prefs["subtitles.save.formats"]
|
||||
out = []
|
||||
if "SRT" in formats:
|
||||
out.append("srt")
|
||||
if "VTT" in formats:
|
||||
out.append("vtt")
|
||||
return out
|
||||
|
||||
def get_providers(self, media_type="series"):
|
||||
providers = {'opensubtitles': cast_bool(Prefs['provider.opensubtitles.enabled']),
|
||||
# 'thesubdb': Prefs['provider.thesubdb.enabled'],
|
||||
'podnapisi': cast_bool(Prefs['provider.podnapisi.enabled']),
|
||||
'titlovi': cast_bool(Prefs['provider.titlovi.enabled']),
|
||||
'addic7ed': cast_bool(Prefs['provider.addic7ed.enabled']),
|
||||
'tvsubtitles': cast_bool(Prefs['provider.tvsubtitles.enabled']),
|
||||
'legendastv': cast_bool(Prefs['provider.legendastv.enabled']),
|
||||
'napiprojekt': cast_bool(Prefs['provider.napiprojekt.enabled']),
|
||||
'hosszupuska': cast_bool(Prefs['provider.hosszupuska.enabled']),
|
||||
'shooter': False,
|
||||
'subscene': cast_bool(Prefs['provider.subscene.enabled']),
|
||||
'argenteam': cast_bool(Prefs['provider.argenteam.enabled']),
|
||||
'subscenter': False,
|
||||
}
|
||||
|
||||
providers_by_prefs = copy.deepcopy(providers)
|
||||
|
||||
# disable subscene for movies by default
|
||||
if media_type == "movies":
|
||||
providers["subscene"] = False
|
||||
|
||||
# ditch non-forced-subtitles-reporting providers
|
||||
if self.forced_only:
|
||||
providers["addic7ed"] = False
|
||||
providers["tvsubtitles"] = False
|
||||
providers["legendastv"] = False
|
||||
providers["napiprojekt"] = False
|
||||
providers["shooter"] = False
|
||||
providers["hosszupuska"] = False
|
||||
providers["titlovi"] = False
|
||||
providers["argenteam"] = False
|
||||
|
||||
# advanced settings
|
||||
if media_type and self.advanced.providers:
|
||||
for provider, data in self.advanced.providers.iteritems():
|
||||
if provider not in providers or not providers_by_prefs[provider]:
|
||||
continue
|
||||
|
||||
if data["enabled_for"] is not None:
|
||||
providers[provider] = media_type in data["enabled_for"]
|
||||
|
||||
if "provider_throttle" not in Dict:
|
||||
Dict["provider_throttle"] = {}
|
||||
|
||||
changed = False
|
||||
for provider, enabled in dict(providers).iteritems():
|
||||
reason, until, throttle_desc = Dict["provider_throttle"].get(provider, (None, None, None))
|
||||
if reason:
|
||||
now = datetime.datetime.now()
|
||||
if now < until:
|
||||
Log.Info("Not using %s until %s, because of: %s", provider,
|
||||
until.strftime("%y/%m/%d %H:%M"), reason)
|
||||
providers[provider] = False
|
||||
else:
|
||||
Log.Info("Using %s again after %s, (disabled because: %s)", provider, throttle_desc, reason)
|
||||
del Dict["provider_throttle"][provider]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
Dict.Save()
|
||||
|
||||
return filter(lambda prov: providers[prov], providers)
|
||||
|
||||
providers = property(get_providers)
|
||||
|
||||
def get_provider_settings(self):
|
||||
provider_settings = {'addic7ed': {'username': Prefs['provider.addic7ed.username'],
|
||||
'password': Prefs['provider.addic7ed.password'],
|
||||
'use_random_agents': cast_bool(Prefs['provider.addic7ed.use_random_agents1']),
|
||||
},
|
||||
'opensubtitles': {'username': Prefs['provider.opensubtitles.username'],
|
||||
'password': Prefs['provider.opensubtitles.password'],
|
||||
'use_tag_search': self.exact_filenames,
|
||||
'only_foreign': self.forced_only,
|
||||
'is_vip': cast_bool(Prefs['provider.opensubtitles.is_vip'])
|
||||
},
|
||||
'podnapisi': {
|
||||
'only_foreign': self.forced_only,
|
||||
},
|
||||
'legendastv': {'username': Prefs['provider.legendastv.username'],
|
||||
'password': Prefs['provider.legendastv.password'],
|
||||
}
|
||||
}
|
||||
|
||||
return provider_settings
|
||||
|
||||
provider_settings = property(get_provider_settings)
|
||||
|
||||
def provider_throttle(self, name, exception):
|
||||
"""
|
||||
throttle a provider :name: for X hours based on the :exception: type
|
||||
:param name:
|
||||
:param exception:
|
||||
:return:
|
||||
"""
|
||||
cls = getattr(exception, "__class__")
|
||||
cls_name = getattr(cls, "__name__")
|
||||
if cls not in VALID_THROTTLE_EXCEPTIONS:
|
||||
for valid_cls in VALID_THROTTLE_EXCEPTIONS:
|
||||
if isinstance(cls, valid_cls):
|
||||
cls = valid_cls
|
||||
|
||||
throttle_data = PROVIDER_THROTTLE_MAP.get(name, PROVIDER_THROTTLE_MAP["default"]).get(cls, None) or \
|
||||
PROVIDER_THROTTLE_MAP["default"].get(cls, None)
|
||||
|
||||
if not throttle_data:
|
||||
return
|
||||
|
||||
throttle_delta, throttle_description = throttle_data
|
||||
|
||||
if "provider_throttle" not in Dict:
|
||||
Dict["provider_throttle"] = {}
|
||||
|
||||
throttle_until = datetime.datetime.now() + throttle_delta
|
||||
Dict["provider_throttle"][name] = (cls_name, throttle_until, throttle_description)
|
||||
|
||||
Log.Info("Throttling %s for %s, until %s, because of: %s", name, throttle_description,
|
||||
throttle_until.strftime("%y/%m/%d %H:%M"), cls_name)
|
||||
Dict.Save()
|
||||
|
||||
@property
|
||||
def provider_pool(self):
|
||||
if cast_bool(Prefs['providers.multithreading']):
|
||||
return subliminal_patch.core.SZAsyncProviderPool
|
||||
return subliminal_patch.core.SZProviderPool
|
||||
|
||||
def check_chmod(self):
|
||||
val = Prefs["subtitles.save.chmod"]
|
||||
if not val or not len(val):
|
||||
return
|
||||
|
||||
wrong_chmod = False
|
||||
if len(val) != 4:
|
||||
wrong_chmod = True
|
||||
|
||||
try:
|
||||
return int(val, 8)
|
||||
except ValueError:
|
||||
wrong_chmod = True
|
||||
|
||||
if wrong_chmod:
|
||||
Log.Warn("Chmod setting ignored, please use only 4-digit integers with leading 0 (e.g.: 775)")
|
||||
|
||||
def get_subtitle_sub_dir(self):
|
||||
"""
|
||||
|
||||
:return: folder, is_absolute
|
||||
"""
|
||||
if not cast_bool(Prefs['subtitles.save.filesystem']):
|
||||
return None, None
|
||||
|
||||
if Prefs["subtitles.save.subFolder.Custom"]:
|
||||
return Prefs["subtitles.save.subFolder.Custom"], os.path.isabs(Prefs["subtitles.save.subFolder.Custom"])
|
||||
|
||||
if Prefs["subtitles.save.subFolder"] == "current folder":
|
||||
return ".", False
|
||||
|
||||
return Prefs["subtitles.save.subFolder"], False
|
||||
|
||||
def determine_ext_sub_strictness(self):
|
||||
val = Prefs["subtitles.scan.filename_strictness"]
|
||||
if val == "any":
|
||||
return "any"
|
||||
elif val.startswith("loose"):
|
||||
return "loose"
|
||||
return "strict"
|
||||
|
||||
def get_default_mods(self):
|
||||
mods = []
|
||||
if self.remove_hi:
|
||||
mods.append("remove_HI")
|
||||
if self.remove_tags:
|
||||
mods.append("remove_tags")
|
||||
if self.fix_ocr:
|
||||
mods.append("OCR_fixes")
|
||||
if self.fix_common:
|
||||
mods.append("common")
|
||||
if self.colors:
|
||||
mods.append("color(name=%s)" % self.colors)
|
||||
if self.reverse_rtl:
|
||||
mods.append("reverse_rtl")
|
||||
|
||||
return mods
|
||||
|
||||
def setup_proxies(self):
|
||||
proxy = Prefs["proxy"]
|
||||
if proxy:
|
||||
os.environ["SZ_HTTP_PROXY"] = proxy.strip()
|
||||
Log.Debug("Using HTTP Proxy: %s", proxy)
|
||||
|
||||
def set_activity_modes(self):
|
||||
val = Prefs["activity.on_playback"]
|
||||
if val == "never":
|
||||
self.react_to_activities = False
|
||||
return
|
||||
|
||||
self.react_to_activities = True
|
||||
if val == "current media item":
|
||||
self.activity_mode = "refresh"
|
||||
elif val == "hybrid: current item or next episode":
|
||||
self.activity_mode = "hybrid"
|
||||
elif val == "hybrid-plus: current item and next episode":
|
||||
self.activity_mode = "hybrid-plus"
|
||||
else:
|
||||
self.activity_mode = "next_episode"
|
||||
|
||||
def get_plex_transcoder(self):
|
||||
base_path = os.environ.get("PLEX_MEDIA_SERVER_HOME", None)
|
||||
if not base_path:
|
||||
# fall back to bundled plugins path
|
||||
bundle_path = os.environ.get("PLEXBUNDLEDPLUGINSPATH", None)
|
||||
if bundle_path:
|
||||
base_path = os.path.normpath(os.path.join(bundle_path, "..", ".."))
|
||||
|
||||
if sys.platform == "darwin":
|
||||
fn = os.path.join(base_path, "MacOS", "Plex Transcoder")
|
||||
elif mswindows:
|
||||
fn = os.path.join(base_path, "plextranscoder.exe")
|
||||
else:
|
||||
fn = os.path.join(base_path, "Plex Transcoder")
|
||||
|
||||
if os.path.isfile(fn):
|
||||
return fn
|
||||
|
||||
# look inside Resources folder as fallback, as well
|
||||
fn = os.path.join(base_path, "Resources", "Plex Transcoder")
|
||||
if os.path.isfile(fn):
|
||||
return fn
|
||||
|
||||
def parse_rename_mode(self):
|
||||
# fixme: exact_filenames should be determined via callback combined with info about the current video
|
||||
# (original_name)
|
||||
|
||||
mode = str(Prefs["media_rename1"])
|
||||
self.refiner_settings = {}
|
||||
|
||||
if cast_bool(Prefs['use_file_info_file']):
|
||||
self.refiner_settings["file_info_file"] = True
|
||||
self.exact_filenames = True
|
||||
|
||||
if mode == "none of the above":
|
||||
return
|
||||
|
||||
elif mode == "Symlink to original file":
|
||||
self.refiner_settings["symlinks"] = True
|
||||
self.exact_filenames = True
|
||||
return
|
||||
|
||||
elif mode == "I keep the original filenames":
|
||||
self.exact_filenames = True
|
||||
return
|
||||
|
||||
if mode in ("Filebot", "Sonarr/Radarr/Filebot"):
|
||||
self.refiner_settings["filebot"] = True
|
||||
|
||||
if mode in ("Sonarr/Radarr (fill api info below)", "Sonarr/Radarr/Filebot"):
|
||||
if Prefs["drone_api.sonarr.url"] and Prefs["drone_api.sonarr.api_key"]:
|
||||
self.refiner_settings["sonarr"] = {
|
||||
"base_url": Prefs["drone_api.sonarr.url"],
|
||||
"api_key": Prefs["drone_api.sonarr.api_key"]
|
||||
}
|
||||
self.exact_filenames = True
|
||||
|
||||
if Prefs["drone_api.radarr.url"] and Prefs["drone_api.radarr.api_key"]:
|
||||
self.refiner_settings["radarr"] = {
|
||||
"base_url": Prefs["drone_api.radarr.url"],
|
||||
"api_key": Prefs["drone_api.radarr.api_key"]
|
||||
}
|
||||
self.exact_filenames = True
|
||||
|
||||
@property
|
||||
def text_based_formats(self):
|
||||
return self.advanced.text_subtitle_formats or TEXT_SUBTITLE_EXTS
|
||||
|
||||
def init_subliminal_patches(self):
|
||||
# configure custom subtitle destination folders for scanning pre-existing subs
|
||||
Log.Debug("Patching subliminal ...")
|
||||
dest_folder = self.subtitle_destination_folder
|
||||
subliminal_patch.core.CUSTOM_PATHS = [dest_folder] if dest_folder else []
|
||||
subliminal_patch.core.INCLUDE_EXOTIC_SUBS = self.exotic_ext
|
||||
|
||||
subliminal_patch.core.DOWNLOAD_TRIES = int(Prefs['subtitles.try_downloads'])
|
||||
subliminal.score.episode_scores["addic7ed_boost"] = int(Prefs['provider.addic7ed.boost_by2'])
|
||||
|
||||
|
||||
config = Config()
|
||||
config.initialize()
|
||||
@@ -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,120 @@
|
||||
# coding=utf-8
|
||||
import os
|
||||
|
||||
from subzero.language import Language
|
||||
|
||||
import subliminal_patch as subliminal
|
||||
|
||||
from support.config import config
|
||||
from support.helpers import cast_bool
|
||||
from subtitlehelpers import get_subtitles_from_metadata
|
||||
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 = set([Language.fromietf(str(l)) for l in config.lang_list])
|
||||
|
||||
# should we treat IETF as alpha3? (ditch the country part)
|
||||
alpha3_map = {}
|
||||
if config.ietf_as_alpha3:
|
||||
for language in languages:
|
||||
if language.country:
|
||||
alpha3_map[language.alpha3] = language.country
|
||||
language.country = None
|
||||
|
||||
if not Prefs['subtitles.save.filesystem']:
|
||||
# scan for existing metadata subtitles
|
||||
meta_subs = get_subtitles_from_metadata(part)
|
||||
for language, subList in meta_subs.iteritems():
|
||||
if subList:
|
||||
video.subtitle_languages.add(language)
|
||||
Log.Debug("Found metadata subtitle %s for %s", language, video)
|
||||
|
||||
have_languages = video.subtitle_languages.copy()
|
||||
if config.ietf_as_alpha3:
|
||||
for language in have_languages:
|
||||
if language.country:
|
||||
alpha3_map[language.alpha3] = language.country
|
||||
language.country = None
|
||||
|
||||
missing_languages = (set(str(l) for l in languages) - set(str(l) for l in have_languages))
|
||||
|
||||
# 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 False
|
||||
|
||||
# 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.fromietf(str(l)) for l in config.lang_list])
|
||||
if not languages:
|
||||
return
|
||||
|
||||
use_videos = []
|
||||
for video, part in video_part_map.iteritems():
|
||||
if not video.ignore_all:
|
||||
missing_languages = get_missing_languages(video, part)
|
||||
else:
|
||||
missing_languages = languages
|
||||
|
||||
if missing_languages:
|
||||
Log.Info(u"%s has missing languages: %s", os.path.basename(video.name), missing_languages)
|
||||
refine_video(video, refiner_settings=config.refiner_settings)
|
||||
use_videos.append(video)
|
||||
|
||||
# prepare blacklist
|
||||
blacklist = get_blacklist_from_part_map(video_part_map, languages)
|
||||
|
||||
if use_videos:
|
||||
Log.Debug("Download best subtitles using settings: min_score: %s, hearing_impaired: %s, languages: %s" %
|
||||
(min_score, hearing_impaired, languages))
|
||||
|
||||
return subliminal.download_best_subtitles(set(use_videos), 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.")
|
||||
Executable
+392
@@ -0,0 +1,392 @@
|
||||
# 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
|
||||
|
||||
import chardet
|
||||
|
||||
from bs4 import UnicodeDammit
|
||||
from subzero.language import Language
|
||||
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 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 season and episode:
|
||||
return '%s%s S%02dE%02d%s' % (section_add, parent_title, season or 0, episode 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"],
|
||||
}
|
||||
)
|
||||
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"
|
||||
)
|
||||
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))
|
||||
env = dict(os.environ)
|
||||
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)
|
||||
|
||||
try:
|
||||
output = subprocess.check_output(quote_args([exe] + prepared_arguments),
|
||||
stderr=subprocess.STDOUT, shell=True, env=env)
|
||||
except subprocess.CalledProcessError:
|
||||
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)
|
||||
|
||||
|
||||
def get_language(lang_short):
|
||||
return Language.fromietf(lang_short)
|
||||
|
||||
|
||||
def display_language(l):
|
||||
addons = []
|
||||
if l.country:
|
||||
addons.append(l.country.alpha2)
|
||||
if l.script:
|
||||
addons.append(l.script.code)
|
||||
|
||||
return l.name if not addons else "%s (%s)" % (l.name, ", ".join(addons))
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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,65 @@
|
||||
# coding=utf-8
|
||||
|
||||
from subzero.lib.dict import DictProxy
|
||||
|
||||
|
||||
class IgnoreDict(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": []}
|
||||
|
||||
|
||||
ignore_list = IgnoreDict(Dict)
|
||||
@@ -0,0 +1,444 @@
|
||||
# coding=utf-8
|
||||
|
||||
import logging
|
||||
import re
|
||||
import traceback
|
||||
import types
|
||||
import os
|
||||
|
||||
import time
|
||||
|
||||
import datetime
|
||||
|
||||
from ignore import ignore_list
|
||||
from helpers import is_recent, get_plex_item_display_title, query_plex, PartUnknownException
|
||||
from lib import Plex, get_intent
|
||||
from config import config, IGNORE_FN
|
||||
from subliminal_patch.subtitle import ModifiedSubtitle
|
||||
from subzero.modification import registry as mod_registry, SubtitleModifications
|
||||
|
||||
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
|
||||
|
||||
item_container = Plex["library"].metadata(item_id)
|
||||
|
||||
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 = []
|
||||
|
||||
for section in Plex["library"].sections():
|
||||
if section.type not in ("movie", "show") \
|
||||
or section.key not in config.enabled_sections \
|
||||
or section.key in ignore_list.sections:
|
||||
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"] in ignore_list.series:
|
||||
Log.Debug(u"Skipping series: %s" % data["parent_title"])
|
||||
continue
|
||||
if data["key"] in ignore_list.videos:
|
||||
Log.Debug(u"Skipping item: %s" % data["title"])
|
||||
continue
|
||||
if is_physically_ignored(data["filename"], plex_item_type):
|
||||
Log.Debug(u"Skipping item: %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_ignored(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:
|
||||
"""
|
||||
# item in soft ignore list
|
||||
if ignore_list["videos"] and rating_key in ignore_list["videos"]:
|
||||
Log.Debug("Item %s is in the soft ignore list" % rating_key)
|
||||
return True
|
||||
|
||||
item = item or get_item(rating_key)
|
||||
kind = get_item_kind(item)
|
||||
|
||||
# show in soft ignore list
|
||||
if kind == "Episode" and ignore_list["series"] and item.show.rating_key in ignore_list["series"]:
|
||||
Log.Debug("Item %s's show is in the soft ignore list" % rating_key)
|
||||
return True
|
||||
|
||||
# season in soft ignore list
|
||||
if kind == "Episode" and ignore_list["seasons"] and item.season.rating_key in ignore_list["seasons"]:
|
||||
Log.Debug("Item %s's season is in the soft ignore list" % rating_key)
|
||||
return True
|
||||
|
||||
# section in soft ignore list
|
||||
if ignore_list["sections"] and item.section.key in ignore_list["sections"]:
|
||||
Log.Debug("Item %s's section is in the soft ignore list" % rating_key)
|
||||
return True
|
||||
|
||||
# physical/path ignore
|
||||
if config.ignore_sz_files or config.ignore_paths:
|
||||
for media in item.media:
|
||||
for part in media.parts:
|
||||
if is_physically_ignored(part.file, kind):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_physically_ignored(fn, kind):
|
||||
if config.ignore_sz_files or config.ignore_paths:
|
||||
# normally check current item folder and the library
|
||||
check_ignore_paths = [".", "../"]
|
||||
if kind == "Episode":
|
||||
# series/episode, we've got a season folder here, also
|
||||
check_ignore_paths.append("../../")
|
||||
|
||||
if config.ignore_paths and config.is_path_ignored(fn):
|
||||
Log.Debug("Item %s's path is manually ignored" % fn)
|
||||
return True
|
||||
|
||||
if config.ignore_sz_files:
|
||||
for sub_path in check_ignore_paths:
|
||||
if config.is_physically_ignored(os.path.normpath(os.path.join(os.path.dirname(fn), sub_path))):
|
||||
Log.Debug("An ignore file exists in either the items or its parent folders")
|
||||
return True
|
||||
|
||||
|
||||
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):
|
||||
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)
|
||||
storage = storage or get_subtitle_storage()
|
||||
|
||||
cleanup = not storage
|
||||
|
||||
stored_subs = stored_subs or storage.load(plex_item.rating_key)
|
||||
|
||||
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)
|
||||
Log.Debug("Modified %s subtitle for: %s:%s with: %s", language.name, rating_key, part_id,
|
||||
", ".join(stored_subtitle.mods) if stored_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))
|
||||
storage.save(stored_subs)
|
||||
|
||||
if cleanup:
|
||||
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")
|
||||
storage.save(stored_subs)
|
||||
|
||||
save_stored_sub(current_sub, rating_key, part_id, language, item_type, plex_item=plex_item, storage=storage,
|
||||
stored_subs=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
+202
@@ -0,0 +1,202 @@
|
||||
# coding=utf-8
|
||||
|
||||
import os
|
||||
|
||||
import config
|
||||
import helpers
|
||||
import subtitlehelpers
|
||||
|
||||
from config import config as sz_config
|
||||
|
||||
|
||||
SECONDARY_TAGS = ['forced', 'normal', 'default', 'embedded', 'embedded-forced', 'custom', 'hi', 'cc', 'sdh']
|
||||
|
||||
|
||||
def find_subtitles(part):
|
||||
lang_sub_map = {}
|
||||
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
|
||||
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 use_filesystem and helpers.cast_bool(Prefs["subtitles.autoclean"]):
|
||||
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 = subtitlehelpers.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
+192
@@ -0,0 +1,192 @@
|
||||
# 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.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.fromietf(str(l)) for l in languages])
|
||||
for media in item.media:
|
||||
existing_subs = {"internal": [], "external": [], "own_external": [], "count": 0}
|
||||
for part in media.parts:
|
||||
|
||||
# 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:
|
||||
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.fromietf(str(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.fromietf(str(list(config.lang_list)[0]))
|
||||
else:
|
||||
continue
|
||||
|
||||
except (ValueError, LanguageReverseError):
|
||||
continue
|
||||
|
||||
if lang:
|
||||
# Log.Debug("Found babelfish language: %r", lang)
|
||||
existing_subs[key].append(lang)
|
||||
existing_subs["count"] = existing_subs["count"] + 1
|
||||
|
||||
missing_from_part = set([Language.fromietf(str(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.fromietf(str(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,357 @@
|
||||
# coding=utf-8
|
||||
|
||||
import os
|
||||
|
||||
import helpers
|
||||
from items import get_item
|
||||
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):
|
||||
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)
|
||||
|
||||
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,
|
||||
"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)
|
||||
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,
|
||||
"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 get_embedded_subtitle_streams(part, requested_language=None, skip_duplicate_unknown=True, get_forced=None):
|
||||
streams = []
|
||||
has_unknown = False
|
||||
for stream in part.streams:
|
||||
# subtitle stream
|
||||
if stream.stream_type == 3 and not stream.stream_key and stream.codec in TEXT_SUBTITLE_EXTS:
|
||||
language = helpers.get_language_from_stream(stream.language_code)
|
||||
is_unknown = False
|
||||
found_requested_language = requested_language and requested_language == language
|
||||
is_forced = helpers.is_stream_forced(stream)
|
||||
|
||||
if get_forced is not None:
|
||||
if (get_forced and not is_forced) or (not get_forced and is_forced):
|
||||
continue
|
||||
|
||||
if not language and config.treat_und_as_first:
|
||||
# only consider first unknown subtitle stream
|
||||
if has_unknown and skip_duplicate_unknown:
|
||||
continue
|
||||
|
||||
language = list(config.lang_list)[0]
|
||||
is_unknown = True
|
||||
has_unknown = True
|
||||
|
||||
if not requested_language or found_requested_language or has_unknown:
|
||||
streams.append({"stream": stream, "is_unknown": is_unknown, "language": language,
|
||||
"is_forced": is_forced})
|
||||
|
||||
if found_requested_language:
|
||||
break
|
||||
|
||||
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)
|
||||
|
||||
# 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,
|
||||
"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,
|
||||
"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,131 @@
|
||||
# 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
|
||||
from support.storage import get_subtitle_storage
|
||||
from support.config import config, TEXT_SUBTITLE_EXTS
|
||||
|
||||
from subzero.video import parse_video, set_existing_languages
|
||||
from subzero.language import language_from_stream
|
||||
|
||||
|
||||
def scan_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("Scanning video: %s, external_subtitles=%s, 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?
|
||||
if plexpy_part:
|
||||
if embedded_subtitles:
|
||||
for stream in plexpy_part.streams:
|
||||
# subtitle stream
|
||||
if stream.stream_type == 3:
|
||||
is_forced = helpers.is_stream_forced(stream)
|
||||
|
||||
if (config.forced_only and is_forced) or \
|
||||
(not config.forced_only and 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.Debug("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 = list(config.lang_list)[0]
|
||||
|
||||
if lang:
|
||||
known_embedded.append(lang.alpha3)
|
||||
else:
|
||||
Log.Warn("Part %s missing of %s, not able to scan internal streams", plex_part.id, rating_key)
|
||||
|
||||
Log.Debug("Known embedded: %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)
|
||||
|
||||
if not ignore_all:
|
||||
set_existing_languages(video, pms_video_info, external_subtitles=external_subtitles,
|
||||
embedded_subtitles=embedded_subtitles, known_embedded=known_embedded,
|
||||
forced_only=config.forced_only, stored_subs=stored_subs, languages=config.lang_list,
|
||||
only_one=config.only_one)
|
||||
|
||||
# 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 = scan_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,242 @@
|
||||
# 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.Warning("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, forced_tag=None):
|
||||
forced_tag = forced_tag or config.forced_only
|
||||
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, forced_tag=forced_tag, path_decoder=force_unicode,
|
||||
debug_mods=config.debug_mods, formats=config.subtitle_formats, tags=tags)
|
||||
return True
|
||||
|
||||
|
||||
def save_subtitles_to_metadata(videos, subtitles, is_forced=False):
|
||||
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 is_forced else None)
|
||||
mp.subtitles[Locale.Language.Match(subtitle.language.alpha2)][subtitle.id] = pm
|
||||
return True
|
||||
|
||||
|
||||
def save_subtitles(scanned_video_part_map, downloaded_subtitles, mode="a", bare_save=False, mods=None,
|
||||
set_current=True, is_forced=False):
|
||||
"""
|
||||
|
||||
: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, forced_tag=is_forced)
|
||||
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,
|
||||
is_forced=is_forced)
|
||||
|
||||
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,215 @@
|
||||
# coding=utf-8
|
||||
|
||||
import re, os
|
||||
import helpers
|
||||
|
||||
from config import config, SUBTITLE_EXTS, TEXT_SUBTITLE_EXTS
|
||||
from bs4 import UnicodeDammit
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
#####################################################################################################################
|
||||
|
||||
|
||||
IETF_MATCH = ".+\.([^-.]+)(?:-[A-Za-z]+)?$"
|
||||
ENDSWITH_LANGUAGECODE_RE = re.compile("\.([^-.]{2,3})(?:-[A-Za-z]{2,})?$")
|
||||
|
||||
|
||||
def match_ietf_language(s):
|
||||
language_match = re.match(".+\.([^\.]+)$" if not helpers.cast_bool(Prefs["subtitles.language.ietf_display"])
|
||||
else IETF_MATCH, s)
|
||||
if language_match and len(language_match.groups()) == 1:
|
||||
language = language_match.groups()[0]
|
||||
return language
|
||||
return s
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
part.subtitles[language][basename] = Proxy.LocalFile(self.filename, codec=codec, format=format, default=default,
|
||||
forced=forced)
|
||||
|
||||
lang_sub_map[language] = [basename]
|
||||
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":
|
||||
# metadata subtitle
|
||||
Log.Debug(u"Found metadata subtitle: %s, %s" % (language, 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
+905
@@ -0,0 +1,905 @@
|
||||
# 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_ignored, 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.scanning import scan_videos
|
||||
from download import download_best_subtitles, pre_download_hook, post_download_hook, language_hook
|
||||
|
||||
PROVIDER_SLACK = 30
|
||||
DL_PROVIDER_SLACK = 30
|
||||
|
||||
|
||||
class Task(object):
|
||||
name = None
|
||||
scheduler = None
|
||||
periodic = False
|
||||
running = False
|
||||
time_start = None
|
||||
data = None
|
||||
|
||||
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" and not {"series", "season", "episode"}.issubset(matches):
|
||||
Log.Debug(u"%s: Skipping %s, because it doesn't match our series/episode", self.name, s)
|
||||
continue
|
||||
|
||||
unsorted_subtitles.append(
|
||||
(s, compute_score(matches, s, video, hearing_impaired=use_hearing_impaired), matches))
|
||||
scored_subtitles = sorted(unsorted_subtitles, key=operator.itemgetter(1), reverse=True)
|
||||
|
||||
subtitles = []
|
||||
for subtitle, score, matches in scored_subtitles:
|
||||
# check score
|
||||
if score < min_score:
|
||||
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"],
|
||||
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"%s: Subtitle download failed (%s)" % (self.name, 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)
|
||||
|
||||
# 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 is_ignored(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)
|
||||
|
||||
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"],
|
||||
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, PROVIDER_SLACK)
|
||||
Thread.Sleep(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)
|
||||
|
||||
stored_subs = None
|
||||
|
||||
if downloads_per_video:
|
||||
Log.Debug(u"%s: Subtitles have been downloaded, "
|
||||
u"waiting %s seconds before continuing", self.name, DL_PROVIDER_SLACK)
|
||||
Thread.Sleep(DL_PROVIDER_SLACK)
|
||||
else:
|
||||
if hit_providers:
|
||||
Log.Debug(u"%s: Waiting %s seconds before continuing", self.name, PROVIDER_SLACK)
|
||||
Thread.Sleep(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 not is_ignored(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)
|
||||
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 82
|
||||
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, DL_PROVIDER_SLACK)
|
||||
Thread.Sleep(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, DL_PROVIDER_SLACK)
|
||||
Thread.Sleep(DL_PROVIDER_SLACK)
|
||||
|
||||
elif better_visited:
|
||||
Log.Debug(u"%s: Waiting %s seconds before continuing", self.name, PROVIDER_SLACK)
|
||||
Thread.Sleep(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, PROVIDER_SLACK)
|
||||
Thread.Sleep(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()
|
||||
deleted_items = storage.delete_missing(wanted_languages=set(str(l) for l in config.lang_list))
|
||||
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()
|
||||
for fn in storage.get_all_files():
|
||||
if fn.endswith(".json.gz"):
|
||||
continue
|
||||
Log.Debug(u"%s: Migrating %s", self.name, fn)
|
||||
storage.load(None, fn)
|
||||
|
||||
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)
|
||||
+818
-115
@@ -1,117 +1,820 @@
|
||||
[
|
||||
{
|
||||
"id": "provider.addic7ed.username",
|
||||
"label": "Addic7ed Username",
|
||||
"type": "text",
|
||||
"default": "Username"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.password",
|
||||
"label": "Addic7ed Password",
|
||||
"type": "text",
|
||||
"option": "hidden",
|
||||
"default": "",
|
||||
"secure": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.use_random_agents",
|
||||
"label": "Addic7ed: Use random user agents (should not be necessary)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "langPref1",
|
||||
"label": "Subtitle Language (1)",
|
||||
"type": "enum",
|
||||
"values": ["sq","ar","be","bs","bg","ca","zh","cs","da","nl","en","et","fi","fr","de","el","he","hi","hu","is","id","it","ja","ko","lv","lt","mk","ms","no","fa","pl","pt","pt-br","ro","ru","sr","sk","sl","es","sv","th","tr","uk","vi","hr"],
|
||||
"default": "en"
|
||||
},
|
||||
{
|
||||
"id": "langPref2",
|
||||
"label": "Subtitle Language (2)",
|
||||
"type": "enum",
|
||||
"values": ["None", "sq","ar","be","bs","bg","ca","zh","cs","da","nl","en","et","fi","fr","de","el","he","hi","hu","is","id","it","ja","ko","lv","lt","mk","ms","no","fa","pl","pt","pt-br","ro","ru","sr","sk","sl","es","sv","th","tr","uk","vi","hr"],
|
||||
"default": "None"
|
||||
},
|
||||
{
|
||||
"id": "langPref3",
|
||||
"label": "Subtitle Language (3)",
|
||||
"type": "enum",
|
||||
"values": ["None", "sq","ar","be","bs","bg","ca","zh","cs","da","nl","en","et","fi","fr","de","el","he","hi","hu","is","id","it","ja","ko","lv","lt","mk","ms","no","fa","pl","pt","pt-br","ro","ru","sr","sk","sl","es","sv","th","tr","uk","vi","hr"],
|
||||
"default": "None"
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.enabled",
|
||||
"label": "Provider: Enable OpenSubtitles",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.thesubdb.enabled",
|
||||
"label": "Provider: Enable TheSubDB",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.podnapisi.enabled",
|
||||
"label": "Provider: Enable Podnapisi.NET",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.enabled",
|
||||
"label": "Provider: Enable Addic7ed",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.tvsubtitles.enabled",
|
||||
"label": "Provider: Enable TVsubtitles.net",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.scan.embedded",
|
||||
"label": "Scan: include embedded subtitles (skip if existing)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.scan.external",
|
||||
"label": "Scan: include external subtitles (skip if existing)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.minimumScore",
|
||||
"label": "Minimum score for subtitles to download",
|
||||
"type": "enum",
|
||||
"values": ["100","95","90","85","80","75","70","65","60","55","50","45","40","35","30","25","20","15","10","5","0"],
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.hearingImpaired",
|
||||
"label": "Download hearing impaired subtitles.",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.filesystem",
|
||||
"label": "Store subtitles next to media files (instead of metadata)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.subFolder",
|
||||
"label": "Subtitle Folder (\"current folder\" is the folder the current media file lives in)",
|
||||
"type": "enum",
|
||||
"values": ["current folder", "sub", "subs", "subtitle", "subtitles"],
|
||||
"default": "current folder"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.subFolder.Custom",
|
||||
"label": "Custom Subtitle folder (computes to real paths; use for example \"bla\" as a subfolder of the current media file folder - can use real paths aswell)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "langPref1a",
|
||||
"label": "Subtitle Language (1)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"sq",
|
||||
"ar",
|
||||
"be",
|
||||
"bs",
|
||||
"bg",
|
||||
"ca",
|
||||
"zh",
|
||||
"cs",
|
||||
"da",
|
||||
"nl",
|
||||
"en",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"de",
|
||||
"el",
|
||||
"he",
|
||||
"hi",
|
||||
"hu",
|
||||
"is",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lv",
|
||||
"lt",
|
||||
"mk",
|
||||
"ms",
|
||||
"no",
|
||||
"fa",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-br",
|
||||
"ro",
|
||||
"ru",
|
||||
"sr",
|
||||
"sr-cyrl",
|
||||
"sr-latn",
|
||||
"sk",
|
||||
"sl",
|
||||
"es",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"hr"
|
||||
],
|
||||
"default": "en"
|
||||
},
|
||||
{
|
||||
"id": "langPref2a",
|
||||
"label": "Subtitle Language (2)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"None",
|
||||
"sq",
|
||||
"ar",
|
||||
"be",
|
||||
"bs",
|
||||
"bg",
|
||||
"ca",
|
||||
"zh",
|
||||
"cs",
|
||||
"da",
|
||||
"nl",
|
||||
"en",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"de",
|
||||
"el",
|
||||
"he",
|
||||
"hi",
|
||||
"hu",
|
||||
"is",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lv",
|
||||
"lt",
|
||||
"mk",
|
||||
"ms",
|
||||
"no",
|
||||
"fa",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-br",
|
||||
"ro",
|
||||
"ru",
|
||||
"sr",
|
||||
"sr-cyrl",
|
||||
"sr-latn",
|
||||
"sk",
|
||||
"sl",
|
||||
"es",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"hr"
|
||||
],
|
||||
"default": "None"
|
||||
},
|
||||
{
|
||||
"id": "langPref3a",
|
||||
"label": "Subtitle Language (3)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"None",
|
||||
"sq",
|
||||
"ar",
|
||||
"be",
|
||||
"bs",
|
||||
"bg",
|
||||
"ca",
|
||||
"zh",
|
||||
"cs",
|
||||
"da",
|
||||
"nl",
|
||||
"en",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"de",
|
||||
"el",
|
||||
"he",
|
||||
"hi",
|
||||
"hu",
|
||||
"is",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lv",
|
||||
"lt",
|
||||
"mk",
|
||||
"ms",
|
||||
"no",
|
||||
"fa",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-br",
|
||||
"ro",
|
||||
"ru",
|
||||
"sr",
|
||||
"sr-cyrl",
|
||||
"sr-latn",
|
||||
"sk",
|
||||
"sl",
|
||||
"es",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"hr"
|
||||
],
|
||||
"default": "None"
|
||||
},
|
||||
{
|
||||
"id": "langPrefCustom",
|
||||
"label": "Additional Subtitle Languages (use ISO-639-1 codes; comma-separated)",
|
||||
"type": "text",
|
||||
"default": "None"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.only_foreign",
|
||||
"label": "Only download foreign/forced subtitles",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.language.ietf_display",
|
||||
"label": "Display languages with country attribute as ISO 639-1 (e.g. pt-BR = pt)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.language.ietf_normalize",
|
||||
"label": "Treat languages with country attribute as ISO 639-1 (e.g. don't download pt-BR if pt subtitle exists)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.only_one",
|
||||
"label": "Restrict to one language (skips adding \".lang.\" to the subtitle filename; only uses \"Subtitle Language (1)\")",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.language.treat_und_as_first",
|
||||
"label": "Embedded subtitles: Treat \"Undefined\" (und) as language 1",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "media_rename1",
|
||||
"label": "I rename my files using",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"Sonarr/Radarr (fill api info below)",
|
||||
"Filebot",
|
||||
"Sonarr/Radarr/Filebot",
|
||||
"Symlink to original file",
|
||||
"I keep the original filenames",
|
||||
"none of the above"
|
||||
],
|
||||
"default": "I keep the original filenames"
|
||||
},
|
||||
{
|
||||
"id": "use_file_info_file",
|
||||
"label": "Retrieve original filename from .file_info/file_info index files (see wiki)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "drone_api.sonarr.url",
|
||||
"label": "Sonarr URL (add URL base if configured)",
|
||||
"type": "text",
|
||||
"default": "http://127.0.0.1:8989"
|
||||
},
|
||||
{
|
||||
"id": "drone_api.sonarr.api_key",
|
||||
"label": "Sonarr API key",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "drone_api.radarr.url",
|
||||
"label": "Radarr URL (add URL base if configured, min. version: 0.2.0.897)",
|
||||
"type": "text",
|
||||
"default": "http://127.0.0.1:7878"
|
||||
},
|
||||
{
|
||||
"id": "drone_api.radarr.api_key",
|
||||
"label": "Radarr API key",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.enabled",
|
||||
"label": "Provider: Enable OpenSubtitles",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.username",
|
||||
"label": "Opensubtitles Username",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.password",
|
||||
"label": "Opensubtitles Password",
|
||||
"type": "text",
|
||||
"option": "hidden",
|
||||
"default": "",
|
||||
"secure": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.is_vip",
|
||||
"label": "OpenSubtitles VIP? (ad-free subs, 1000 subs/day, no-cache VIP server: http://v.ht/osvip)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "provider.podnapisi.enabled",
|
||||
"label": "Provider: Enable Podnapisi.NET",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.titlovi.enabled",
|
||||
"label": "Provider: Enable Titlovi.com",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.enabled",
|
||||
"label": "Provider: Enable Addic7ed",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.username",
|
||||
"label": "Addic7ed Username",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.password",
|
||||
"label": "Addic7ed Password",
|
||||
"type": "text",
|
||||
"option": "hidden",
|
||||
"default": "",
|
||||
"secure": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.boost_by2",
|
||||
"label": "Addic7ed: boost score (if requirements met)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"100",
|
||||
"95",
|
||||
"90",
|
||||
"85",
|
||||
"80",
|
||||
"75",
|
||||
"70",
|
||||
"67",
|
||||
"65",
|
||||
"60",
|
||||
"55",
|
||||
"50",
|
||||
"45",
|
||||
"40",
|
||||
"35",
|
||||
"30",
|
||||
"25",
|
||||
"21",
|
||||
"20",
|
||||
"19",
|
||||
"15",
|
||||
"10",
|
||||
"5",
|
||||
"0"
|
||||
],
|
||||
"default": "19"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.use_random_agents1",
|
||||
"label": "Addic7ed: Use random user agents",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.legendastv.enabled",
|
||||
"label": "Provider: Enable Legendas TV (mostly pt-BR; UNRAR NEEDED)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "provider.legendastv.username",
|
||||
"label": "Legendas TV Username",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "provider.legendastv.password",
|
||||
"label": "Legendas TV Password",
|
||||
"type": "text",
|
||||
"option": "hidden",
|
||||
"default": "",
|
||||
"secure": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.tvsubtitles.enabled",
|
||||
"label": "Provider: Enable TVsubtitles.net",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.napiprojekt.enabled",
|
||||
"label": "Provider: Enable NapiProjekt.pl (Polish)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "provider.subscene.enabled",
|
||||
"label": "Provider: Enable SubScene (TV shows)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.hosszupuska.enabled",
|
||||
"label": "Provider: Enable hosszupuskasub.com (Hungarian)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "provider.argenteam.enabled",
|
||||
"label": "Provider: Enable aRGENTeaM (Spanish)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "providers.multithreading",
|
||||
"label": "Search enabled providers simultaneously (multithreading)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.embedded.autoextract",
|
||||
"label": "Automatically extract and use embedded subtitles upon media addition (with configured default mods)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search_after_autoextract",
|
||||
"label": "After automatic extraction of embedded subtitles, also immediately search for available subtitles?",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.scan.embedded",
|
||||
"label": "Don't search for subtitles of a language if there are embedded subtitles inside the media file (MKV/MP4)?",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.scan.external",
|
||||
"label": "Don't search for subtitles of a language if they already exist on the filesystem (metadata/filesystem)?",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.scan.filename_strictness",
|
||||
"label": "How strict should these subtitles existing on the filesystem be detected?",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"exact: media filename match",
|
||||
"loose: filename contains media filename",
|
||||
"any"
|
||||
],
|
||||
"default": "loose: filename contains media filename"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.scan.exotic_ext",
|
||||
"label": "Include non-text subtitle formats (anything else than .srt/.ssa/.ass/.vtt; embedded or external) in the above?",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.minimumTVScore2",
|
||||
"label": "Minimum score for TV (min: 240, def/sane: 337, min-ideal: 352; see http://v.ht/szscores)",
|
||||
"type": "text",
|
||||
"default": "337"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.minimumMovieScore2",
|
||||
"label": "Minimum score for movies (min: 60, def/sane: 69, min-ideal: 82; see http://v.ht/szscores)",
|
||||
"type": "text",
|
||||
"default": "60"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.hearingImpaired",
|
||||
"label": "Download hearing impaired subtitles.",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"prefer",
|
||||
"don't prefer",
|
||||
"force HI",
|
||||
"force non-HI"
|
||||
],
|
||||
"default": "don't prefer"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.remove_hi",
|
||||
"label": "Remove Hearing Impaired tags from downloaded subtitles",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.remove_tags",
|
||||
"label": "Remove style tags from downloaded subtitles (bold, italic, underline, colors, ...)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.fix_common",
|
||||
"label": "Fix common issues in subtitles",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.fix_ocr",
|
||||
"label": "Fix common OCR errors in downloaded subtitles",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.reverse_rtl",
|
||||
"label": "Reverse punctuation in RTL languages (heb)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.colors",
|
||||
"label": "Change colors of subtitles to",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"don't change",
|
||||
"white",
|
||||
"light-grey",
|
||||
"red",
|
||||
"green",
|
||||
"yellow",
|
||||
"blue",
|
||||
"magenta",
|
||||
"cyan",
|
||||
"black",
|
||||
"dark-red",
|
||||
"dark-green",
|
||||
"dark-yellow",
|
||||
"dark-blue",
|
||||
"dark-magenta",
|
||||
"dark-cyan",
|
||||
"dark-grey"
|
||||
],
|
||||
"default": "don't change"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.filesystem",
|
||||
"label": "Store subtitles next to media files (instead of metadata)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.formats",
|
||||
"label": "Subtitle formats to save (non-SRT only works if the previous option is enabled)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"SRT",
|
||||
"VTT",
|
||||
"SRT+VTT"
|
||||
],
|
||||
"default": "SRT"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.subFolder",
|
||||
"label": "Subtitle Folder (\"current folder\" is the folder the current media file lives in)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"current folder",
|
||||
"sub",
|
||||
"subs",
|
||||
"subtitle",
|
||||
"subtitles"
|
||||
],
|
||||
"default": "current folder"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.subFolder.Custom",
|
||||
"label": "Custom Subtitle folder (overrides \"Subtitle Folder\"; computes to real paths)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.metadata_fallback",
|
||||
"label": "Fall back to metadata storage if filesystem storage failed",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.chmod",
|
||||
"label": "Set subtitle file permissions to (integer, e.g.: 0775)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "subtitles.autoclean",
|
||||
"label": "Automatically delete leftover/unused (externally saved) subtitles",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "activity.on_playback",
|
||||
"label": "On media playback: search for missing subtitles (refresh item)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"never",
|
||||
"current media item",
|
||||
"next episode (series)",
|
||||
"hybrid: current item or next episode",
|
||||
"hybrid-plus: current item and next episode"
|
||||
],
|
||||
"default": "never"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.tasks.SearchAllRecentlyAddedMissing.frequency",
|
||||
"label": "Scheduler: Periodically search for recent items with missing subtitles",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"never",
|
||||
"every 6 hours",
|
||||
"every 12 hours",
|
||||
"every 24 hours"
|
||||
],
|
||||
"default": "every 6 hours"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.item_is_recent_age",
|
||||
"label": "Scheduler: Item age to be considered recent",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"1 days",
|
||||
"2 days",
|
||||
"3 days",
|
||||
"4 days",
|
||||
"1 weeks",
|
||||
"2 weeks",
|
||||
"3 weeks",
|
||||
"4 weeks",
|
||||
"5 weeks",
|
||||
"6 weeks",
|
||||
"12 weeks"
|
||||
],
|
||||
"default": "2 weeks"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.max_recent_items_per_library",
|
||||
"label": "Scheduler: Recent items to consider per library",
|
||||
"type": "text",
|
||||
"default": "1000"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.tasks.FindBetterSubtitles.frequency",
|
||||
"label": "Scheduler: Periodically search for better subtitles",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"never",
|
||||
"every 6 hours",
|
||||
"every 12 hours",
|
||||
"every 24 hours"
|
||||
],
|
||||
"default": "every 12 hours"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.tasks.FindBetterSubtitles.max_days_after_added",
|
||||
"label": "Scheduler: Days to search for better subtitles (max: 30 days)",
|
||||
"type": "text",
|
||||
"default": "7"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.tasks.FindBetterSubtitles.air_date_cutoff",
|
||||
"label": "Scheduler: Don't search for better subtitles if the item's air date is older than",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"don't limit",
|
||||
"1 year",
|
||||
"2 years",
|
||||
"3 years",
|
||||
"4 years",
|
||||
"5 years",
|
||||
"6 years",
|
||||
"7 years",
|
||||
"8 years",
|
||||
"9 years",
|
||||
"10 years"
|
||||
],
|
||||
"default": "1 year"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.tasks.FindBetterSubtitles.overwrite_manually_selected",
|
||||
"label": "Scheduler: Overwrite manually selected subtitles when better found",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.tasks.FindBetterSubtitles.overwrite_manually_modified",
|
||||
"label": "Scheduler: Overwrite subtitles with non-default subtitle modifications when better found",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "history_size",
|
||||
"label": "History: amount of items to store historical data for",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"50",
|
||||
"100",
|
||||
"150",
|
||||
"250",
|
||||
"500"
|
||||
],
|
||||
"default": "100"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.try_downloads",
|
||||
"label": "How many download tries per subtitle (on timeout or error)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"default": "2"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.ignore_fs",
|
||||
"label": "Ignore folders (with \"subzero.ignore/.subzero.ignore/.nosz\" files in them)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.ignore_paths",
|
||||
"label": "Ignore anything in the following paths (comma-separated)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "plugin_mode",
|
||||
"label": "Sub-Zero mode",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"agent + channel",
|
||||
"only agent",
|
||||
"only channel"
|
||||
],
|
||||
"default": "agent + channel"
|
||||
},
|
||||
{
|
||||
"id": "plugin_pin",
|
||||
"label": "Access PIN (any amount of numbers, 0-9)",
|
||||
"type": "text",
|
||||
"option": "hidden",
|
||||
"default": "",
|
||||
"secure": "true"
|
||||
},
|
||||
{
|
||||
"id": "plugin_pin_valid_for",
|
||||
"label": "Access PIN valid for minutes",
|
||||
"type": "text",
|
||||
"default": "10"
|
||||
},
|
||||
{
|
||||
"id": "plugin_pin_mode",
|
||||
"label": "Use PIN to restrict access to (needs plugin or PMS restart)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"disabled",
|
||||
"channel menu",
|
||||
"advanced menu"
|
||||
],
|
||||
"default": "disabled"
|
||||
},
|
||||
{
|
||||
"id": "notify_executable",
|
||||
"label": "Call this executable upon successful subtitle download (see Wiki for details)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "check_permissions",
|
||||
"label": "Check for correct folder permissions of every library on plugin start",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "new_style_cache",
|
||||
"label": "Use new style caching (for subliminal)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "low_impact_mode",
|
||||
"label": "Low impact mode (for remote filesystems)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "pms_request_timeout",
|
||||
"label": "Timeout for API requests sent to the PMS",
|
||||
"type": "text",
|
||||
"default": "15"
|
||||
},
|
||||
{
|
||||
"id": "proxy",
|
||||
"label": "HTTP proxy to use for providers (supports credentials)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "log_level",
|
||||
"label": "How verbose should the logging be?",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"CRITICAL",
|
||||
"ERROR",
|
||||
"WARNING",
|
||||
"INFO",
|
||||
"DEBUG"
|
||||
],
|
||||
"default": "WARNING"
|
||||
},
|
||||
{
|
||||
"id": "log_rotate_keep",
|
||||
"label": "How many log backups to keep?",
|
||||
"type": "text",
|
||||
"default": "5"
|
||||
},
|
||||
{
|
||||
"id": "log_debug_mods",
|
||||
"label": "Log subtitle modification (debug)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "log_console",
|
||||
"label": "Log to console (for development/debugging)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "track_usage",
|
||||
"label": "Collect anonymous usage statistics",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
}
|
||||
]
|
||||
|
||||
Regular → Executable
+27
-9
@@ -4,30 +4,48 @@
|
||||
<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.5.3</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<string>2.5.3.2422</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>
|
||||
<string>0</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.5.3.2422
|
||||
|
||||
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>
|
||||
|
||||
panni, 2018
|
||||
</div>
|
||||
</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 where, old_where
|
||||
|
||||
__version__ = "2017.11.05"
|
||||
@@ -0,0 +1,2 @@
|
||||
from certifi import where
|
||||
print(where())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
certifi.py
|
||||
~~~~~~~~~~
|
||||
|
||||
This module returns the installation location of cacert.pem.
|
||||
"""
|
||||
import os
|
||||
import warnings
|
||||
|
||||
|
||||
class DeprecatedBundleWarning(DeprecationWarning):
|
||||
"""
|
||||
The weak security bundle is being deprecated. Please bother your service
|
||||
provider to get them to stop using cross-signed roots.
|
||||
"""
|
||||
|
||||
|
||||
def where():
|
||||
f = os.path.dirname(__file__)
|
||||
|
||||
return os.path.join(f, 'cacert.pem')
|
||||
|
||||
|
||||
def old_where():
|
||||
warnings.warn(
|
||||
"The weak security bundle is being deprecated. It will be removed in "
|
||||
"2018.",
|
||||
DeprecatedBundleWarning
|
||||
)
|
||||
f = os.path.dirname(__file__)
|
||||
return os.path.join(f, 'weak.pem')
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(where())
|
||||
@@ -0,0 +1,414 @@
|
||||
# Issuer: CN=Entrust.net Secure Server Certification Authority O=Entrust.net OU=www.entrust.net/CPS incorp. by ref. (limits liab.)/(c) 1999 Entrust.net Limited
|
||||
# Subject: CN=Entrust.net Secure Server Certification Authority O=Entrust.net OU=www.entrust.net/CPS incorp. by ref. (limits liab.)/(c) 1999 Entrust.net Limited
|
||||
# Label: "Entrust.net Secure Server CA"
|
||||
# Serial: 927650371
|
||||
# MD5 Fingerprint: df:f2:80:73:cc:f1:e6:61:73:fc:f5:42:e9:c5:7c:ee
|
||||
# SHA1 Fingerprint: 99:a6:9b:e6:1a:fe:88:6b:4d:2b:82:00:7c:b8:54:fc:31:7e:15:39
|
||||
# SHA256 Fingerprint: 62:f2:40:27:8c:56:4c:4d:d8:bf:7d:9d:4f:6f:36:6e:a8:94:d2:2f:5f:34:d9:89:a9:83:ac:ec:2f:ff:ed:50
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIE2DCCBEGgAwIBAgIEN0rSQzANBgkqhkiG9w0BAQUFADCBwzELMAkGA1UEBhMC
|
||||
VVMxFDASBgNVBAoTC0VudHJ1c3QubmV0MTswOQYDVQQLEzJ3d3cuZW50cnVzdC5u
|
||||
ZXQvQ1BTIGluY29ycC4gYnkgcmVmLiAobGltaXRzIGxpYWIuKTElMCMGA1UECxMc
|
||||
KGMpIDE5OTkgRW50cnVzdC5uZXQgTGltaXRlZDE6MDgGA1UEAxMxRW50cnVzdC5u
|
||||
ZXQgU2VjdXJlIFNlcnZlciBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw05OTA1
|
||||
MjUxNjA5NDBaFw0xOTA1MjUxNjM5NDBaMIHDMQswCQYDVQQGEwJVUzEUMBIGA1UE
|
||||
ChMLRW50cnVzdC5uZXQxOzA5BgNVBAsTMnd3dy5lbnRydXN0Lm5ldC9DUFMgaW5j
|
||||
b3JwLiBieSByZWYuIChsaW1pdHMgbGlhYi4pMSUwIwYDVQQLExwoYykgMTk5OSBF
|
||||
bnRydXN0Lm5ldCBMaW1pdGVkMTowOAYDVQQDEzFFbnRydXN0Lm5ldCBTZWN1cmUg
|
||||
U2VydmVyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGdMA0GCSqGSIb3DQEBAQUA
|
||||
A4GLADCBhwKBgQDNKIM0VBuJ8w+vN5Ex/68xYMmo6LIQaO2f55M28Qpku0f1BBc/
|
||||
I0dNxScZgSYMVHINiC3ZH5oSn7yzcdOAGT9HZnuMNSjSuQrfJNqc1lB5gXpa0zf3
|
||||
wkrYKZImZNHkmGw6AIr1NJtl+O3jEP/9uElY3KDegjlrgbEWGWG5VLbmQwIBA6OC
|
||||
AdcwggHTMBEGCWCGSAGG+EIBAQQEAwIABzCCARkGA1UdHwSCARAwggEMMIHeoIHb
|
||||
oIHYpIHVMIHSMQswCQYDVQQGEwJVUzEUMBIGA1UEChMLRW50cnVzdC5uZXQxOzA5
|
||||
BgNVBAsTMnd3dy5lbnRydXN0Lm5ldC9DUFMgaW5jb3JwLiBieSByZWYuIChsaW1p
|
||||
dHMgbGlhYi4pMSUwIwYDVQQLExwoYykgMTk5OSBFbnRydXN0Lm5ldCBMaW1pdGVk
|
||||
MTowOAYDVQQDEzFFbnRydXN0Lm5ldCBTZWN1cmUgU2VydmVyIENlcnRpZmljYXRp
|
||||
b24gQXV0aG9yaXR5MQ0wCwYDVQQDEwRDUkwxMCmgJ6AlhiNodHRwOi8vd3d3LmVu
|
||||
dHJ1c3QubmV0L0NSTC9uZXQxLmNybDArBgNVHRAEJDAigA8xOTk5MDUyNTE2MDk0
|
||||
MFqBDzIwMTkwNTI1MTYwOTQwWjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAU8Bdi
|
||||
E1U9s/8KAGv7UISX8+1i0BowHQYDVR0OBBYEFPAXYhNVPbP/CgBr+1CEl/PtYtAa
|
||||
MAwGA1UdEwQFMAMBAf8wGQYJKoZIhvZ9B0EABAwwChsEVjQuMAMCBJAwDQYJKoZI
|
||||
hvcNAQEFBQADgYEAkNwwAvpkdMKnCqV8IY00F6j7Rw7/JXyNEwr75Ji174z4xRAN
|
||||
95K+8cPV1ZVqBLssziY2ZcgxxufuP+NXdYR6Ee9GTxj005i7qIcyunL2POI9n9cd
|
||||
2cNgQ4xYDiKWL2KjLB+6rQXvqzJ4h6BUcxm1XAX5Uj5tLUUL9wqT6u0G+bI=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 2 Policy Validation Authority
|
||||
# Subject: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 2 Policy Validation Authority
|
||||
# Label: "ValiCert Class 2 VA"
|
||||
# Serial: 1
|
||||
# MD5 Fingerprint: a9:23:75:9b:ba:49:36:6e:31:c2:db:f2:e7:66:ba:87
|
||||
# SHA1 Fingerprint: 31:7a:2a:d0:7f:2b:33:5e:f5:a1:c3:4e:4b:57:e8:b7:d8:f1:fc:a6
|
||||
# SHA256 Fingerprint: 58:d0:17:27:9c:d4:dc:63:ab:dd:b1:96:a6:c9:90:6c:30:c4:e0:87:83:ea:e8:c1:60:99:54:d6:93:55:59:6b
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC5zCCAlACAQEwDQYJKoZIhvcNAQEFBQAwgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0
|
||||
IFZhbGlkYXRpb24gTmV0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAz
|
||||
BgNVBAsTLFZhbGlDZXJ0IENsYXNzIDIgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9y
|
||||
aXR5MSEwHwYDVQQDExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG
|
||||
9w0BCQEWEWluZm9AdmFsaWNlcnQuY29tMB4XDTk5MDYyNjAwMTk1NFoXDTE5MDYy
|
||||
NjAwMTk1NFowgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0IFZhbGlkYXRpb24gTmV0d29y
|
||||
azEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAzBgNVBAsTLFZhbGlDZXJ0IENs
|
||||
YXNzIDIgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9yaXR5MSEwHwYDVQQDExhodHRw
|
||||
Oi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG9w0BCQEWEWluZm9AdmFsaWNl
|
||||
cnQuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDOOnHK5avIWZJV16vY
|
||||
dA757tn2VUdZZUcOBVXc65g2PFxTXdMwzzjsvUGJ7SVCCSRrCl6zfN1SLUzm1NZ9
|
||||
WlmpZdRJEy0kTRxQb7XBhVQ7/nHk01xC+YDgkRoKWzk2Z/M/VXwbP7RfZHM047QS
|
||||
v4dk+NoS/zcnwbNDu+97bi5p9wIDAQABMA0GCSqGSIb3DQEBBQUAA4GBADt/UG9v
|
||||
UJSZSWI4OB9L+KXIPqeCgfYrx+jFzug6EILLGACOTb2oWH+heQC1u+mNr0HZDzTu
|
||||
IYEZoDJJKPTEjlbVUjP9UNV+mWwD5MlM/Mtsq2azSiGM5bUMMj4QssxsodyamEwC
|
||||
W/POuZ6lcg5Ktz885hZo+L7tdEy8W9ViH0Pd
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=NetLock Expressz (Class C) Tanusitvanykiado O=NetLock Halozatbiztonsagi Kft. OU=Tanusitvanykiadok
|
||||
# Subject: CN=NetLock Expressz (Class C) Tanusitvanykiado O=NetLock Halozatbiztonsagi Kft. OU=Tanusitvanykiadok
|
||||
# Label: "NetLock Express (Class C) Root"
|
||||
# Serial: 104
|
||||
# MD5 Fingerprint: 4f:eb:f1:f0:70:c2:80:63:5d:58:9f:da:12:3c:a9:c4
|
||||
# SHA1 Fingerprint: e3:92:51:2f:0a:cf:f5:05:df:f6:de:06:7f:75:37:e1:65:ea:57:4b
|
||||
# SHA256 Fingerprint: 0b:5e:ed:4e:84:64:03:cf:55:e0:65:84:84:40:ed:2a:82:75:8b:f5:b9:aa:1f:25:3d:46:13:cf:a0:80:ff:3f
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFTzCCBLigAwIBAgIBaDANBgkqhkiG9w0BAQQFADCBmzELMAkGA1UEBhMCSFUx
|
||||
ETAPBgNVBAcTCEJ1ZGFwZXN0MScwJQYDVQQKEx5OZXRMb2NrIEhhbG96YXRiaXp0
|
||||
b25zYWdpIEtmdC4xGjAYBgNVBAsTEVRhbnVzaXR2YW55a2lhZG9rMTQwMgYDVQQD
|
||||
EytOZXRMb2NrIEV4cHJlc3N6IChDbGFzcyBDKSBUYW51c2l0dmFueWtpYWRvMB4X
|
||||
DTk5MDIyNTE0MDgxMVoXDTE5MDIyMDE0MDgxMVowgZsxCzAJBgNVBAYTAkhVMREw
|
||||
DwYDVQQHEwhCdWRhcGVzdDEnMCUGA1UEChMeTmV0TG9jayBIYWxvemF0Yml6dG9u
|
||||
c2FnaSBLZnQuMRowGAYDVQQLExFUYW51c2l0dmFueWtpYWRvazE0MDIGA1UEAxMr
|
||||
TmV0TG9jayBFeHByZXNzeiAoQ2xhc3MgQykgVGFudXNpdHZhbnlraWFkbzCBnzAN
|
||||
BgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA6+ywbGGKIyWvYCDj2Z/8kwvbXY2wobNA
|
||||
OoLO/XXgeDIDhlqGlZHtU/qdQPzm6N3ZW3oDvV3zOwzDUXmbrVWg6dADEK8KuhRC
|
||||
2VImESLH0iDMgqSaqf64gXadarfSNnU+sYYJ9m5tfk63euyucYT2BDMIJTLrdKwW
|
||||
RMbkQJMdf60CAwEAAaOCAp8wggKbMBIGA1UdEwEB/wQIMAYBAf8CAQQwDgYDVR0P
|
||||
AQH/BAQDAgAGMBEGCWCGSAGG+EIBAQQEAwIABzCCAmAGCWCGSAGG+EIBDQSCAlEW
|
||||
ggJNRklHWUVMRU0hIEV6ZW4gdGFudXNpdHZhbnkgYSBOZXRMb2NrIEtmdC4gQWx0
|
||||
YWxhbm9zIFN6b2xnYWx0YXRhc2kgRmVsdGV0ZWxlaWJlbiBsZWlydCBlbGphcmFz
|
||||
b2sgYWxhcGphbiBrZXN6dWx0LiBBIGhpdGVsZXNpdGVzIGZvbHlhbWF0YXQgYSBO
|
||||
ZXRMb2NrIEtmdC4gdGVybWVrZmVsZWxvc3NlZy1iaXp0b3NpdGFzYSB2ZWRpLiBB
|
||||
IGRpZ2l0YWxpcyBhbGFpcmFzIGVsZm9nYWRhc2FuYWsgZmVsdGV0ZWxlIGF6IGVs
|
||||
b2lydCBlbGxlbm9yemVzaSBlbGphcmFzIG1lZ3RldGVsZS4gQXogZWxqYXJhcyBs
|
||||
ZWlyYXNhIG1lZ3RhbGFsaGF0byBhIE5ldExvY2sgS2Z0LiBJbnRlcm5ldCBob25s
|
||||
YXBqYW4gYSBodHRwczovL3d3dy5uZXRsb2NrLm5ldC9kb2NzIGNpbWVuIHZhZ3kg
|
||||
a2VyaGV0byBheiBlbGxlbm9yemVzQG5ldGxvY2submV0IGUtbWFpbCBjaW1lbi4g
|
||||
SU1QT1JUQU5UISBUaGUgaXNzdWFuY2UgYW5kIHRoZSB1c2Ugb2YgdGhpcyBjZXJ0
|
||||
aWZpY2F0ZSBpcyBzdWJqZWN0IHRvIHRoZSBOZXRMb2NrIENQUyBhdmFpbGFibGUg
|
||||
YXQgaHR0cHM6Ly93d3cubmV0bG9jay5uZXQvZG9jcyBvciBieSBlLW1haWwgYXQg
|
||||
Y3BzQG5ldGxvY2submV0LjANBgkqhkiG9w0BAQQFAAOBgQAQrX/XDDKACtiG8XmY
|
||||
ta3UzbM2xJZIwVzNmtkFLp++UOv0JhQQLdRmF/iewSf98e3ke0ugbLWrmldwpu2g
|
||||
pO0u9f38vf5NNwgMvOOWgyL1SRt/Syu0VMGAfJlOHdCM7tCs5ZL6dVb+ZKATj7i4
|
||||
Fp1hBWeAyNDYpQcCNJgEjTME1A==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=NetLock Uzleti (Class B) Tanusitvanykiado O=NetLock Halozatbiztonsagi Kft. OU=Tanusitvanykiadok
|
||||
# Subject: CN=NetLock Uzleti (Class B) Tanusitvanykiado O=NetLock Halozatbiztonsagi Kft. OU=Tanusitvanykiadok
|
||||
# Label: "NetLock Business (Class B) Root"
|
||||
# Serial: 105
|
||||
# MD5 Fingerprint: 39:16:aa:b9:6a:41:e1:14:69:df:9e:6c:3b:72:dc:b6
|
||||
# SHA1 Fingerprint: 87:9f:4b:ee:05:df:98:58:3b:e3:60:d6:33:e7:0d:3f:fe:98:71:af
|
||||
# SHA256 Fingerprint: 39:df:7b:68:2b:7b:93:8f:84:71:54:81:cc:de:8d:60:d8:f2:2e:c5:98:87:7d:0a:aa:c1:2b:59:18:2b:03:12
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFSzCCBLSgAwIBAgIBaTANBgkqhkiG9w0BAQQFADCBmTELMAkGA1UEBhMCSFUx
|
||||
ETAPBgNVBAcTCEJ1ZGFwZXN0MScwJQYDVQQKEx5OZXRMb2NrIEhhbG96YXRiaXp0
|
||||
b25zYWdpIEtmdC4xGjAYBgNVBAsTEVRhbnVzaXR2YW55a2lhZG9rMTIwMAYDVQQD
|
||||
EylOZXRMb2NrIFV6bGV0aSAoQ2xhc3MgQikgVGFudXNpdHZhbnlraWFkbzAeFw05
|
||||
OTAyMjUxNDEwMjJaFw0xOTAyMjAxNDEwMjJaMIGZMQswCQYDVQQGEwJIVTERMA8G
|
||||
A1UEBxMIQnVkYXBlc3QxJzAlBgNVBAoTHk5ldExvY2sgSGFsb3phdGJpenRvbnNh
|
||||
Z2kgS2Z0LjEaMBgGA1UECxMRVGFudXNpdHZhbnlraWFkb2sxMjAwBgNVBAMTKU5l
|
||||
dExvY2sgVXpsZXRpIChDbGFzcyBCKSBUYW51c2l0dmFueWtpYWRvMIGfMA0GCSqG
|
||||
SIb3DQEBAQUAA4GNADCBiQKBgQCx6gTsIKAjwo84YM/HRrPVG/77uZmeBNwcf4xK
|
||||
gZjupNTKihe5In+DCnVMm8Bp2GQ5o+2So/1bXHQawEfKOml2mrriRBf8TKPV/riX
|
||||
iK+IA4kfpPIEPsgHC+b5sy96YhQJRhTKZPWLgLViqNhr1nGTLbO/CVRY7QbrqHvc
|
||||
Q7GhaQIDAQABo4ICnzCCApswEgYDVR0TAQH/BAgwBgEB/wIBBDAOBgNVHQ8BAf8E
|
||||
BAMCAAYwEQYJYIZIAYb4QgEBBAQDAgAHMIICYAYJYIZIAYb4QgENBIICURaCAk1G
|
||||
SUdZRUxFTSEgRXplbiB0YW51c2l0dmFueSBhIE5ldExvY2sgS2Z0LiBBbHRhbGFu
|
||||
b3MgU3pvbGdhbHRhdGFzaSBGZWx0ZXRlbGVpYmVuIGxlaXJ0IGVsamFyYXNvayBh
|
||||
bGFwamFuIGtlc3p1bHQuIEEgaGl0ZWxlc2l0ZXMgZm9seWFtYXRhdCBhIE5ldExv
|
||||
Y2sgS2Z0LiB0ZXJtZWtmZWxlbG9zc2VnLWJpenRvc2l0YXNhIHZlZGkuIEEgZGln
|
||||
aXRhbGlzIGFsYWlyYXMgZWxmb2dhZGFzYW5hayBmZWx0ZXRlbGUgYXogZWxvaXJ0
|
||||
IGVsbGVub3J6ZXNpIGVsamFyYXMgbWVndGV0ZWxlLiBBeiBlbGphcmFzIGxlaXJh
|
||||
c2EgbWVndGFsYWxoYXRvIGEgTmV0TG9jayBLZnQuIEludGVybmV0IGhvbmxhcGph
|
||||
biBhIGh0dHBzOi8vd3d3Lm5ldGxvY2submV0L2RvY3MgY2ltZW4gdmFneSBrZXJo
|
||||
ZXRvIGF6IGVsbGVub3J6ZXNAbmV0bG9jay5uZXQgZS1tYWlsIGNpbWVuLiBJTVBP
|
||||
UlRBTlQhIFRoZSBpc3N1YW5jZSBhbmQgdGhlIHVzZSBvZiB0aGlzIGNlcnRpZmlj
|
||||
YXRlIGlzIHN1YmplY3QgdG8gdGhlIE5ldExvY2sgQ1BTIGF2YWlsYWJsZSBhdCBo
|
||||
dHRwczovL3d3dy5uZXRsb2NrLm5ldC9kb2NzIG9yIGJ5IGUtbWFpbCBhdCBjcHNA
|
||||
bmV0bG9jay5uZXQuMA0GCSqGSIb3DQEBBAUAA4GBAATbrowXr/gOkDFOzT4JwG06
|
||||
sPgzTEdM43WIEJessDgVkcYplswhwG08pXTP2IKlOcNl40JwuyKQ433bNXbhoLXa
|
||||
n3BukxowOR0w2y7jfLKRstE3Kfq51hdcR0/jHTjrn9V7lagonhVK0dHQKwCXoOKS
|
||||
NitjrFgBazMpUIaD8QFI
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 3 Policy Validation Authority
|
||||
# Subject: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 3 Policy Validation Authority
|
||||
# Label: "RSA Root Certificate 1"
|
||||
# Serial: 1
|
||||
# MD5 Fingerprint: a2:6f:53:b7:ee:40:db:4a:68:e7:fa:18:d9:10:4b:72
|
||||
# SHA1 Fingerprint: 69:bd:8c:f4:9c:d3:00:fb:59:2e:17:93:ca:55:6a:f3:ec:aa:35:fb
|
||||
# SHA256 Fingerprint: bc:23:f9:8a:31:3c:b9:2d:e3:bb:fc:3a:5a:9f:44:61:ac:39:49:4c:4a:e1:5a:9e:9d:f1:31:e9:9b:73:01:9a
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC5zCCAlACAQEwDQYJKoZIhvcNAQEFBQAwgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0
|
||||
IFZhbGlkYXRpb24gTmV0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAz
|
||||
BgNVBAsTLFZhbGlDZXJ0IENsYXNzIDMgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9y
|
||||
aXR5MSEwHwYDVQQDExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG
|
||||
9w0BCQEWEWluZm9AdmFsaWNlcnQuY29tMB4XDTk5MDYyNjAwMjIzM1oXDTE5MDYy
|
||||
NjAwMjIzM1owgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0IFZhbGlkYXRpb24gTmV0d29y
|
||||
azEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAzBgNVBAsTLFZhbGlDZXJ0IENs
|
||||
YXNzIDMgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9yaXR5MSEwHwYDVQQDExhodHRw
|
||||
Oi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG9w0BCQEWEWluZm9AdmFsaWNl
|
||||
cnQuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDjmFGWHOjVsQaBalfD
|
||||
cnWTq8+epvzzFlLWLU2fNUSoLgRNB0mKOCn1dzfnt6td3zZxFJmP3MKS8edgkpfs
|
||||
2Ejcv8ECIMYkpChMMFp2bbFc893enhBxoYjHW5tBbcqwuI4V7q0zK89HBFx1cQqY
|
||||
JJgpp0lZpd34t0NiYfPT4tBVPwIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAFa7AliE
|
||||
Zwgs3x/be0kz9dNnnfS0ChCzycUs4pJqcXgn8nCDQtM+z6lU9PHYkhaM0QTLS6vJ
|
||||
n0WuPIqpsHEzXcjFV9+vqDWzf4mH6eglkrh/hXqu1rweN1gqZ8mRzyqBPu3GOd/A
|
||||
PhmcGcwTTYJBtYze4D1gCCAPRX5ron+jjBXu
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 1 Policy Validation Authority
|
||||
# Subject: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 1 Policy Validation Authority
|
||||
# Label: "ValiCert Class 1 VA"
|
||||
# Serial: 1
|
||||
# MD5 Fingerprint: 65:58:ab:15:ad:57:6c:1e:a8:a7:b5:69:ac:bf:ff:eb
|
||||
# SHA1 Fingerprint: e5:df:74:3c:b6:01:c4:9b:98:43:dc:ab:8c:e8:6a:81:10:9f:e4:8e
|
||||
# SHA256 Fingerprint: f4:c1:49:55:1a:30:13:a3:5b:c7:bf:fe:17:a7:f3:44:9b:c1:ab:5b:5a:0a:e7:4b:06:c2:3b:90:00:4c:01:04
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC5zCCAlACAQEwDQYJKoZIhvcNAQEFBQAwgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0
|
||||
IFZhbGlkYXRpb24gTmV0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAz
|
||||
BgNVBAsTLFZhbGlDZXJ0IENsYXNzIDEgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9y
|
||||
aXR5MSEwHwYDVQQDExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG
|
||||
9w0BCQEWEWluZm9AdmFsaWNlcnQuY29tMB4XDTk5MDYyNTIyMjM0OFoXDTE5MDYy
|
||||
NTIyMjM0OFowgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0IFZhbGlkYXRpb24gTmV0d29y
|
||||
azEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAzBgNVBAsTLFZhbGlDZXJ0IENs
|
||||
YXNzIDEgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9yaXR5MSEwHwYDVQQDExhodHRw
|
||||
Oi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG9w0BCQEWEWluZm9AdmFsaWNl
|
||||
cnQuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDYWYJ6ibiWuqYvaG9Y
|
||||
LqdUHAZu9OqNSLwxlBfw8068srg1knaw0KWlAdcAAxIiGQj4/xEjm84H9b9pGib+
|
||||
TunRf50sQB1ZaG6m+FiwnRqP0z/x3BkGgagO4DrdyFNFCQbmD3DD+kCmDuJWBQ8Y
|
||||
TfwggtFzVXSNdnKgHZ0dwN0/cQIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAFBoPUn0
|
||||
LBwGlN+VYH+Wexf+T3GtZMjdd9LvWVXoP+iOBSoh8gfStadS/pyxtuJbdxdA6nLW
|
||||
I8sogTLDAHkY7FkXicnGah5xyf23dKUlRWnFSKsZ4UWKJWsZ7uW7EvV/96aNUcPw
|
||||
nXS3qT6gpf+2SQMT2iLM7XGCK5nPOrf1LXLI
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=Equifax Secure eBusiness CA-1 O=Equifax Secure Inc.
|
||||
# Subject: CN=Equifax Secure eBusiness CA-1 O=Equifax Secure Inc.
|
||||
# Label: "Equifax Secure eBusiness CA 1"
|
||||
# Serial: 4
|
||||
# MD5 Fingerprint: 64:9c:ef:2e:44:fc:c6:8f:52:07:d0:51:73:8f:cb:3d
|
||||
# SHA1 Fingerprint: da:40:18:8b:91:89:a3:ed:ee:ae:da:97:fe:2f:9d:f5:b7:d1:8a:41
|
||||
# SHA256 Fingerprint: cf:56:ff:46:a4:a1:86:10:9d:d9:65:84:b5:ee:b5:8a:51:0c:42:75:b0:e5:f9:4f:40:bb:ae:86:5e:19:f6:73
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICgjCCAeugAwIBAgIBBDANBgkqhkiG9w0BAQQFADBTMQswCQYDVQQGEwJVUzEc
|
||||
MBoGA1UEChMTRXF1aWZheCBTZWN1cmUgSW5jLjEmMCQGA1UEAxMdRXF1aWZheCBT
|
||||
ZWN1cmUgZUJ1c2luZXNzIENBLTEwHhcNOTkwNjIxMDQwMDAwWhcNMjAwNjIxMDQw
|
||||
MDAwWjBTMQswCQYDVQQGEwJVUzEcMBoGA1UEChMTRXF1aWZheCBTZWN1cmUgSW5j
|
||||
LjEmMCQGA1UEAxMdRXF1aWZheCBTZWN1cmUgZUJ1c2luZXNzIENBLTEwgZ8wDQYJ
|
||||
KoZIhvcNAQEBBQADgY0AMIGJAoGBAM4vGbwXt3fek6lfWg0XTzQaDJj0ItlZ1MRo
|
||||
RvC0NcWFAyDGr0WlIVFFQesWWDYyb+JQYmT5/VGcqiTZ9J2DKocKIdMSODRsjQBu
|
||||
WqDZQu4aIZX5UkxVWsUPOE9G+m34LjXWHXzr4vCwdYDIqROsvojvOm6rXyo4YgKw
|
||||
Env+j6YDAgMBAAGjZjBkMBEGCWCGSAGG+EIBAQQEAwIABzAPBgNVHRMBAf8EBTAD
|
||||
AQH/MB8GA1UdIwQYMBaAFEp4MlIR21kWNl7fwRQ2QGpHfEyhMB0GA1UdDgQWBBRK
|
||||
eDJSEdtZFjZe38EUNkBqR3xMoTANBgkqhkiG9w0BAQQFAAOBgQB1W6ibAxHm6VZM
|
||||
zfmpTMANmvPMZWnmJXbMWbfWVMMdzZmsGd20hdXgPfxiIKeES1hl8eL5lSE/9dR+
|
||||
WB5Hh1Q+WKG1tfgq73HnvMP2sUlG4tega+VWeponmHxGYhTnyfxuAxJ5gDgdSIKN
|
||||
/Bf+KpYrtWKmpj29f5JZzVoqgrI3eQ==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=Equifax Secure Global eBusiness CA-1 O=Equifax Secure Inc.
|
||||
# Subject: CN=Equifax Secure Global eBusiness CA-1 O=Equifax Secure Inc.
|
||||
# Label: "Equifax Secure Global eBusiness CA"
|
||||
# Serial: 1
|
||||
# MD5 Fingerprint: 8f:5d:77:06:27:c4:98:3c:5b:93:78:e7:d7:7d:9b:cc
|
||||
# SHA1 Fingerprint: 7e:78:4a:10:1c:82:65:cc:2d:e1:f1:6d:47:b4:40:ca:d9:0a:19:45
|
||||
# SHA256 Fingerprint: 5f:0b:62:ea:b5:e3:53:ea:65:21:65:16:58:fb:b6:53:59:f4:43:28:0a:4a:fb:d1:04:d7:7d:10:f9:f0:4c:07
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICkDCCAfmgAwIBAgIBATANBgkqhkiG9w0BAQQFADBaMQswCQYDVQQGEwJVUzEc
|
||||
MBoGA1UEChMTRXF1aWZheCBTZWN1cmUgSW5jLjEtMCsGA1UEAxMkRXF1aWZheCBT
|
||||
ZWN1cmUgR2xvYmFsIGVCdXNpbmVzcyBDQS0xMB4XDTk5MDYyMTA0MDAwMFoXDTIw
|
||||
MDYyMTA0MDAwMFowWjELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE0VxdWlmYXggU2Vj
|
||||
dXJlIEluYy4xLTArBgNVBAMTJEVxdWlmYXggU2VjdXJlIEdsb2JhbCBlQnVzaW5l
|
||||
c3MgQ0EtMTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAuucXkAJlsTRVPEnC
|
||||
UdXfp9E3j9HngXNBUmCbnaEXJnitx7HoJpQytd4zjTov2/KaelpzmKNc6fuKcxtc
|
||||
58O/gGzNqfTWK8D3+ZmqY6KxRwIP1ORROhI8bIpaVIRw28HFkM9yRcuoWcDNM50/
|
||||
o5brhTMhHD4ePmBudpxnhcXIw2ECAwEAAaNmMGQwEQYJYIZIAYb4QgEBBAQDAgAH
|
||||
MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUvqigdHJQa0S3ySPY+6j/s1dr
|
||||
aGwwHQYDVR0OBBYEFL6ooHRyUGtEt8kj2Puo/7NXa2hsMA0GCSqGSIb3DQEBBAUA
|
||||
A4GBADDiAVGqx+pf2rnQZQ8w1j7aDRRJbpGTJxQx78T3LUX47Me/okENI7SS+RkA
|
||||
Z70Br83gcfxaz2TE4JaY0KNA4gGK7ycH8WUBikQtBmV1UsCGECAhX2xrD2yuCRyv
|
||||
8qIYNMR1pHMc8Y3c7635s3a0kr/clRAevsvIO1qEYBlWlKlV
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=Thawte Premium Server CA O=Thawte Consulting cc OU=Certification Services Division
|
||||
# Subject: CN=Thawte Premium Server CA O=Thawte Consulting cc OU=Certification Services Division
|
||||
# Label: "Thawte Premium Server CA"
|
||||
# Serial: 1
|
||||
# MD5 Fingerprint: 06:9f:69:79:16:66:90:02:1b:8c:8c:a2:c3:07:6f:3a
|
||||
# SHA1 Fingerprint: 62:7f:8d:78:27:65:63:99:d2:7d:7f:90:44:c9:fe:b3:f3:3e:fa:9a
|
||||
# SHA256 Fingerprint: ab:70:36:36:5c:71:54:aa:29:c2:c2:9f:5d:41:91:16:3b:16:2a:22:25:01:13:57:d5:6d:07:ff:a7:bc:1f:72
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDJzCCApCgAwIBAgIBATANBgkqhkiG9w0BAQQFADCBzjELMAkGA1UEBhMCWkEx
|
||||
FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTESMBAGA1UEBxMJQ2FwZSBUb3duMR0wGwYD
|
||||
VQQKExRUaGF3dGUgQ29uc3VsdGluZyBjYzEoMCYGA1UECxMfQ2VydGlmaWNhdGlv
|
||||
biBTZXJ2aWNlcyBEaXZpc2lvbjEhMB8GA1UEAxMYVGhhd3RlIFByZW1pdW0gU2Vy
|
||||
dmVyIENBMSgwJgYJKoZIhvcNAQkBFhlwcmVtaXVtLXNlcnZlckB0aGF3dGUuY29t
|
||||
MB4XDTk2MDgwMTAwMDAwMFoXDTIwMTIzMTIzNTk1OVowgc4xCzAJBgNVBAYTAlpB
|
||||
MRUwEwYDVQQIEwxXZXN0ZXJuIENhcGUxEjAQBgNVBAcTCUNhcGUgVG93bjEdMBsG
|
||||
A1UEChMUVGhhd3RlIENvbnN1bHRpbmcgY2MxKDAmBgNVBAsTH0NlcnRpZmljYXRp
|
||||
b24gU2VydmljZXMgRGl2aXNpb24xITAfBgNVBAMTGFRoYXd0ZSBQcmVtaXVtIFNl
|
||||
cnZlciBDQTEoMCYGCSqGSIb3DQEJARYZcHJlbWl1bS1zZXJ2ZXJAdGhhd3RlLmNv
|
||||
bTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA0jY2aovXwlue2oFBYo847kkE
|
||||
VdbQ7xwblRZH7xhINTpS9CtqBo87L+pW46+GjZ4X9560ZXUCTe/LCaIhUdib0GfQ
|
||||
ug2SBhRz1JPLlyoAnFxODLz6FVL88kRu2hFKbgifLy3j+ao6hnO2RlNYyIkFvYMR
|
||||
uHM/qgeN9EJN50CdHDcCAwEAAaMTMBEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG
|
||||
9w0BAQQFAAOBgQAmSCwWwlj66BZ0DKqqX1Q/8tfJeGBeXm43YyJ3Nn6yF8Q0ufUI
|
||||
hfzJATj/Tb7yFkJD57taRvvBxhEf8UqwKEbJw8RCfbz6q1lu1bdRiBHjpIUZa4JM
|
||||
pAwSremkrj/xw0llmozFyD4lt5SZu5IycQfwhl7tUCemDaYj+bvLpgcUQg==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=Thawte Server CA O=Thawte Consulting cc OU=Certification Services Division
|
||||
# Subject: CN=Thawte Server CA O=Thawte Consulting cc OU=Certification Services Division
|
||||
# Label: "Thawte Server CA"
|
||||
# Serial: 1
|
||||
# MD5 Fingerprint: c5:70:c4:a2:ed:53:78:0c:c8:10:53:81:64:cb:d0:1d
|
||||
# SHA1 Fingerprint: 23:e5:94:94:51:95:f2:41:48:03:b4:d5:64:d2:a3:a3:f5:d8:8b:8c
|
||||
# SHA256 Fingerprint: b4:41:0b:73:e2:e6:ea:ca:47:fb:c4:2f:8f:a4:01:8a:f4:38:1d:c5:4c:fa:a8:44:50:46:1e:ed:09:45:4d:e9
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDEzCCAnygAwIBAgIBATANBgkqhkiG9w0BAQQFADCBxDELMAkGA1UEBhMCWkEx
|
||||
FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTESMBAGA1UEBxMJQ2FwZSBUb3duMR0wGwYD
|
||||
VQQKExRUaGF3dGUgQ29uc3VsdGluZyBjYzEoMCYGA1UECxMfQ2VydGlmaWNhdGlv
|
||||
biBTZXJ2aWNlcyBEaXZpc2lvbjEZMBcGA1UEAxMQVGhhd3RlIFNlcnZlciBDQTEm
|
||||
MCQGCSqGSIb3DQEJARYXc2VydmVyLWNlcnRzQHRoYXd0ZS5jb20wHhcNOTYwODAx
|
||||
MDAwMDAwWhcNMjAxMjMxMjM1OTU5WjCBxDELMAkGA1UEBhMCWkExFTATBgNVBAgT
|
||||
DFdlc3Rlcm4gQ2FwZTESMBAGA1UEBxMJQ2FwZSBUb3duMR0wGwYDVQQKExRUaGF3
|
||||
dGUgQ29uc3VsdGluZyBjYzEoMCYGA1UECxMfQ2VydGlmaWNhdGlvbiBTZXJ2aWNl
|
||||
cyBEaXZpc2lvbjEZMBcGA1UEAxMQVGhhd3RlIFNlcnZlciBDQTEmMCQGCSqGSIb3
|
||||
DQEJARYXc2VydmVyLWNlcnRzQHRoYXd0ZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQAD
|
||||
gY0AMIGJAoGBANOkUG7I/1Zr5s9dtuoMaHVHoqrC2oQl/Kj0R1HahbUgdJSGHg91
|
||||
yekIYfUGbTBuFRkC6VLAYttNmZ7iagxEOM3+vuNkCXDF/rFrKbYvScg71CcEJRCX
|
||||
L+eQbcAoQpnXTEPew/UhbVSfXcNY4cDk2VuwuNy0e982OsK1ZiIS1ocNAgMBAAGj
|
||||
EzARMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAB/pMaVz7lcxG
|
||||
7oWDTSEwjsrZqG9JGubaUeNgcGyEYRGhGshIPllDfU+VPaGLtwtimHp1it2ITk6e
|
||||
QNuozDJ0uW8NxuOzRAvZim+aKZuZGCg70eNAKJpaPNW15yAbi8qkq43pUdniTCxZ
|
||||
qdq5snUb9kLy78fyGPmJvKP/iiMucEc=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
|
||||
# Subject: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
|
||||
# Label: "Verisign Class 3 Public Primary Certification Authority"
|
||||
# Serial: 149843929435818692848040365716851702463
|
||||
# MD5 Fingerprint: 10:fc:63:5d:f6:26:3e:0d:f3:25:be:5f:79:cd:67:67
|
||||
# SHA1 Fingerprint: 74:2c:31:92:e6:07:e4:24:eb:45:49:54:2b:e1:bb:c5:3e:61:74:e2
|
||||
# SHA256 Fingerprint: e7:68:56:34:ef:ac:f6:9a:ce:93:9a:6b:25:5b:7b:4f:ab:ef:42:93:5b:50:a2:65:ac:b5:cb:60:27:e4:4e:70
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICPDCCAaUCEHC65B0Q2Sk0tjjKewPMur8wDQYJKoZIhvcNAQECBQAwXzELMAkG
|
||||
A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz
|
||||
cyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2
|
||||
MDEyOTAwMDAwMFoXDTI4MDgwMTIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV
|
||||
BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAzIFB1YmxpYyBQcmlt
|
||||
YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN
|
||||
ADCBiQKBgQDJXFme8huKARS0EN8EQNvjV69qRUCPhAwL0TPZ2RHP7gJYHyX3KqhE
|
||||
BarsAx94f56TuZoAqiN91qyFomNFx3InzPRMxnVx0jnvT0Lwdd8KkMaOIG+YD/is
|
||||
I19wKTakyYbnsZogy1Olhec9vn2a/iRFM9x2Fe0PonFkTGUugWhFpwIDAQABMA0G
|
||||
CSqGSIb3DQEBAgUAA4GBALtMEivPLCYATxQT3ab7/AoRhIzzKBxnki98tsX63/Do
|
||||
lbwdj2wsqFHMc9ikwFPwTtYmwHYBV4GSXiHx0bH/59AhWM1pF+NEHJwZRDmJXNyc
|
||||
AA9WjQKZ7aKQRUzkuxCkPfAyAw7xzvjoyVGM5mKf5p/AfbdynMk2OmufTqj/ZA1k
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
|
||||
# Subject: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
|
||||
# Label: "Verisign Class 3 Public Primary Certification Authority"
|
||||
# Serial: 80507572722862485515306429940691309246
|
||||
# MD5 Fingerprint: ef:5a:f1:33:ef:f1:cd:bb:51:02:ee:12:14:4b:96:c4
|
||||
# SHA1 Fingerprint: a1:db:63:93:91:6f:17:e4:18:55:09:40:04:15:c7:02:40:b0:ae:6b
|
||||
# SHA256 Fingerprint: a4:b6:b3:99:6f:c2:f3:06:b3:fd:86:81:bd:63:41:3d:8c:50:09:cc:4f:a3:29:c2:cc:f0:e2:fa:1b:14:03:05
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICPDCCAaUCEDyRMcsf9tAbDpq40ES/Er4wDQYJKoZIhvcNAQEFBQAwXzELMAkG
|
||||
A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz
|
||||
cyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2
|
||||
MDEyOTAwMDAwMFoXDTI4MDgwMjIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV
|
||||
BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAzIFB1YmxpYyBQcmlt
|
||||
YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN
|
||||
ADCBiQKBgQDJXFme8huKARS0EN8EQNvjV69qRUCPhAwL0TPZ2RHP7gJYHyX3KqhE
|
||||
BarsAx94f56TuZoAqiN91qyFomNFx3InzPRMxnVx0jnvT0Lwdd8KkMaOIG+YD/is
|
||||
I19wKTakyYbnsZogy1Olhec9vn2a/iRFM9x2Fe0PonFkTGUugWhFpwIDAQABMA0G
|
||||
CSqGSIb3DQEBBQUAA4GBABByUqkFFBkyCEHwxWsKzH4PIRnN5GfcX6kb5sroc50i
|
||||
2JhucwNhkcV8sEVAbkSdjbCxlnRhLQ2pRdKkkirWmnWXbj9T/UWZYB2oK0z5XqcJ
|
||||
2HUw19JlYD1n1khVdWk/kfVIC0dpImmClr7JyDiGSnoscxlIaU5rfGW/D/xwzoiQ
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority - G2/(c) 1998 VeriSign, Inc. - For authorized use only/VeriSign Trust Network
|
||||
# Subject: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority - G2/(c) 1998 VeriSign, Inc. - For authorized use only/VeriSign Trust Network
|
||||
# Label: "Verisign Class 3 Public Primary Certification Authority - G2"
|
||||
# Serial: 167285380242319648451154478808036881606
|
||||
# MD5 Fingerprint: a2:33:9b:4c:74:78:73:d4:6c:e7:c1:f3:8d:cb:5c:e9
|
||||
# SHA1 Fingerprint: 85:37:1c:a6:e5:50:14:3d:ce:28:03:47:1b:de:3a:09:e8:f8:77:0f
|
||||
# SHA256 Fingerprint: 83:ce:3c:12:29:68:8a:59:3d:48:5f:81:97:3c:0f:91:95:43:1e:da:37:cc:5e:36:43:0e:79:c7:a8:88:63:8b
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDAjCCAmsCEH3Z/gfPqB63EHln+6eJNMYwDQYJKoZIhvcNAQEFBQAwgcExCzAJ
|
||||
BgNVBAYTAlVTMRcwFQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xh
|
||||
c3MgMyBQdWJsaWMgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcy
|
||||
MTowOAYDVQQLEzEoYykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3Jp
|
||||
emVkIHVzZSBvbmx5MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMB4X
|
||||
DTk4MDUxODAwMDAwMFoXDTI4MDgwMTIzNTk1OVowgcExCzAJBgNVBAYTAlVTMRcw
|
||||
FQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xhc3MgMyBQdWJsaWMg
|
||||
UHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcyMTowOAYDVQQLEzEo
|
||||
YykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5
|
||||
MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMIGfMA0GCSqGSIb3DQEB
|
||||
AQUAA4GNADCBiQKBgQDMXtERXVxp0KvTuWpMmR9ZmDCOFoUgRm1HP9SFIIThbbP4
|
||||
pO0M8RcPO/mn+SXXwc+EY/J8Y8+iR/LGWzOOZEAEaMGAuWQcRXfH2G71lSk8UOg0
|
||||
13gfqLptQ5GVj0VXXn7F+8qkBOvqlzdUMG+7AUcyM83cV5tkaWH4mx0ciU9cZwID
|
||||
AQABMA0GCSqGSIb3DQEBBQUAA4GBAFFNzb5cy5gZnBWyATl4Lk0PZ3BwmcYQWpSk
|
||||
U01UbSuvDV1Ai2TT1+7eVmGSX6bEHRBhNtMsJzzoKQm5EWR0zLVznxxIqbxhAe7i
|
||||
F6YM40AIOw7n60RzKprxaZLvcRTDOaxxp5EJb+RxBrO6WVcmeQD2+A2iMzAo1KpY
|
||||
oJ2daZH9
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=GTE CyberTrust Global Root O=GTE Corporation OU=GTE CyberTrust Solutions, Inc.
|
||||
# Subject: CN=GTE CyberTrust Global Root O=GTE Corporation OU=GTE CyberTrust Solutions, Inc.
|
||||
# Label: "GTE CyberTrust Global Root"
|
||||
# Serial: 421
|
||||
# MD5 Fingerprint: ca:3d:d3:68:f1:03:5c:d0:32:fa:b8:2b:59:e8:5a:db
|
||||
# SHA1 Fingerprint: 97:81:79:50:d8:1c:96:70:cc:34:d8:09:cf:79:44:31:36:7e:f4:74
|
||||
# SHA256 Fingerprint: a5:31:25:18:8d:21:10:aa:96:4b:02:c7:b7:c6:da:32:03:17:08:94:e5:fb:71:ff:fb:66:67:d5:e6:81:0a:36
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICWjCCAcMCAgGlMA0GCSqGSIb3DQEBBAUAMHUxCzAJBgNVBAYTAlVTMRgwFgYD
|
||||
VQQKEw9HVEUgQ29ycG9yYXRpb24xJzAlBgNVBAsTHkdURSBDeWJlclRydXN0IFNv
|
||||
bHV0aW9ucywgSW5jLjEjMCEGA1UEAxMaR1RFIEN5YmVyVHJ1c3QgR2xvYmFsIFJv
|
||||
b3QwHhcNOTgwODEzMDAyOTAwWhcNMTgwODEzMjM1OTAwWjB1MQswCQYDVQQGEwJV
|
||||
UzEYMBYGA1UEChMPR1RFIENvcnBvcmF0aW9uMScwJQYDVQQLEx5HVEUgQ3liZXJU
|
||||
cnVzdCBTb2x1dGlvbnMsIEluYy4xIzAhBgNVBAMTGkdURSBDeWJlclRydXN0IEds
|
||||
b2JhbCBSb290MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCVD6C28FCc6HrH
|
||||
iM3dFw4usJTQGz0O9pTAipTHBsiQl8i4ZBp6fmw8U+E3KHNgf7KXUwefU/ltWJTS
|
||||
r41tiGeA5u2ylc9yMcqlHHK6XALnZELn+aks1joNrI1CqiQBOeacPwGFVw1Yh0X4
|
||||
04Wqk2kmhXBIgD8SFcd5tB8FLztimQIDAQABMA0GCSqGSIb3DQEBBAUAA4GBAG3r
|
||||
GwnpXtlR22ciYaQqPEh346B8pt5zohQDhT37qw4wxYMWM4ETCJ57NE7fQMh017l9
|
||||
3PR2VX2bY1QY6fDq81yx2YtCHrnAlU66+tXifPVoYb+O7AWXX1uw16OFNMQkpw0P
|
||||
lZPvy5TYnh+dXIVtx6quTx8itc2VrbqnzPmrC3p/
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: C=US, O=Equifax, OU=Equifax Secure Certificate Authority
|
||||
# Subject: C=US, O=Equifax, OU=Equifax Secure Certificate Authority
|
||||
# Label: "Equifax Secure Certificate Authority"
|
||||
# Serial: 903804111
|
||||
# MD5 Fingerprint: 67:cb:9d:c0:13:24:8a:82:9b:b2:17:1e:d1:1b:ec:d4
|
||||
# SHA1 Fingerprint: d2:32:09:ad:23:d3:14:23:21:74:e4:0d:7f:9d:62:13:97:86:63:3a
|
||||
# SHA256 Fingerprint: 08:29:7a:40:47:db:a2:36:80:c7:31:db:6e:31:76:53:ca:78:48:e1:be:bd:3a:0b:01:79:a7:07:f9:2c:f1:78
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDIDCCAomgAwIBAgIENd70zzANBgkqhkiG9w0BAQUFADBOMQswCQYDVQQGEwJV
|
||||
UzEQMA4GA1UEChMHRXF1aWZheDEtMCsGA1UECxMkRXF1aWZheCBTZWN1cmUgQ2Vy
|
||||
dGlmaWNhdGUgQXV0aG9yaXR5MB4XDTk4MDgyMjE2NDE1MVoXDTE4MDgyMjE2NDE1
|
||||
MVowTjELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0VxdWlmYXgxLTArBgNVBAsTJEVx
|
||||
dWlmYXggU2VjdXJlIENlcnRpZmljYXRlIEF1dGhvcml0eTCBnzANBgkqhkiG9w0B
|
||||
AQEFAAOBjQAwgYkCgYEAwV2xWGcIYu6gmi0fCG2RFGiYCh7+2gRvE4RiIcPRfM6f
|
||||
BeC4AfBONOziipUEZKzxa1NfBbPLZ4C/QgKO/t0BCezhABRP/PvwDN1Dulsr4R+A
|
||||
cJkVV5MW8Q+XarfCaCMczE1ZMKxRHjuvK9buY0V7xdlfUNLjUA86iOe/FP3gx7kC
|
||||
AwEAAaOCAQkwggEFMHAGA1UdHwRpMGcwZaBjoGGkXzBdMQswCQYDVQQGEwJVUzEQ
|
||||
MA4GA1UEChMHRXF1aWZheDEtMCsGA1UECxMkRXF1aWZheCBTZWN1cmUgQ2VydGlm
|
||||
aWNhdGUgQXV0aG9yaXR5MQ0wCwYDVQQDEwRDUkwxMBoGA1UdEAQTMBGBDzIwMTgw
|
||||
ODIyMTY0MTUxWjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAUSOZo+SvSspXXR9gj
|
||||
IBBPM5iQn9QwHQYDVR0OBBYEFEjmaPkr0rKV10fYIyAQTzOYkJ/UMAwGA1UdEwQF
|
||||
MAMBAf8wGgYJKoZIhvZ9B0EABA0wCxsFVjMuMGMDAgbAMA0GCSqGSIb3DQEBBQUA
|
||||
A4GBAFjOKer89961zgK5F7WF0bnj4JXMJTENAKaSbn+2kmOeUJXRmm/kEd5jhW6Y
|
||||
7qj/WsjTVbJmcVfewCHrPSqnI0kBBIZCe/zuf6IWUrVnZ9NA2zsmWLIodz2uFHdh
|
||||
1voqZiegDfqnc1zqcPGUIWVEX/r87yloqaKHee9570+sB3c4
|
||||
-----END CERTIFICATE-----
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ 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-JP, SHIFT_JIS, CP932, 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)
|
||||
@@ -16,6 +16,14 @@ Detects
|
||||
|
||||
Requires Python 2.6 or later
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
Install from `PyPI <https://pypi.python.org/pypi/chardet>`_::
|
||||
|
||||
pip install chardet
|
||||
|
||||
|
||||
Command-line Tool
|
||||
-----------------
|
||||
|
||||
@@ -31,7 +39,7 @@ 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
|
||||
supported python 3.x. We've recently merged with `Ian Cordasco <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+.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# 02110-1301 USA
|
||||
######################### END LICENSE BLOCK #########################
|
||||
|
||||
__version__ = "2.2.1"
|
||||
__version__ = "2.3.0"
|
||||
from sys import version_info
|
||||
|
||||
|
||||
|
||||
@@ -12,34 +12,68 @@ Example::
|
||||
If no paths are provided, it takes its input from stdin.
|
||||
|
||||
"""
|
||||
from io import open
|
||||
from sys import argv, stdin
|
||||
|
||||
from __future__ import absolute_import, print_function, unicode_literals
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from io import open
|
||||
|
||||
from chardet import __version__
|
||||
from chardet.universaldetector import UniversalDetector
|
||||
|
||||
|
||||
def description_of(file, name='stdin'):
|
||||
"""Return a string describing the probable encoding of a file."""
|
||||
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 file:
|
||||
for line in lines:
|
||||
u.feed(line)
|
||||
u.close()
|
||||
result = u.result
|
||||
if result['encoding']:
|
||||
return '%s: %s with confidence %s' % (name,
|
||||
result['encoding'],
|
||||
result['confidence'])
|
||||
return '{0}: {1} with confidence {2}'.format(name, result['encoding'],
|
||||
result['confidence'])
|
||||
else:
|
||||
return '%s: no result' % name
|
||||
return '{0}: no result'.format(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))
|
||||
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",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
conflict_handler='resolve')
|
||||
parser.add_argument('input',
|
||||
help='File whose encoding we would like to determine.',
|
||||
type=argparse.FileType('rb'), nargs='*',
|
||||
default=[sys.stdin])
|
||||
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__':
|
||||
|
||||
@@ -177,6 +177,12 @@ class JapaneseContextAnalysis:
|
||||
return -1, 1
|
||||
|
||||
class SJISContextAnalysis(JapaneseContextAnalysis):
|
||||
def __init__(self):
|
||||
self.charset_name = "SHIFT_JIS"
|
||||
|
||||
def get_charset_name(self):
|
||||
return self.charset_name
|
||||
|
||||
def get_order(self, aBuf):
|
||||
if not aBuf:
|
||||
return -1, 1
|
||||
@@ -184,6 +190,8 @@ class SJISContextAnalysis(JapaneseContextAnalysis):
|
||||
first_char = wrap_ord(aBuf[0])
|
||||
if ((0x81 <= first_char <= 0x9F) or (0xE0 <= first_char <= 0xFC)):
|
||||
charLen = 2
|
||||
if (first_char == 0x87) or (0xFA <= first_char <= 0xFC):
|
||||
self.charset_name = "CP932"
|
||||
else:
|
||||
charLen = 1
|
||||
|
||||
|
||||
@@ -129,11 +129,11 @@ class Latin1Prober(CharSetProber):
|
||||
if total < 0.01:
|
||||
confidence = 0.0
|
||||
else:
|
||||
confidence = ((self._mFreqCounter[3] / total)
|
||||
- (self._mFreqCounter[1] * 20.0 / total))
|
||||
confidence = ((self._mFreqCounter[3] - self._mFreqCounter[1] * 20.0)
|
||||
/ total)
|
||||
if confidence < 0.0:
|
||||
confidence = 0.0
|
||||
# lower the confidence of latin1 so that other more accurate
|
||||
# detector can take priority.
|
||||
confidence = confidence * 0.5
|
||||
confidence = confidence * 0.73
|
||||
return confidence
|
||||
|
||||
@@ -353,7 +353,7 @@ SJIS_cls = (
|
||||
2,2,2,2,2,2,2,2, # 68 - 6f
|
||||
2,2,2,2,2,2,2,2, # 70 - 77
|
||||
2,2,2,2,2,2,2,1, # 78 - 7f
|
||||
3,3,3,3,3,3,3,3, # 80 - 87
|
||||
3,3,3,3,3,2,2,3, # 80 - 87
|
||||
3,3,3,3,3,3,3,3, # 88 - 8f
|
||||
3,3,3,3,3,3,3,3, # 90 - 97
|
||||
3,3,3,3,3,3,3,3, # 98 - 9f
|
||||
@@ -369,9 +369,8 @@ SJIS_cls = (
|
||||
2,2,2,2,2,2,2,2, # d8 - df
|
||||
3,3,3,3,3,3,3,3, # e0 - e7
|
||||
3,3,3,3,3,4,4,4, # e8 - ef
|
||||
4,4,4,4,4,4,4,4, # f0 - f7
|
||||
4,4,4,4,4,0,0,0 # f8 - ff
|
||||
)
|
||||
3,3,3,3,3,3,3,3, # f0 - f7
|
||||
3,3,3,3,3,0,0,0) # f8 - ff
|
||||
|
||||
|
||||
SJIS_st = (
|
||||
@@ -571,5 +570,3 @@ UTF8SMModel = {'classTable': UTF8_cls,
|
||||
'stateTable': UTF8_st,
|
||||
'charLenTable': UTF8CharLenTable,
|
||||
'name': 'UTF-8'}
|
||||
|
||||
# flake8: noqa
|
||||
|
||||
@@ -47,7 +47,7 @@ class SJISProber(MultiByteCharSetProber):
|
||||
self._mContextAnalyzer.reset()
|
||||
|
||||
def get_charset_name(self):
|
||||
return "SHIFT_JIS"
|
||||
return self._mContextAnalyzer.get_charset_name()
|
||||
|
||||
def feed(self, aBuf):
|
||||
aLen = len(aBuf)
|
||||
|
||||
@@ -71,9 +71,9 @@ class UniversalDetector:
|
||||
|
||||
if not self._mGotData:
|
||||
# If the data starts with BOM, we know it is UTF
|
||||
if aBuf[:3] == codecs.BOM:
|
||||
if aBuf[:3] == codecs.BOM_UTF8:
|
||||
# EF BB BF UTF-8 with BOM
|
||||
self.result = {'encoding': "UTF-8", 'confidence': 1.0}
|
||||
self.result = {'encoding': "UTF-8-SIG", 'confidence': 1.0}
|
||||
elif aBuf[:4] == codecs.BOM_UTF32_LE:
|
||||
# FF FE 00 00 UTF-32, little-endian BOM
|
||||
self.result = {'encoding': "UTF-32LE", 'confidence': 1.0}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
click
|
||||
~~~~~
|
||||
|
||||
Click is a simple Python module that wraps the stdlib's optparse to make
|
||||
writing command line scripts fun. Unlike other modules, it's based around
|
||||
a simple API that does not come with too much magic and is composable.
|
||||
|
||||
In case optparse ever gets removed from the stdlib, it will be shipped by
|
||||
this module.
|
||||
|
||||
:copyright: (c) 2014 by Armin Ronacher.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
# Core classes
|
||||
from .core import Context, BaseCommand, Command, MultiCommand, Group, \
|
||||
CommandCollection, Parameter, Option, Argument
|
||||
|
||||
# Globals
|
||||
from .globals import get_current_context
|
||||
|
||||
# Decorators
|
||||
from .decorators import pass_context, pass_obj, make_pass_decorator, \
|
||||
command, group, argument, option, confirmation_option, \
|
||||
password_option, version_option, help_option
|
||||
|
||||
# Types
|
||||
from .types import ParamType, File, Path, Choice, IntRange, Tuple, \
|
||||
STRING, INT, FLOAT, BOOL, UUID, UNPROCESSED
|
||||
|
||||
# Utilities
|
||||
from .utils import echo, get_binary_stream, get_text_stream, open_file, \
|
||||
format_filename, get_app_dir, get_os_args
|
||||
|
||||
# Terminal functions
|
||||
from .termui import prompt, confirm, get_terminal_size, echo_via_pager, \
|
||||
progressbar, clear, style, unstyle, secho, edit, launch, getchar, \
|
||||
pause
|
||||
|
||||
# Exceptions
|
||||
from .exceptions import ClickException, UsageError, BadParameter, \
|
||||
FileError, Abort, NoSuchOption, BadOptionUsage, BadArgumentUsage, \
|
||||
MissingParameter
|
||||
|
||||
# Formatting
|
||||
from .formatting import HelpFormatter, wrap_text
|
||||
|
||||
# Parsing
|
||||
from .parser import OptionParser
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Core classes
|
||||
'Context', 'BaseCommand', 'Command', 'MultiCommand', 'Group',
|
||||
'CommandCollection', 'Parameter', 'Option', 'Argument',
|
||||
|
||||
# Globals
|
||||
'get_current_context',
|
||||
|
||||
# Decorators
|
||||
'pass_context', 'pass_obj', 'make_pass_decorator', 'command', 'group',
|
||||
'argument', 'option', 'confirmation_option', 'password_option',
|
||||
'version_option', 'help_option',
|
||||
|
||||
# Types
|
||||
'ParamType', 'File', 'Path', 'Choice', 'IntRange', 'Tuple', 'STRING',
|
||||
'INT', 'FLOAT', 'BOOL', 'UUID', 'UNPROCESSED',
|
||||
|
||||
# Utilities
|
||||
'echo', 'get_binary_stream', 'get_text_stream', 'open_file',
|
||||
'format_filename', 'get_app_dir', 'get_os_args',
|
||||
|
||||
# Terminal functions
|
||||
'prompt', 'confirm', 'get_terminal_size', 'echo_via_pager',
|
||||
'progressbar', 'clear', 'style', 'unstyle', 'secho', 'edit', 'launch',
|
||||
'getchar', 'pause',
|
||||
|
||||
# Exceptions
|
||||
'ClickException', 'UsageError', 'BadParameter', 'FileError',
|
||||
'Abort', 'NoSuchOption', 'BadOptionUsage', 'BadArgumentUsage',
|
||||
'MissingParameter',
|
||||
|
||||
# Formatting
|
||||
'HelpFormatter', 'wrap_text',
|
||||
|
||||
# Parsing
|
||||
'OptionParser',
|
||||
]
|
||||
|
||||
|
||||
# Controls if click should emit the warning about the use of unicode
|
||||
# literals.
|
||||
disable_unicode_literals_warning = False
|
||||
|
||||
|
||||
__version__ = '6.7'
|
||||
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
import re
|
||||
from .utils import echo
|
||||
from .parser import split_arg_string
|
||||
from .core import MultiCommand, Option
|
||||
|
||||
|
||||
COMPLETION_SCRIPT = '''
|
||||
%(complete_func)s() {
|
||||
COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\
|
||||
COMP_CWORD=$COMP_CWORD \\
|
||||
%(autocomplete_var)s=complete $1 ) )
|
||||
return 0
|
||||
}
|
||||
|
||||
complete -F %(complete_func)s -o default %(script_names)s
|
||||
'''
|
||||
|
||||
_invalid_ident_char_re = re.compile(r'[^a-zA-Z0-9_]')
|
||||
|
||||
|
||||
def get_completion_script(prog_name, complete_var):
|
||||
cf_name = _invalid_ident_char_re.sub('', prog_name.replace('-', '_'))
|
||||
return (COMPLETION_SCRIPT % {
|
||||
'complete_func': '_%s_completion' % cf_name,
|
||||
'script_names': prog_name,
|
||||
'autocomplete_var': complete_var,
|
||||
}).strip() + ';'
|
||||
|
||||
|
||||
def resolve_ctx(cli, prog_name, args):
|
||||
ctx = cli.make_context(prog_name, args, resilient_parsing=True)
|
||||
while ctx.protected_args + ctx.args and isinstance(ctx.command, MultiCommand):
|
||||
a = ctx.protected_args + ctx.args
|
||||
cmd = ctx.command.get_command(ctx, a[0])
|
||||
if cmd is None:
|
||||
return None
|
||||
ctx = cmd.make_context(a[0], a[1:], parent=ctx, resilient_parsing=True)
|
||||
return ctx
|
||||
|
||||
|
||||
def get_choices(cli, prog_name, args, incomplete):
|
||||
ctx = resolve_ctx(cli, prog_name, args)
|
||||
if ctx is None:
|
||||
return
|
||||
|
||||
choices = []
|
||||
if incomplete and not incomplete[:1].isalnum():
|
||||
for param in ctx.command.params:
|
||||
if not isinstance(param, Option):
|
||||
continue
|
||||
choices.extend(param.opts)
|
||||
choices.extend(param.secondary_opts)
|
||||
elif isinstance(ctx.command, MultiCommand):
|
||||
choices.extend(ctx.command.list_commands(ctx))
|
||||
|
||||
for item in choices:
|
||||
if item.startswith(incomplete):
|
||||
yield item
|
||||
|
||||
|
||||
def do_complete(cli, prog_name):
|
||||
cwords = split_arg_string(os.environ['COMP_WORDS'])
|
||||
cword = int(os.environ['COMP_CWORD'])
|
||||
args = cwords[1:cword]
|
||||
try:
|
||||
incomplete = cwords[cword]
|
||||
except IndexError:
|
||||
incomplete = ''
|
||||
|
||||
for item in get_choices(cli, prog_name, args, incomplete):
|
||||
echo(item)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def bashcomplete(cli, prog_name, complete_var, complete_instr):
|
||||
if complete_instr == 'source':
|
||||
echo(get_completion_script(prog_name, complete_var))
|
||||
return True
|
||||
elif complete_instr == 'complete':
|
||||
return do_complete(cli, prog_name)
|
||||
return False
|
||||
@@ -0,0 +1,648 @@
|
||||
import re
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import codecs
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
|
||||
PY2 = sys.version_info[0] == 2
|
||||
WIN = sys.platform.startswith('win')
|
||||
DEFAULT_COLUMNS = 80
|
||||
|
||||
|
||||
_ansi_re = re.compile('\033\[((?:\d|;)*)([a-zA-Z])')
|
||||
|
||||
|
||||
def get_filesystem_encoding():
|
||||
return sys.getfilesystemencoding() or sys.getdefaultencoding()
|
||||
|
||||
|
||||
def _make_text_stream(stream, encoding, errors):
|
||||
if encoding is None:
|
||||
encoding = get_best_encoding(stream)
|
||||
if errors is None:
|
||||
errors = 'replace'
|
||||
return _NonClosingTextIOWrapper(stream, encoding, errors,
|
||||
line_buffering=True)
|
||||
|
||||
|
||||
def is_ascii_encoding(encoding):
|
||||
"""Checks if a given encoding is ascii."""
|
||||
try:
|
||||
return codecs.lookup(encoding).name == 'ascii'
|
||||
except LookupError:
|
||||
return False
|
||||
|
||||
|
||||
def get_best_encoding(stream):
|
||||
"""Returns the default stream encoding if not found."""
|
||||
rv = getattr(stream, 'encoding', None) or sys.getdefaultencoding()
|
||||
if is_ascii_encoding(rv):
|
||||
return 'utf-8'
|
||||
return rv
|
||||
|
||||
|
||||
class _NonClosingTextIOWrapper(io.TextIOWrapper):
|
||||
|
||||
def __init__(self, stream, encoding, errors, **extra):
|
||||
self._stream = stream = _FixupStream(stream)
|
||||
io.TextIOWrapper.__init__(self, stream, encoding, errors, **extra)
|
||||
|
||||
# The io module is a place where the Python 3 text behavior
|
||||
# was forced upon Python 2, so we need to unbreak
|
||||
# it to look like Python 2.
|
||||
if PY2:
|
||||
def write(self, x):
|
||||
if isinstance(x, str) or is_bytes(x):
|
||||
try:
|
||||
self.flush()
|
||||
except Exception:
|
||||
pass
|
||||
return self.buffer.write(str(x))
|
||||
return io.TextIOWrapper.write(self, x)
|
||||
|
||||
def writelines(self, lines):
|
||||
for line in lines:
|
||||
self.write(line)
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
self.detach()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def isatty(self):
|
||||
# https://bitbucket.org/pypy/pypy/issue/1803
|
||||
return self._stream.isatty()
|
||||
|
||||
|
||||
class _FixupStream(object):
|
||||
"""The new io interface needs more from streams than streams
|
||||
traditionally implement. As such, this fix-up code is necessary in
|
||||
some circumstances.
|
||||
"""
|
||||
|
||||
def __init__(self, stream):
|
||||
self._stream = stream
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._stream, name)
|
||||
|
||||
def read1(self, size):
|
||||
f = getattr(self._stream, 'read1', None)
|
||||
if f is not None:
|
||||
return f(size)
|
||||
# We only dispatch to readline instead of read in Python 2 as we
|
||||
# do not want cause problems with the different implementation
|
||||
# of line buffering.
|
||||
if PY2:
|
||||
return self._stream.readline(size)
|
||||
return self._stream.read(size)
|
||||
|
||||
def readable(self):
|
||||
x = getattr(self._stream, 'readable', None)
|
||||
if x is not None:
|
||||
return x()
|
||||
try:
|
||||
self._stream.read(0)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
def writable(self):
|
||||
x = getattr(self._stream, 'writable', None)
|
||||
if x is not None:
|
||||
return x()
|
||||
try:
|
||||
self._stream.write('')
|
||||
except Exception:
|
||||
try:
|
||||
self._stream.write(b'')
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
def seekable(self):
|
||||
x = getattr(self._stream, 'seekable', None)
|
||||
if x is not None:
|
||||
return x()
|
||||
try:
|
||||
self._stream.seek(self._stream.tell())
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
if PY2:
|
||||
text_type = unicode
|
||||
bytes = str
|
||||
raw_input = raw_input
|
||||
string_types = (str, unicode)
|
||||
iteritems = lambda x: x.iteritems()
|
||||
range_type = xrange
|
||||
|
||||
def is_bytes(x):
|
||||
return isinstance(x, (buffer, bytearray))
|
||||
|
||||
_identifier_re = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
|
||||
|
||||
# For Windows, we need to force stdout/stdin/stderr to binary if it's
|
||||
# fetched for that. This obviously is not the most correct way to do
|
||||
# it as it changes global state. Unfortunately, there does not seem to
|
||||
# be a clear better way to do it as just reopening the file in binary
|
||||
# mode does not change anything.
|
||||
#
|
||||
# An option would be to do what Python 3 does and to open the file as
|
||||
# binary only, patch it back to the system, and then use a wrapper
|
||||
# stream that converts newlines. It's not quite clear what's the
|
||||
# correct option here.
|
||||
#
|
||||
# This code also lives in _winconsole for the fallback to the console
|
||||
# emulation stream.
|
||||
#
|
||||
# There are also Windows environments where the `msvcrt` module is not
|
||||
# available (which is why we use try-catch instead of the WIN variable
|
||||
# here), such as the Google App Engine development server on Windows. In
|
||||
# those cases there is just nothing we can do.
|
||||
try:
|
||||
import msvcrt
|
||||
except ImportError:
|
||||
set_binary_mode = lambda x: x
|
||||
else:
|
||||
def set_binary_mode(f):
|
||||
try:
|
||||
fileno = f.fileno()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
msvcrt.setmode(fileno, os.O_BINARY)
|
||||
return f
|
||||
|
||||
def isidentifier(x):
|
||||
return _identifier_re.search(x) is not None
|
||||
|
||||
def get_binary_stdin():
|
||||
return set_binary_mode(sys.stdin)
|
||||
|
||||
def get_binary_stdout():
|
||||
return set_binary_mode(sys.stdout)
|
||||
|
||||
def get_binary_stderr():
|
||||
return set_binary_mode(sys.stderr)
|
||||
|
||||
def get_text_stdin(encoding=None, errors=None):
|
||||
rv = _get_windows_console_stream(sys.stdin, encoding, errors)
|
||||
if rv is not None:
|
||||
return rv
|
||||
return _make_text_stream(sys.stdin, encoding, errors)
|
||||
|
||||
def get_text_stdout(encoding=None, errors=None):
|
||||
rv = _get_windows_console_stream(sys.stdout, encoding, errors)
|
||||
if rv is not None:
|
||||
return rv
|
||||
return _make_text_stream(sys.stdout, encoding, errors)
|
||||
|
||||
def get_text_stderr(encoding=None, errors=None):
|
||||
rv = _get_windows_console_stream(sys.stderr, encoding, errors)
|
||||
if rv is not None:
|
||||
return rv
|
||||
return _make_text_stream(sys.stderr, encoding, errors)
|
||||
|
||||
def filename_to_ui(value):
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode(get_filesystem_encoding(), 'replace')
|
||||
return value
|
||||
else:
|
||||
import io
|
||||
text_type = str
|
||||
raw_input = input
|
||||
string_types = (str,)
|
||||
range_type = range
|
||||
isidentifier = lambda x: x.isidentifier()
|
||||
iteritems = lambda x: iter(x.items())
|
||||
|
||||
def is_bytes(x):
|
||||
return isinstance(x, (bytes, memoryview, bytearray))
|
||||
|
||||
def _is_binary_reader(stream, default=False):
|
||||
try:
|
||||
return isinstance(stream.read(0), bytes)
|
||||
except Exception:
|
||||
return default
|
||||
# This happens in some cases where the stream was already
|
||||
# closed. In this case, we assume the default.
|
||||
|
||||
def _is_binary_writer(stream, default=False):
|
||||
try:
|
||||
stream.write(b'')
|
||||
except Exception:
|
||||
try:
|
||||
stream.write('')
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
return True
|
||||
|
||||
def _find_binary_reader(stream):
|
||||
# We need to figure out if the given stream is already binary.
|
||||
# This can happen because the official docs recommend detaching
|
||||
# the streams to get binary streams. Some code might do this, so
|
||||
# we need to deal with this case explicitly.
|
||||
if _is_binary_reader(stream, False):
|
||||
return stream
|
||||
|
||||
buf = getattr(stream, 'buffer', None)
|
||||
|
||||
# Same situation here; this time we assume that the buffer is
|
||||
# actually binary in case it's closed.
|
||||
if buf is not None and _is_binary_reader(buf, True):
|
||||
return buf
|
||||
|
||||
def _find_binary_writer(stream):
|
||||
# We need to figure out if the given stream is already binary.
|
||||
# This can happen because the official docs recommend detatching
|
||||
# the streams to get binary streams. Some code might do this, so
|
||||
# we need to deal with this case explicitly.
|
||||
if _is_binary_writer(stream, False):
|
||||
return stream
|
||||
|
||||
buf = getattr(stream, 'buffer', None)
|
||||
|
||||
# Same situation here; this time we assume that the buffer is
|
||||
# actually binary in case it's closed.
|
||||
if buf is not None and _is_binary_writer(buf, True):
|
||||
return buf
|
||||
|
||||
def _stream_is_misconfigured(stream):
|
||||
"""A stream is misconfigured if its encoding is ASCII."""
|
||||
# If the stream does not have an encoding set, we assume it's set
|
||||
# to ASCII. This appears to happen in certain unittest
|
||||
# environments. It's not quite clear what the correct behavior is
|
||||
# but this at least will force Click to recover somehow.
|
||||
return is_ascii_encoding(getattr(stream, 'encoding', None) or 'ascii')
|
||||
|
||||
def _is_compatible_text_stream(stream, encoding, errors):
|
||||
stream_encoding = getattr(stream, 'encoding', None)
|
||||
stream_errors = getattr(stream, 'errors', None)
|
||||
|
||||
# Perfect match.
|
||||
if stream_encoding == encoding and stream_errors == errors:
|
||||
return True
|
||||
|
||||
# Otherwise, it's only a compatible stream if we did not ask for
|
||||
# an encoding.
|
||||
if encoding is None:
|
||||
return stream_encoding is not None
|
||||
|
||||
return False
|
||||
|
||||
def _force_correct_text_reader(text_reader, encoding, errors):
|
||||
if _is_binary_reader(text_reader, False):
|
||||
binary_reader = text_reader
|
||||
else:
|
||||
# If there is no target encoding set, we need to verify that the
|
||||
# reader is not actually misconfigured.
|
||||
if encoding is None and not _stream_is_misconfigured(text_reader):
|
||||
return text_reader
|
||||
|
||||
if _is_compatible_text_stream(text_reader, encoding, errors):
|
||||
return text_reader
|
||||
|
||||
# If the reader has no encoding, we try to find the underlying
|
||||
# binary reader for it. If that fails because the environment is
|
||||
# misconfigured, we silently go with the same reader because this
|
||||
# is too common to happen. In that case, mojibake is better than
|
||||
# exceptions.
|
||||
binary_reader = _find_binary_reader(text_reader)
|
||||
if binary_reader is None:
|
||||
return text_reader
|
||||
|
||||
# At this point, we default the errors to replace instead of strict
|
||||
# because nobody handles those errors anyways and at this point
|
||||
# we're so fundamentally fucked that nothing can repair it.
|
||||
if errors is None:
|
||||
errors = 'replace'
|
||||
return _make_text_stream(binary_reader, encoding, errors)
|
||||
|
||||
def _force_correct_text_writer(text_writer, encoding, errors):
|
||||
if _is_binary_writer(text_writer, False):
|
||||
binary_writer = text_writer
|
||||
else:
|
||||
# If there is no target encoding set, we need to verify that the
|
||||
# writer is not actually misconfigured.
|
||||
if encoding is None and not _stream_is_misconfigured(text_writer):
|
||||
return text_writer
|
||||
|
||||
if _is_compatible_text_stream(text_writer, encoding, errors):
|
||||
return text_writer
|
||||
|
||||
# If the writer has no encoding, we try to find the underlying
|
||||
# binary writer for it. If that fails because the environment is
|
||||
# misconfigured, we silently go with the same writer because this
|
||||
# is too common to happen. In that case, mojibake is better than
|
||||
# exceptions.
|
||||
binary_writer = _find_binary_writer(text_writer)
|
||||
if binary_writer is None:
|
||||
return text_writer
|
||||
|
||||
# At this point, we default the errors to replace instead of strict
|
||||
# because nobody handles those errors anyways and at this point
|
||||
# we're so fundamentally fucked that nothing can repair it.
|
||||
if errors is None:
|
||||
errors = 'replace'
|
||||
return _make_text_stream(binary_writer, encoding, errors)
|
||||
|
||||
def get_binary_stdin():
|
||||
reader = _find_binary_reader(sys.stdin)
|
||||
if reader is None:
|
||||
raise RuntimeError('Was not able to determine binary '
|
||||
'stream for sys.stdin.')
|
||||
return reader
|
||||
|
||||
def get_binary_stdout():
|
||||
writer = _find_binary_writer(sys.stdout)
|
||||
if writer is None:
|
||||
raise RuntimeError('Was not able to determine binary '
|
||||
'stream for sys.stdout.')
|
||||
return writer
|
||||
|
||||
def get_binary_stderr():
|
||||
writer = _find_binary_writer(sys.stderr)
|
||||
if writer is None:
|
||||
raise RuntimeError('Was not able to determine binary '
|
||||
'stream for sys.stderr.')
|
||||
return writer
|
||||
|
||||
def get_text_stdin(encoding=None, errors=None):
|
||||
rv = _get_windows_console_stream(sys.stdin, encoding, errors)
|
||||
if rv is not None:
|
||||
return rv
|
||||
return _force_correct_text_reader(sys.stdin, encoding, errors)
|
||||
|
||||
def get_text_stdout(encoding=None, errors=None):
|
||||
rv = _get_windows_console_stream(sys.stdout, encoding, errors)
|
||||
if rv is not None:
|
||||
return rv
|
||||
return _force_correct_text_writer(sys.stdout, encoding, errors)
|
||||
|
||||
def get_text_stderr(encoding=None, errors=None):
|
||||
rv = _get_windows_console_stream(sys.stderr, encoding, errors)
|
||||
if rv is not None:
|
||||
return rv
|
||||
return _force_correct_text_writer(sys.stderr, encoding, errors)
|
||||
|
||||
def filename_to_ui(value):
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode(get_filesystem_encoding(), 'replace')
|
||||
else:
|
||||
value = value.encode('utf-8', 'surrogateescape') \
|
||||
.decode('utf-8', 'replace')
|
||||
return value
|
||||
|
||||
|
||||
def get_streerror(e, default=None):
|
||||
if hasattr(e, 'strerror'):
|
||||
msg = e.strerror
|
||||
else:
|
||||
if default is not None:
|
||||
msg = default
|
||||
else:
|
||||
msg = str(e)
|
||||
if isinstance(msg, bytes):
|
||||
msg = msg.decode('utf-8', 'replace')
|
||||
return msg
|
||||
|
||||
|
||||
def open_stream(filename, mode='r', encoding=None, errors='strict',
|
||||
atomic=False):
|
||||
# Standard streams first. These are simple because they don't need
|
||||
# special handling for the atomic flag. It's entirely ignored.
|
||||
if filename == '-':
|
||||
if 'w' in mode:
|
||||
if 'b' in mode:
|
||||
return get_binary_stdout(), False
|
||||
return get_text_stdout(encoding=encoding, errors=errors), False
|
||||
if 'b' in mode:
|
||||
return get_binary_stdin(), False
|
||||
return get_text_stdin(encoding=encoding, errors=errors), False
|
||||
|
||||
# Non-atomic writes directly go out through the regular open functions.
|
||||
if not atomic:
|
||||
if encoding is None:
|
||||
return open(filename, mode), True
|
||||
return io.open(filename, mode, encoding=encoding, errors=errors), True
|
||||
|
||||
# Some usability stuff for atomic writes
|
||||
if 'a' in mode:
|
||||
raise ValueError(
|
||||
'Appending to an existing file is not supported, because that '
|
||||
'would involve an expensive `copy`-operation to a temporary '
|
||||
'file. Open the file in normal `w`-mode and copy explicitly '
|
||||
'if that\'s what you\'re after.'
|
||||
)
|
||||
if 'x' in mode:
|
||||
raise ValueError('Use the `overwrite`-parameter instead.')
|
||||
if 'w' not in mode:
|
||||
raise ValueError('Atomic writes only make sense with `w`-mode.')
|
||||
|
||||
# Atomic writes are more complicated. They work by opening a file
|
||||
# as a proxy in the same folder and then using the fdopen
|
||||
# functionality to wrap it in a Python file. Then we wrap it in an
|
||||
# atomic file that moves the file over on close.
|
||||
import tempfile
|
||||
fd, tmp_filename = tempfile.mkstemp(dir=os.path.dirname(filename),
|
||||
prefix='.__atomic-write')
|
||||
|
||||
if encoding is not None:
|
||||
f = io.open(fd, mode, encoding=encoding, errors=errors)
|
||||
else:
|
||||
f = os.fdopen(fd, mode)
|
||||
|
||||
return _AtomicFile(f, tmp_filename, filename), True
|
||||
|
||||
|
||||
# Used in a destructor call, needs extra protection from interpreter cleanup.
|
||||
if hasattr(os, 'replace'):
|
||||
_replace = os.replace
|
||||
_can_replace = True
|
||||
else:
|
||||
_replace = os.rename
|
||||
_can_replace = not WIN
|
||||
|
||||
|
||||
class _AtomicFile(object):
|
||||
|
||||
def __init__(self, f, tmp_filename, real_filename):
|
||||
self._f = f
|
||||
self._tmp_filename = tmp_filename
|
||||
self._real_filename = real_filename
|
||||
self.closed = False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._real_filename
|
||||
|
||||
def close(self, delete=False):
|
||||
if self.closed:
|
||||
return
|
||||
self._f.close()
|
||||
if not _can_replace:
|
||||
try:
|
||||
os.remove(self._real_filename)
|
||||
except OSError:
|
||||
pass
|
||||
_replace(self._tmp_filename, self._real_filename)
|
||||
self.closed = True
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._f, name)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, tb):
|
||||
self.close(delete=exc_type is not None)
|
||||
|
||||
def __repr__(self):
|
||||
return repr(self._f)
|
||||
|
||||
|
||||
auto_wrap_for_ansi = None
|
||||
colorama = None
|
||||
get_winterm_size = None
|
||||
|
||||
|
||||
def strip_ansi(value):
|
||||
return _ansi_re.sub('', value)
|
||||
|
||||
|
||||
def should_strip_ansi(stream=None, color=None):
|
||||
if color is None:
|
||||
if stream is None:
|
||||
stream = sys.stdin
|
||||
return not isatty(stream)
|
||||
return not color
|
||||
|
||||
|
||||
# If we're on Windows, we provide transparent integration through
|
||||
# colorama. This will make ANSI colors through the echo function
|
||||
# work automatically.
|
||||
if WIN:
|
||||
# Windows has a smaller terminal
|
||||
DEFAULT_COLUMNS = 79
|
||||
|
||||
from ._winconsole import _get_windows_console_stream
|
||||
|
||||
def _get_argv_encoding():
|
||||
import locale
|
||||
return locale.getpreferredencoding()
|
||||
|
||||
if PY2:
|
||||
def raw_input(prompt=''):
|
||||
sys.stderr.flush()
|
||||
if prompt:
|
||||
stdout = _default_text_stdout()
|
||||
stdout.write(prompt)
|
||||
stdin = _default_text_stdin()
|
||||
return stdin.readline().rstrip('\r\n')
|
||||
|
||||
try:
|
||||
import colorama
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
_ansi_stream_wrappers = WeakKeyDictionary()
|
||||
|
||||
def auto_wrap_for_ansi(stream, color=None):
|
||||
"""This function wraps a stream so that calls through colorama
|
||||
are issued to the win32 console API to recolor on demand. It
|
||||
also ensures to reset the colors if a write call is interrupted
|
||||
to not destroy the console afterwards.
|
||||
"""
|
||||
try:
|
||||
cached = _ansi_stream_wrappers.get(stream)
|
||||
except Exception:
|
||||
cached = None
|
||||
if cached is not None:
|
||||
return cached
|
||||
strip = should_strip_ansi(stream, color)
|
||||
ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip)
|
||||
rv = ansi_wrapper.stream
|
||||
_write = rv.write
|
||||
|
||||
def _safe_write(s):
|
||||
try:
|
||||
return _write(s)
|
||||
except:
|
||||
ansi_wrapper.reset_all()
|
||||
raise
|
||||
|
||||
rv.write = _safe_write
|
||||
try:
|
||||
_ansi_stream_wrappers[stream] = rv
|
||||
except Exception:
|
||||
pass
|
||||
return rv
|
||||
|
||||
def get_winterm_size():
|
||||
win = colorama.win32.GetConsoleScreenBufferInfo(
|
||||
colorama.win32.STDOUT).srWindow
|
||||
return win.Right - win.Left, win.Bottom - win.Top
|
||||
else:
|
||||
def _get_argv_encoding():
|
||||
return getattr(sys.stdin, 'encoding', None) or get_filesystem_encoding()
|
||||
|
||||
_get_windows_console_stream = lambda *x: None
|
||||
|
||||
|
||||
def term_len(x):
|
||||
return len(strip_ansi(x))
|
||||
|
||||
|
||||
def isatty(stream):
|
||||
try:
|
||||
return stream.isatty()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _make_cached_stream_func(src_func, wrapper_func):
|
||||
cache = WeakKeyDictionary()
|
||||
def func():
|
||||
stream = src_func()
|
||||
try:
|
||||
rv = cache.get(stream)
|
||||
except Exception:
|
||||
rv = None
|
||||
if rv is not None:
|
||||
return rv
|
||||
rv = wrapper_func()
|
||||
try:
|
||||
cache[stream] = rv
|
||||
except Exception:
|
||||
pass
|
||||
return rv
|
||||
return func
|
||||
|
||||
|
||||
_default_text_stdin = _make_cached_stream_func(
|
||||
lambda: sys.stdin, get_text_stdin)
|
||||
_default_text_stdout = _make_cached_stream_func(
|
||||
lambda: sys.stdout, get_text_stdout)
|
||||
_default_text_stderr = _make_cached_stream_func(
|
||||
lambda: sys.stderr, get_text_stderr)
|
||||
|
||||
|
||||
binary_streams = {
|
||||
'stdin': get_binary_stdin,
|
||||
'stdout': get_binary_stdout,
|
||||
'stderr': get_binary_stderr,
|
||||
}
|
||||
|
||||
text_streams = {
|
||||
'stdin': get_text_stdin,
|
||||
'stdout': get_text_stdout,
|
||||
'stderr': get_text_stderr,
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
"""
|
||||
click._termui_impl
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module contains implementations for the termui module. To keep the
|
||||
import time of Click down, some infrequently used functionality is placed
|
||||
in this module and only imported as needed.
|
||||
|
||||
:copyright: (c) 2014 by Armin Ronacher.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import math
|
||||
from ._compat import _default_text_stdout, range_type, PY2, isatty, \
|
||||
open_stream, strip_ansi, term_len, get_best_encoding, WIN
|
||||
from .utils import echo
|
||||
from .exceptions import ClickException
|
||||
|
||||
|
||||
if os.name == 'nt':
|
||||
BEFORE_BAR = '\r'
|
||||
AFTER_BAR = '\n'
|
||||
else:
|
||||
BEFORE_BAR = '\r\033[?25l'
|
||||
AFTER_BAR = '\033[?25h\n'
|
||||
|
||||
|
||||
def _length_hint(obj):
|
||||
"""Returns the length hint of an object."""
|
||||
try:
|
||||
return len(obj)
|
||||
except (AttributeError, TypeError):
|
||||
try:
|
||||
get_hint = type(obj).__length_hint__
|
||||
except AttributeError:
|
||||
return None
|
||||
try:
|
||||
hint = get_hint(obj)
|
||||
except TypeError:
|
||||
return None
|
||||
if hint is NotImplemented or \
|
||||
not isinstance(hint, (int, long)) or \
|
||||
hint < 0:
|
||||
return None
|
||||
return hint
|
||||
|
||||
|
||||
class ProgressBar(object):
|
||||
|
||||
def __init__(self, iterable, length=None, fill_char='#', empty_char=' ',
|
||||
bar_template='%(bar)s', info_sep=' ', show_eta=True,
|
||||
show_percent=None, show_pos=False, item_show_func=None,
|
||||
label=None, file=None, color=None, width=30):
|
||||
self.fill_char = fill_char
|
||||
self.empty_char = empty_char
|
||||
self.bar_template = bar_template
|
||||
self.info_sep = info_sep
|
||||
self.show_eta = show_eta
|
||||
self.show_percent = show_percent
|
||||
self.show_pos = show_pos
|
||||
self.item_show_func = item_show_func
|
||||
self.label = label or ''
|
||||
if file is None:
|
||||
file = _default_text_stdout()
|
||||
self.file = file
|
||||
self.color = color
|
||||
self.width = width
|
||||
self.autowidth = width == 0
|
||||
|
||||
if length is None:
|
||||
length = _length_hint(iterable)
|
||||
if iterable is None:
|
||||
if length is None:
|
||||
raise TypeError('iterable or length is required')
|
||||
iterable = range_type(length)
|
||||
self.iter = iter(iterable)
|
||||
self.length = length
|
||||
self.length_known = length is not None
|
||||
self.pos = 0
|
||||
self.avg = []
|
||||
self.start = self.last_eta = time.time()
|
||||
self.eta_known = False
|
||||
self.finished = False
|
||||
self.max_width = None
|
||||
self.entered = False
|
||||
self.current_item = None
|
||||
self.is_hidden = not isatty(self.file)
|
||||
self._last_line = None
|
||||
|
||||
def __enter__(self):
|
||||
self.entered = True
|
||||
self.render_progress()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, tb):
|
||||
self.render_finish()
|
||||
|
||||
def __iter__(self):
|
||||
if not self.entered:
|
||||
raise RuntimeError('You need to use progress bars in a with block.')
|
||||
self.render_progress()
|
||||
return self
|
||||
|
||||
def render_finish(self):
|
||||
if self.is_hidden:
|
||||
return
|
||||
self.file.write(AFTER_BAR)
|
||||
self.file.flush()
|
||||
|
||||
@property
|
||||
def pct(self):
|
||||
if self.finished:
|
||||
return 1.0
|
||||
return min(self.pos / (float(self.length) or 1), 1.0)
|
||||
|
||||
@property
|
||||
def time_per_iteration(self):
|
||||
if not self.avg:
|
||||
return 0.0
|
||||
return sum(self.avg) / float(len(self.avg))
|
||||
|
||||
@property
|
||||
def eta(self):
|
||||
if self.length_known and not self.finished:
|
||||
return self.time_per_iteration * (self.length - self.pos)
|
||||
return 0.0
|
||||
|
||||
def format_eta(self):
|
||||
if self.eta_known:
|
||||
t = self.eta + 1
|
||||
seconds = t % 60
|
||||
t /= 60
|
||||
minutes = t % 60
|
||||
t /= 60
|
||||
hours = t % 24
|
||||
t /= 24
|
||||
if t > 0:
|
||||
days = t
|
||||
return '%dd %02d:%02d:%02d' % (days, hours, minutes, seconds)
|
||||
else:
|
||||
return '%02d:%02d:%02d' % (hours, minutes, seconds)
|
||||
return ''
|
||||
|
||||
def format_pos(self):
|
||||
pos = str(self.pos)
|
||||
if self.length_known:
|
||||
pos += '/%s' % self.length
|
||||
return pos
|
||||
|
||||
def format_pct(self):
|
||||
return ('% 4d%%' % int(self.pct * 100))[1:]
|
||||
|
||||
def format_progress_line(self):
|
||||
show_percent = self.show_percent
|
||||
|
||||
info_bits = []
|
||||
if self.length_known:
|
||||
bar_length = int(self.pct * self.width)
|
||||
bar = self.fill_char * bar_length
|
||||
bar += self.empty_char * (self.width - bar_length)
|
||||
if show_percent is None:
|
||||
show_percent = not self.show_pos
|
||||
else:
|
||||
if self.finished:
|
||||
bar = self.fill_char * self.width
|
||||
else:
|
||||
bar = list(self.empty_char * (self.width or 1))
|
||||
if self.time_per_iteration != 0:
|
||||
bar[int((math.cos(self.pos * self.time_per_iteration)
|
||||
/ 2.0 + 0.5) * self.width)] = self.fill_char
|
||||
bar = ''.join(bar)
|
||||
|
||||
if self.show_pos:
|
||||
info_bits.append(self.format_pos())
|
||||
if show_percent:
|
||||
info_bits.append(self.format_pct())
|
||||
if self.show_eta and self.eta_known and not self.finished:
|
||||
info_bits.append(self.format_eta())
|
||||
if self.item_show_func is not None:
|
||||
item_info = self.item_show_func(self.current_item)
|
||||
if item_info is not None:
|
||||
info_bits.append(item_info)
|
||||
|
||||
return (self.bar_template % {
|
||||
'label': self.label,
|
||||
'bar': bar,
|
||||
'info': self.info_sep.join(info_bits)
|
||||
}).rstrip()
|
||||
|
||||
def render_progress(self):
|
||||
from .termui import get_terminal_size
|
||||
nl = False
|
||||
|
||||
if self.is_hidden:
|
||||
buf = [self.label]
|
||||
nl = True
|
||||
else:
|
||||
buf = []
|
||||
# Update width in case the terminal has been resized
|
||||
if self.autowidth:
|
||||
old_width = self.width
|
||||
self.width = 0
|
||||
clutter_length = term_len(self.format_progress_line())
|
||||
new_width = max(0, get_terminal_size()[0] - clutter_length)
|
||||
if new_width < old_width:
|
||||
buf.append(BEFORE_BAR)
|
||||
buf.append(' ' * self.max_width)
|
||||
self.max_width = new_width
|
||||
self.width = new_width
|
||||
|
||||
clear_width = self.width
|
||||
if self.max_width is not None:
|
||||
clear_width = self.max_width
|
||||
|
||||
buf.append(BEFORE_BAR)
|
||||
line = self.format_progress_line()
|
||||
line_len = term_len(line)
|
||||
if self.max_width is None or self.max_width < line_len:
|
||||
self.max_width = line_len
|
||||
buf.append(line)
|
||||
|
||||
buf.append(' ' * (clear_width - line_len))
|
||||
line = ''.join(buf)
|
||||
|
||||
# Render the line only if it changed.
|
||||
if line != self._last_line:
|
||||
self._last_line = line
|
||||
echo(line, file=self.file, color=self.color, nl=nl)
|
||||
self.file.flush()
|
||||
|
||||
def make_step(self, n_steps):
|
||||
self.pos += n_steps
|
||||
if self.length_known and self.pos >= self.length:
|
||||
self.finished = True
|
||||
|
||||
if (time.time() - self.last_eta) < 1.0:
|
||||
return
|
||||
|
||||
self.last_eta = time.time()
|
||||
self.avg = self.avg[-6:] + [-(self.start - time.time()) / (self.pos)]
|
||||
|
||||
self.eta_known = self.length_known
|
||||
|
||||
def update(self, n_steps):
|
||||
self.make_step(n_steps)
|
||||
self.render_progress()
|
||||
|
||||
def finish(self):
|
||||
self.eta_known = 0
|
||||
self.current_item = None
|
||||
self.finished = True
|
||||
|
||||
def next(self):
|
||||
if self.is_hidden:
|
||||
return next(self.iter)
|
||||
try:
|
||||
rv = next(self.iter)
|
||||
self.current_item = rv
|
||||
except StopIteration:
|
||||
self.finish()
|
||||
self.render_progress()
|
||||
raise StopIteration()
|
||||
else:
|
||||
self.update(1)
|
||||
return rv
|
||||
|
||||
if not PY2:
|
||||
__next__ = next
|
||||
del next
|
||||
|
||||
|
||||
def pager(text, color=None):
|
||||
"""Decide what method to use for paging through text."""
|
||||
stdout = _default_text_stdout()
|
||||
if not isatty(sys.stdin) or not isatty(stdout):
|
||||
return _nullpager(stdout, text, color)
|
||||
pager_cmd = (os.environ.get('PAGER', None) or '').strip()
|
||||
if pager_cmd:
|
||||
if WIN:
|
||||
return _tempfilepager(text, pager_cmd, color)
|
||||
return _pipepager(text, pager_cmd, color)
|
||||
if os.environ.get('TERM') in ('dumb', 'emacs'):
|
||||
return _nullpager(stdout, text, color)
|
||||
if WIN or sys.platform.startswith('os2'):
|
||||
return _tempfilepager(text, 'more <', color)
|
||||
if hasattr(os, 'system') and os.system('(less) 2>/dev/null') == 0:
|
||||
return _pipepager(text, 'less', color)
|
||||
|
||||
import tempfile
|
||||
fd, filename = tempfile.mkstemp()
|
||||
os.close(fd)
|
||||
try:
|
||||
if hasattr(os, 'system') and os.system('more "%s"' % filename) == 0:
|
||||
return _pipepager(text, 'more', color)
|
||||
return _nullpager(stdout, text, color)
|
||||
finally:
|
||||
os.unlink(filename)
|
||||
|
||||
|
||||
def _pipepager(text, cmd, color):
|
||||
"""Page through text by feeding it to another program. Invoking a
|
||||
pager through this might support colors.
|
||||
"""
|
||||
import subprocess
|
||||
env = dict(os.environ)
|
||||
|
||||
# If we're piping to less we might support colors under the
|
||||
# condition that
|
||||
cmd_detail = cmd.rsplit('/', 1)[-1].split()
|
||||
if color is None and cmd_detail[0] == 'less':
|
||||
less_flags = os.environ.get('LESS', '') + ' '.join(cmd_detail[1:])
|
||||
if not less_flags:
|
||||
env['LESS'] = '-R'
|
||||
color = True
|
||||
elif 'r' in less_flags or 'R' in less_flags:
|
||||
color = True
|
||||
|
||||
if not color:
|
||||
text = strip_ansi(text)
|
||||
|
||||
c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE,
|
||||
env=env)
|
||||
encoding = get_best_encoding(c.stdin)
|
||||
try:
|
||||
c.stdin.write(text.encode(encoding, 'replace'))
|
||||
c.stdin.close()
|
||||
except (IOError, KeyboardInterrupt):
|
||||
pass
|
||||
|
||||
# Less doesn't respect ^C, but catches it for its own UI purposes (aborting
|
||||
# search or other commands inside less).
|
||||
#
|
||||
# That means when the user hits ^C, the parent process (click) terminates,
|
||||
# but less is still alive, paging the output and messing up the terminal.
|
||||
#
|
||||
# If the user wants to make the pager exit on ^C, they should set
|
||||
# `LESS='-K'`. It's not our decision to make.
|
||||
while True:
|
||||
try:
|
||||
c.wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
def _tempfilepager(text, cmd, color):
|
||||
"""Page through text by invoking a program on a temporary file."""
|
||||
import tempfile
|
||||
filename = tempfile.mktemp()
|
||||
if not color:
|
||||
text = strip_ansi(text)
|
||||
encoding = get_best_encoding(sys.stdout)
|
||||
with open_stream(filename, 'wb')[0] as f:
|
||||
f.write(text.encode(encoding))
|
||||
try:
|
||||
os.system(cmd + ' "' + filename + '"')
|
||||
finally:
|
||||
os.unlink(filename)
|
||||
|
||||
|
||||
def _nullpager(stream, text, color):
|
||||
"""Simply print unformatted text. This is the ultimate fallback."""
|
||||
if not color:
|
||||
text = strip_ansi(text)
|
||||
stream.write(text)
|
||||
|
||||
|
||||
class Editor(object):
|
||||
|
||||
def __init__(self, editor=None, env=None, require_save=True,
|
||||
extension='.txt'):
|
||||
self.editor = editor
|
||||
self.env = env
|
||||
self.require_save = require_save
|
||||
self.extension = extension
|
||||
|
||||
def get_editor(self):
|
||||
if self.editor is not None:
|
||||
return self.editor
|
||||
for key in 'VISUAL', 'EDITOR':
|
||||
rv = os.environ.get(key)
|
||||
if rv:
|
||||
return rv
|
||||
if WIN:
|
||||
return 'notepad'
|
||||
for editor in 'vim', 'nano':
|
||||
if os.system('which %s >/dev/null 2>&1' % editor) == 0:
|
||||
return editor
|
||||
return 'vi'
|
||||
|
||||
def edit_file(self, filename):
|
||||
import subprocess
|
||||
editor = self.get_editor()
|
||||
if self.env:
|
||||
environ = os.environ.copy()
|
||||
environ.update(self.env)
|
||||
else:
|
||||
environ = None
|
||||
try:
|
||||
c = subprocess.Popen('%s "%s"' % (editor, filename),
|
||||
env=environ, shell=True)
|
||||
exit_code = c.wait()
|
||||
if exit_code != 0:
|
||||
raise ClickException('%s: Editing failed!' % editor)
|
||||
except OSError as e:
|
||||
raise ClickException('%s: Editing failed: %s' % (editor, e))
|
||||
|
||||
def edit(self, text):
|
||||
import tempfile
|
||||
|
||||
text = text or ''
|
||||
if text and not text.endswith('\n'):
|
||||
text += '\n'
|
||||
|
||||
fd, name = tempfile.mkstemp(prefix='editor-', suffix=self.extension)
|
||||
try:
|
||||
if WIN:
|
||||
encoding = 'utf-8-sig'
|
||||
text = text.replace('\n', '\r\n')
|
||||
else:
|
||||
encoding = 'utf-8'
|
||||
text = text.encode(encoding)
|
||||
|
||||
f = os.fdopen(fd, 'wb')
|
||||
f.write(text)
|
||||
f.close()
|
||||
timestamp = os.path.getmtime(name)
|
||||
|
||||
self.edit_file(name)
|
||||
|
||||
if self.require_save \
|
||||
and os.path.getmtime(name) == timestamp:
|
||||
return None
|
||||
|
||||
f = open(name, 'rb')
|
||||
try:
|
||||
rv = f.read()
|
||||
finally:
|
||||
f.close()
|
||||
return rv.decode('utf-8-sig').replace('\r\n', '\n')
|
||||
finally:
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
def open_url(url, wait=False, locate=False):
|
||||
import subprocess
|
||||
|
||||
def _unquote_file(url):
|
||||
try:
|
||||
import urllib
|
||||
except ImportError:
|
||||
import urllib
|
||||
if url.startswith('file://'):
|
||||
url = urllib.unquote(url[7:])
|
||||
return url
|
||||
|
||||
if sys.platform == 'darwin':
|
||||
args = ['open']
|
||||
if wait:
|
||||
args.append('-W')
|
||||
if locate:
|
||||
args.append('-R')
|
||||
args.append(_unquote_file(url))
|
||||
null = open('/dev/null', 'w')
|
||||
try:
|
||||
return subprocess.Popen(args, stderr=null).wait()
|
||||
finally:
|
||||
null.close()
|
||||
elif WIN:
|
||||
if locate:
|
||||
url = _unquote_file(url)
|
||||
args = 'explorer /select,"%s"' % _unquote_file(
|
||||
url.replace('"', ''))
|
||||
else:
|
||||
args = 'start %s "" "%s"' % (
|
||||
wait and '/WAIT' or '', url.replace('"', ''))
|
||||
return os.system(args)
|
||||
|
||||
try:
|
||||
if locate:
|
||||
url = os.path.dirname(_unquote_file(url)) or '.'
|
||||
else:
|
||||
url = _unquote_file(url)
|
||||
c = subprocess.Popen(['xdg-open', url])
|
||||
if wait:
|
||||
return c.wait()
|
||||
return 0
|
||||
except OSError:
|
||||
if url.startswith(('http://', 'https://')) and not locate and not wait:
|
||||
import webbrowser
|
||||
webbrowser.open(url)
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
def _translate_ch_to_exc(ch):
|
||||
if ch == '\x03':
|
||||
raise KeyboardInterrupt()
|
||||
if ch == '\x04':
|
||||
raise EOFError()
|
||||
|
||||
|
||||
if WIN:
|
||||
import msvcrt
|
||||
|
||||
def getchar(echo):
|
||||
rv = msvcrt.getch()
|
||||
if echo:
|
||||
msvcrt.putchar(rv)
|
||||
_translate_ch_to_exc(rv)
|
||||
if PY2:
|
||||
enc = getattr(sys.stdin, 'encoding', None)
|
||||
if enc is not None:
|
||||
rv = rv.decode(enc, 'replace')
|
||||
else:
|
||||
rv = rv.decode('cp1252', 'replace')
|
||||
return rv
|
||||
else:
|
||||
import tty
|
||||
import termios
|
||||
|
||||
def getchar(echo):
|
||||
if not isatty(sys.stdin):
|
||||
f = open('/dev/tty')
|
||||
fd = f.fileno()
|
||||
else:
|
||||
fd = sys.stdin.fileno()
|
||||
f = None
|
||||
try:
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
ch = os.read(fd, 32)
|
||||
if echo and isatty(sys.stdout):
|
||||
sys.stdout.write(ch)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
sys.stdout.flush()
|
||||
if f is not None:
|
||||
f.close()
|
||||
except termios.error:
|
||||
pass
|
||||
_translate_ch_to_exc(ch)
|
||||
return ch.decode(get_best_encoding(sys.stdin), 'replace')
|
||||
@@ -0,0 +1,38 @@
|
||||
import textwrap
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
class TextWrapper(textwrap.TextWrapper):
|
||||
|
||||
def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width):
|
||||
space_left = max(width - cur_len, 1)
|
||||
|
||||
if self.break_long_words:
|
||||
last = reversed_chunks[-1]
|
||||
cut = last[:space_left]
|
||||
res = last[space_left:]
|
||||
cur_line.append(cut)
|
||||
reversed_chunks[-1] = res
|
||||
elif not cur_line:
|
||||
cur_line.append(reversed_chunks.pop())
|
||||
|
||||
@contextmanager
|
||||
def extra_indent(self, indent):
|
||||
old_initial_indent = self.initial_indent
|
||||
old_subsequent_indent = self.subsequent_indent
|
||||
self.initial_indent += indent
|
||||
self.subsequent_indent += indent
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.initial_indent = old_initial_indent
|
||||
self.subsequent_indent = old_subsequent_indent
|
||||
|
||||
def indent_only(self, text):
|
||||
rv = []
|
||||
for idx, line in enumerate(text.splitlines()):
|
||||
indent = self.initial_indent
|
||||
if idx > 0:
|
||||
indent = self.subsequent_indent
|
||||
rv.append(indent + line)
|
||||
return '\n'.join(rv)
|
||||
@@ -0,0 +1,118 @@
|
||||
import os
|
||||
import sys
|
||||
import codecs
|
||||
|
||||
from ._compat import PY2
|
||||
|
||||
|
||||
# If someone wants to vendor click, we want to ensure the
|
||||
# correct package is discovered. Ideally we could use a
|
||||
# relative import here but unfortunately Python does not
|
||||
# support that.
|
||||
click = sys.modules[__name__.rsplit('.', 1)[0]]
|
||||
|
||||
|
||||
def _find_unicode_literals_frame():
|
||||
import __future__
|
||||
frm = sys._getframe(1)
|
||||
idx = 1
|
||||
while frm is not None:
|
||||
if frm.f_globals.get('__name__', '').startswith('click.'):
|
||||
frm = frm.f_back
|
||||
idx += 1
|
||||
elif frm.f_code.co_flags & __future__.unicode_literals.compiler_flag:
|
||||
return idx
|
||||
else:
|
||||
break
|
||||
return 0
|
||||
|
||||
|
||||
def _check_for_unicode_literals():
|
||||
if not __debug__:
|
||||
return
|
||||
if not PY2 or click.disable_unicode_literals_warning:
|
||||
return
|
||||
bad_frame = _find_unicode_literals_frame()
|
||||
if bad_frame <= 0:
|
||||
return
|
||||
from warnings import warn
|
||||
warn(Warning('Click detected the use of the unicode_literals '
|
||||
'__future__ import. This is heavily discouraged '
|
||||
'because it can introduce subtle bugs in your '
|
||||
'code. You should instead use explicit u"" literals '
|
||||
'for your unicode strings. For more information see '
|
||||
'http://click.pocoo.org/python3/'),
|
||||
stacklevel=bad_frame)
|
||||
|
||||
|
||||
def _verify_python3_env():
|
||||
"""Ensures that the environment is good for unicode on Python 3."""
|
||||
if PY2:
|
||||
return
|
||||
try:
|
||||
import locale
|
||||
fs_enc = codecs.lookup(locale.getpreferredencoding()).name
|
||||
except Exception:
|
||||
fs_enc = 'ascii'
|
||||
if fs_enc != 'ascii':
|
||||
return
|
||||
|
||||
extra = ''
|
||||
if os.name == 'posix':
|
||||
import subprocess
|
||||
rv = subprocess.Popen(['locale', '-a'], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE).communicate()[0]
|
||||
good_locales = set()
|
||||
has_c_utf8 = False
|
||||
|
||||
# Make sure we're operating on text here.
|
||||
if isinstance(rv, bytes):
|
||||
rv = rv.decode('ascii', 'replace')
|
||||
|
||||
for line in rv.splitlines():
|
||||
locale = line.strip()
|
||||
if locale.lower().endswith(('.utf-8', '.utf8')):
|
||||
good_locales.add(locale)
|
||||
if locale.lower() in ('c.utf8', 'c.utf-8'):
|
||||
has_c_utf8 = True
|
||||
|
||||
extra += '\n\n'
|
||||
if not good_locales:
|
||||
extra += (
|
||||
'Additional information: on this system no suitable UTF-8\n'
|
||||
'locales were discovered. This most likely requires resolving\n'
|
||||
'by reconfiguring the locale system.'
|
||||
)
|
||||
elif has_c_utf8:
|
||||
extra += (
|
||||
'This system supports the C.UTF-8 locale which is recommended.\n'
|
||||
'You might be able to resolve your issue by exporting the\n'
|
||||
'following environment variables:\n\n'
|
||||
' export LC_ALL=C.UTF-8\n'
|
||||
' export LANG=C.UTF-8'
|
||||
)
|
||||
else:
|
||||
extra += (
|
||||
'This system lists a couple of UTF-8 supporting locales that\n'
|
||||
'you can pick from. The following suitable locales where\n'
|
||||
'discovered: %s'
|
||||
) % ', '.join(sorted(good_locales))
|
||||
|
||||
bad_locale = None
|
||||
for locale in os.environ.get('LC_ALL'), os.environ.get('LANG'):
|
||||
if locale and locale.lower().endswith(('.utf-8', '.utf8')):
|
||||
bad_locale = locale
|
||||
if locale is not None:
|
||||
break
|
||||
if bad_locale is not None:
|
||||
extra += (
|
||||
'\n\nClick discovered that you exported a UTF-8 locale\n'
|
||||
'but the locale system could not pick up from it because\n'
|
||||
'it does not exist. The exported locale is "%s" but it\n'
|
||||
'is not supported'
|
||||
) % bad_locale
|
||||
|
||||
raise RuntimeError('Click will abort further execution because Python 3 '
|
||||
'was configured to use ASCII as encoding for the '
|
||||
'environment. Consult http://click.pocoo.org/python3/'
|
||||
'for mitigation steps.' + extra)
|
||||
@@ -0,0 +1,273 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# This module is based on the excellent work by Adam Bartoš who
|
||||
# provided a lot of what went into the implementation here in
|
||||
# the discussion to issue1602 in the Python bug tracker.
|
||||
#
|
||||
# There are some general differences in regards to how this works
|
||||
# compared to the original patches as we do not need to patch
|
||||
# the entire interpreter but just work in our little world of
|
||||
# echo and prmopt.
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import zlib
|
||||
import time
|
||||
import ctypes
|
||||
import msvcrt
|
||||
from click._compat import _NonClosingTextIOWrapper, text_type, PY2
|
||||
from ctypes import byref, POINTER, c_int, c_char, c_char_p, \
|
||||
c_void_p, py_object, c_ssize_t, c_ulong, windll, WINFUNCTYPE
|
||||
try:
|
||||
from ctypes import pythonapi
|
||||
PyObject_GetBuffer = pythonapi.PyObject_GetBuffer
|
||||
PyBuffer_Release = pythonapi.PyBuffer_Release
|
||||
except ImportError:
|
||||
pythonapi = None
|
||||
from ctypes.wintypes import LPWSTR, LPCWSTR
|
||||
|
||||
|
||||
c_ssize_p = POINTER(c_ssize_t)
|
||||
|
||||
kernel32 = windll.kernel32
|
||||
GetStdHandle = kernel32.GetStdHandle
|
||||
ReadConsoleW = kernel32.ReadConsoleW
|
||||
WriteConsoleW = kernel32.WriteConsoleW
|
||||
GetLastError = kernel32.GetLastError
|
||||
GetCommandLineW = WINFUNCTYPE(LPWSTR)(
|
||||
('GetCommandLineW', windll.kernel32))
|
||||
CommandLineToArgvW = WINFUNCTYPE(
|
||||
POINTER(LPWSTR), LPCWSTR, POINTER(c_int))(
|
||||
('CommandLineToArgvW', windll.shell32))
|
||||
|
||||
|
||||
STDIN_HANDLE = GetStdHandle(-10)
|
||||
STDOUT_HANDLE = GetStdHandle(-11)
|
||||
STDERR_HANDLE = GetStdHandle(-12)
|
||||
|
||||
|
||||
PyBUF_SIMPLE = 0
|
||||
PyBUF_WRITABLE = 1
|
||||
|
||||
ERROR_SUCCESS = 0
|
||||
ERROR_NOT_ENOUGH_MEMORY = 8
|
||||
ERROR_OPERATION_ABORTED = 995
|
||||
|
||||
STDIN_FILENO = 0
|
||||
STDOUT_FILENO = 1
|
||||
STDERR_FILENO = 2
|
||||
|
||||
EOF = b'\x1a'
|
||||
MAX_BYTES_WRITTEN = 32767
|
||||
|
||||
|
||||
class Py_buffer(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('buf', c_void_p),
|
||||
('obj', py_object),
|
||||
('len', c_ssize_t),
|
||||
('itemsize', c_ssize_t),
|
||||
('readonly', c_int),
|
||||
('ndim', c_int),
|
||||
('format', c_char_p),
|
||||
('shape', c_ssize_p),
|
||||
('strides', c_ssize_p),
|
||||
('suboffsets', c_ssize_p),
|
||||
('internal', c_void_p)
|
||||
]
|
||||
|
||||
if PY2:
|
||||
_fields_.insert(-1, ('smalltable', c_ssize_t * 2))
|
||||
|
||||
|
||||
# On PyPy we cannot get buffers so our ability to operate here is
|
||||
# serverly limited.
|
||||
if pythonapi is None:
|
||||
get_buffer = None
|
||||
else:
|
||||
def get_buffer(obj, writable=False):
|
||||
buf = Py_buffer()
|
||||
flags = PyBUF_WRITABLE if writable else PyBUF_SIMPLE
|
||||
PyObject_GetBuffer(py_object(obj), byref(buf), flags)
|
||||
try:
|
||||
buffer_type = c_char * buf.len
|
||||
return buffer_type.from_address(buf.buf)
|
||||
finally:
|
||||
PyBuffer_Release(byref(buf))
|
||||
|
||||
|
||||
class _WindowsConsoleRawIOBase(io.RawIOBase):
|
||||
|
||||
def __init__(self, handle):
|
||||
self.handle = handle
|
||||
|
||||
def isatty(self):
|
||||
io.RawIOBase.isatty(self)
|
||||
return True
|
||||
|
||||
|
||||
class _WindowsConsoleReader(_WindowsConsoleRawIOBase):
|
||||
|
||||
def readable(self):
|
||||
return True
|
||||
|
||||
def readinto(self, b):
|
||||
bytes_to_be_read = len(b)
|
||||
if not bytes_to_be_read:
|
||||
return 0
|
||||
elif bytes_to_be_read % 2:
|
||||
raise ValueError('cannot read odd number of bytes from '
|
||||
'UTF-16-LE encoded console')
|
||||
|
||||
buffer = get_buffer(b, writable=True)
|
||||
code_units_to_be_read = bytes_to_be_read // 2
|
||||
code_units_read = c_ulong()
|
||||
|
||||
rv = ReadConsoleW(self.handle, buffer, code_units_to_be_read,
|
||||
byref(code_units_read), None)
|
||||
if GetLastError() == ERROR_OPERATION_ABORTED:
|
||||
# wait for KeyboardInterrupt
|
||||
time.sleep(0.1)
|
||||
if not rv:
|
||||
raise OSError('Windows error: %s' % GetLastError())
|
||||
|
||||
if buffer[0] == EOF:
|
||||
return 0
|
||||
return 2 * code_units_read.value
|
||||
|
||||
|
||||
class _WindowsConsoleWriter(_WindowsConsoleRawIOBase):
|
||||
|
||||
def writable(self):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _get_error_message(errno):
|
||||
if errno == ERROR_SUCCESS:
|
||||
return 'ERROR_SUCCESS'
|
||||
elif errno == ERROR_NOT_ENOUGH_MEMORY:
|
||||
return 'ERROR_NOT_ENOUGH_MEMORY'
|
||||
return 'Windows error %s' % errno
|
||||
|
||||
def write(self, b):
|
||||
bytes_to_be_written = len(b)
|
||||
buf = get_buffer(b)
|
||||
code_units_to_be_written = min(bytes_to_be_written,
|
||||
MAX_BYTES_WRITTEN) // 2
|
||||
code_units_written = c_ulong()
|
||||
|
||||
WriteConsoleW(self.handle, buf, code_units_to_be_written,
|
||||
byref(code_units_written), None)
|
||||
bytes_written = 2 * code_units_written.value
|
||||
|
||||
if bytes_written == 0 and bytes_to_be_written > 0:
|
||||
raise OSError(self._get_error_message(GetLastError()))
|
||||
return bytes_written
|
||||
|
||||
|
||||
class ConsoleStream(object):
|
||||
|
||||
def __init__(self, text_stream, byte_stream):
|
||||
self._text_stream = text_stream
|
||||
self.buffer = byte_stream
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.buffer.name
|
||||
|
||||
def write(self, x):
|
||||
if isinstance(x, text_type):
|
||||
return self._text_stream.write(x)
|
||||
try:
|
||||
self.flush()
|
||||
except Exception:
|
||||
pass
|
||||
return self.buffer.write(x)
|
||||
|
||||
def writelines(self, lines):
|
||||
for line in lines:
|
||||
self.write(line)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._text_stream, name)
|
||||
|
||||
def isatty(self):
|
||||
return self.buffer.isatty()
|
||||
|
||||
def __repr__(self):
|
||||
return '<ConsoleStream name=%r encoding=%r>' % (
|
||||
self.name,
|
||||
self.encoding,
|
||||
)
|
||||
|
||||
|
||||
def _get_text_stdin(buffer_stream):
|
||||
text_stream = _NonClosingTextIOWrapper(
|
||||
io.BufferedReader(_WindowsConsoleReader(STDIN_HANDLE)),
|
||||
'utf-16-le', 'strict', line_buffering=True)
|
||||
return ConsoleStream(text_stream, buffer_stream)
|
||||
|
||||
|
||||
def _get_text_stdout(buffer_stream):
|
||||
text_stream = _NonClosingTextIOWrapper(
|
||||
_WindowsConsoleWriter(STDOUT_HANDLE),
|
||||
'utf-16-le', 'strict', line_buffering=True)
|
||||
return ConsoleStream(text_stream, buffer_stream)
|
||||
|
||||
|
||||
def _get_text_stderr(buffer_stream):
|
||||
text_stream = _NonClosingTextIOWrapper(
|
||||
_WindowsConsoleWriter(STDERR_HANDLE),
|
||||
'utf-16-le', 'strict', line_buffering=True)
|
||||
return ConsoleStream(text_stream, buffer_stream)
|
||||
|
||||
|
||||
if PY2:
|
||||
def _hash_py_argv():
|
||||
return zlib.crc32('\x00'.join(sys.argv[1:]))
|
||||
|
||||
_initial_argv_hash = _hash_py_argv()
|
||||
|
||||
def _get_windows_argv():
|
||||
argc = c_int(0)
|
||||
argv_unicode = CommandLineToArgvW(GetCommandLineW(), byref(argc))
|
||||
argv = [argv_unicode[i] for i in range(0, argc.value)]
|
||||
|
||||
if not hasattr(sys, 'frozen'):
|
||||
argv = argv[1:]
|
||||
while len(argv) > 0:
|
||||
arg = argv[0]
|
||||
if not arg.startswith('-') or arg == '-':
|
||||
break
|
||||
argv = argv[1:]
|
||||
if arg.startswith(('-c', '-m')):
|
||||
break
|
||||
|
||||
return argv[1:]
|
||||
|
||||
|
||||
_stream_factories = {
|
||||
0: _get_text_stdin,
|
||||
1: _get_text_stdout,
|
||||
2: _get_text_stderr,
|
||||
}
|
||||
|
||||
|
||||
def _get_windows_console_stream(f, encoding, errors):
|
||||
if get_buffer is not None and \
|
||||
encoding in ('utf-16-le', None) \
|
||||
and errors in ('strict', None) and \
|
||||
hasattr(f, 'isatty') and f.isatty():
|
||||
func = _stream_factories.get(f.fileno())
|
||||
if func is not None:
|
||||
if not PY2:
|
||||
f = getattr(f, 'buffer')
|
||||
if f is None:
|
||||
return None
|
||||
else:
|
||||
# If we are on Python 2 we need to set the stream that we
|
||||
# deal with to binary mode as otherwise the exercise if a
|
||||
# bit moot. The same problems apply as for
|
||||
# get_binary_stdin and friends from _compat.
|
||||
msvcrt.setmode(f.fileno(), os.O_BINARY)
|
||||
return func(f)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
import sys
|
||||
import inspect
|
||||
|
||||
from functools import update_wrapper
|
||||
|
||||
from ._compat import iteritems
|
||||
from ._unicodefun import _check_for_unicode_literals
|
||||
from .utils import echo
|
||||
from .globals import get_current_context
|
||||
|
||||
|
||||
def pass_context(f):
|
||||
"""Marks a callback as wanting to receive the current context
|
||||
object as first argument.
|
||||
"""
|
||||
def new_func(*args, **kwargs):
|
||||
return f(get_current_context(), *args, **kwargs)
|
||||
return update_wrapper(new_func, f)
|
||||
|
||||
|
||||
def pass_obj(f):
|
||||
"""Similar to :func:`pass_context`, but only pass the object on the
|
||||
context onwards (:attr:`Context.obj`). This is useful if that object
|
||||
represents the state of a nested system.
|
||||
"""
|
||||
def new_func(*args, **kwargs):
|
||||
return f(get_current_context().obj, *args, **kwargs)
|
||||
return update_wrapper(new_func, f)
|
||||
|
||||
|
||||
def make_pass_decorator(object_type, ensure=False):
|
||||
"""Given an object type this creates a decorator that will work
|
||||
similar to :func:`pass_obj` but instead of passing the object of the
|
||||
current context, it will find the innermost context of type
|
||||
:func:`object_type`.
|
||||
|
||||
This generates a decorator that works roughly like this::
|
||||
|
||||
from functools import update_wrapper
|
||||
|
||||
def decorator(f):
|
||||
@pass_context
|
||||
def new_func(ctx, *args, **kwargs):
|
||||
obj = ctx.find_object(object_type)
|
||||
return ctx.invoke(f, obj, *args, **kwargs)
|
||||
return update_wrapper(new_func, f)
|
||||
return decorator
|
||||
|
||||
:param object_type: the type of the object to pass.
|
||||
:param ensure: if set to `True`, a new object will be created and
|
||||
remembered on the context if it's not there yet.
|
||||
"""
|
||||
def decorator(f):
|
||||
def new_func(*args, **kwargs):
|
||||
ctx = get_current_context()
|
||||
if ensure:
|
||||
obj = ctx.ensure_object(object_type)
|
||||
else:
|
||||
obj = ctx.find_object(object_type)
|
||||
if obj is None:
|
||||
raise RuntimeError('Managed to invoke callback without a '
|
||||
'context object of type %r existing'
|
||||
% object_type.__name__)
|
||||
return ctx.invoke(f, obj, *args[1:], **kwargs)
|
||||
return update_wrapper(new_func, f)
|
||||
return decorator
|
||||
|
||||
|
||||
def _make_command(f, name, attrs, cls):
|
||||
if isinstance(f, Command):
|
||||
raise TypeError('Attempted to convert a callback into a '
|
||||
'command twice.')
|
||||
try:
|
||||
params = f.__click_params__
|
||||
params.reverse()
|
||||
del f.__click_params__
|
||||
except AttributeError:
|
||||
params = []
|
||||
help = attrs.get('help')
|
||||
if help is None:
|
||||
help = inspect.getdoc(f)
|
||||
if isinstance(help, bytes):
|
||||
help = help.decode('utf-8')
|
||||
else:
|
||||
help = inspect.cleandoc(help)
|
||||
attrs['help'] = help
|
||||
_check_for_unicode_literals()
|
||||
return cls(name=name or f.__name__.lower(),
|
||||
callback=f, params=params, **attrs)
|
||||
|
||||
|
||||
def command(name=None, cls=None, **attrs):
|
||||
"""Creates a new :class:`Command` and uses the decorated function as
|
||||
callback. This will also automatically attach all decorated
|
||||
:func:`option`\s and :func:`argument`\s as parameters to the command.
|
||||
|
||||
The name of the command defaults to the name of the function. If you
|
||||
want to change that, you can pass the intended name as the first
|
||||
argument.
|
||||
|
||||
All keyword arguments are forwarded to the underlying command class.
|
||||
|
||||
Once decorated the function turns into a :class:`Command` instance
|
||||
that can be invoked as a command line utility or be attached to a
|
||||
command :class:`Group`.
|
||||
|
||||
:param name: the name of the command. This defaults to the function
|
||||
name.
|
||||
:param cls: the command class to instantiate. This defaults to
|
||||
:class:`Command`.
|
||||
"""
|
||||
if cls is None:
|
||||
cls = Command
|
||||
def decorator(f):
|
||||
cmd = _make_command(f, name, attrs, cls)
|
||||
cmd.__doc__ = f.__doc__
|
||||
return cmd
|
||||
return decorator
|
||||
|
||||
|
||||
def group(name=None, **attrs):
|
||||
"""Creates a new :class:`Group` with a function as callback. This
|
||||
works otherwise the same as :func:`command` just that the `cls`
|
||||
parameter is set to :class:`Group`.
|
||||
"""
|
||||
attrs.setdefault('cls', Group)
|
||||
return command(name, **attrs)
|
||||
|
||||
|
||||
def _param_memo(f, param):
|
||||
if isinstance(f, Command):
|
||||
f.params.append(param)
|
||||
else:
|
||||
if not hasattr(f, '__click_params__'):
|
||||
f.__click_params__ = []
|
||||
f.__click_params__.append(param)
|
||||
|
||||
|
||||
def argument(*param_decls, **attrs):
|
||||
"""Attaches an argument to the command. All positional arguments are
|
||||
passed as parameter declarations to :class:`Argument`; all keyword
|
||||
arguments are forwarded unchanged (except ``cls``).
|
||||
This is equivalent to creating an :class:`Argument` instance manually
|
||||
and attaching it to the :attr:`Command.params` list.
|
||||
|
||||
:param cls: the argument class to instantiate. This defaults to
|
||||
:class:`Argument`.
|
||||
"""
|
||||
def decorator(f):
|
||||
ArgumentClass = attrs.pop('cls', Argument)
|
||||
_param_memo(f, ArgumentClass(param_decls, **attrs))
|
||||
return f
|
||||
return decorator
|
||||
|
||||
|
||||
def option(*param_decls, **attrs):
|
||||
"""Attaches an option to the command. All positional arguments are
|
||||
passed as parameter declarations to :class:`Option`; all keyword
|
||||
arguments are forwarded unchanged (except ``cls``).
|
||||
This is equivalent to creating an :class:`Option` instance manually
|
||||
and attaching it to the :attr:`Command.params` list.
|
||||
|
||||
:param cls: the option class to instantiate. This defaults to
|
||||
:class:`Option`.
|
||||
"""
|
||||
def decorator(f):
|
||||
if 'help' in attrs:
|
||||
attrs['help'] = inspect.cleandoc(attrs['help'])
|
||||
OptionClass = attrs.pop('cls', Option)
|
||||
_param_memo(f, OptionClass(param_decls, **attrs))
|
||||
return f
|
||||
return decorator
|
||||
|
||||
|
||||
def confirmation_option(*param_decls, **attrs):
|
||||
"""Shortcut for confirmation prompts that can be ignored by passing
|
||||
``--yes`` as parameter.
|
||||
|
||||
This is equivalent to decorating a function with :func:`option` with
|
||||
the following parameters::
|
||||
|
||||
def callback(ctx, param, value):
|
||||
if not value:
|
||||
ctx.abort()
|
||||
|
||||
@click.command()
|
||||
@click.option('--yes', is_flag=True, callback=callback,
|
||||
expose_value=False, prompt='Do you want to continue?')
|
||||
def dropdb():
|
||||
pass
|
||||
"""
|
||||
def decorator(f):
|
||||
def callback(ctx, param, value):
|
||||
if not value:
|
||||
ctx.abort()
|
||||
attrs.setdefault('is_flag', True)
|
||||
attrs.setdefault('callback', callback)
|
||||
attrs.setdefault('expose_value', False)
|
||||
attrs.setdefault('prompt', 'Do you want to continue?')
|
||||
attrs.setdefault('help', 'Confirm the action without prompting.')
|
||||
return option(*(param_decls or ('--yes',)), **attrs)(f)
|
||||
return decorator
|
||||
|
||||
|
||||
def password_option(*param_decls, **attrs):
|
||||
"""Shortcut for password prompts.
|
||||
|
||||
This is equivalent to decorating a function with :func:`option` with
|
||||
the following parameters::
|
||||
|
||||
@click.command()
|
||||
@click.option('--password', prompt=True, confirmation_prompt=True,
|
||||
hide_input=True)
|
||||
def changeadmin(password):
|
||||
pass
|
||||
"""
|
||||
def decorator(f):
|
||||
attrs.setdefault('prompt', True)
|
||||
attrs.setdefault('confirmation_prompt', True)
|
||||
attrs.setdefault('hide_input', True)
|
||||
return option(*(param_decls or ('--password',)), **attrs)(f)
|
||||
return decorator
|
||||
|
||||
|
||||
def version_option(version=None, *param_decls, **attrs):
|
||||
"""Adds a ``--version`` option which immediately ends the program
|
||||
printing out the version number. This is implemented as an eager
|
||||
option that prints the version and exits the program in the callback.
|
||||
|
||||
:param version: the version number to show. If not provided Click
|
||||
attempts an auto discovery via setuptools.
|
||||
:param prog_name: the name of the program (defaults to autodetection)
|
||||
:param message: custom message to show instead of the default
|
||||
(``'%(prog)s, version %(version)s'``)
|
||||
:param others: everything else is forwarded to :func:`option`.
|
||||
"""
|
||||
if version is None:
|
||||
module = sys._getframe(1).f_globals.get('__name__')
|
||||
def decorator(f):
|
||||
prog_name = attrs.pop('prog_name', None)
|
||||
message = attrs.pop('message', '%(prog)s, version %(version)s')
|
||||
|
||||
def callback(ctx, param, value):
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
prog = prog_name
|
||||
if prog is None:
|
||||
prog = ctx.find_root().info_name
|
||||
ver = version
|
||||
if ver is None:
|
||||
try:
|
||||
import pkg_resources
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
for dist in pkg_resources.working_set:
|
||||
scripts = dist.get_entry_map().get('console_scripts') or {}
|
||||
for script_name, entry_point in iteritems(scripts):
|
||||
if entry_point.module_name == module:
|
||||
ver = dist.version
|
||||
break
|
||||
if ver is None:
|
||||
raise RuntimeError('Could not determine version')
|
||||
echo(message % {
|
||||
'prog': prog,
|
||||
'version': ver,
|
||||
}, color=ctx.color)
|
||||
ctx.exit()
|
||||
|
||||
attrs.setdefault('is_flag', True)
|
||||
attrs.setdefault('expose_value', False)
|
||||
attrs.setdefault('is_eager', True)
|
||||
attrs.setdefault('help', 'Show the version and exit.')
|
||||
attrs['callback'] = callback
|
||||
return option(*(param_decls or ('--version',)), **attrs)(f)
|
||||
return decorator
|
||||
|
||||
|
||||
def help_option(*param_decls, **attrs):
|
||||
"""Adds a ``--help`` option which immediately ends the program
|
||||
printing out the help page. This is usually unnecessary to add as
|
||||
this is added by default to all commands unless suppressed.
|
||||
|
||||
Like :func:`version_option`, this is implemented as eager option that
|
||||
prints in the callback and exits.
|
||||
|
||||
All arguments are forwarded to :func:`option`.
|
||||
"""
|
||||
def decorator(f):
|
||||
def callback(ctx, param, value):
|
||||
if value and not ctx.resilient_parsing:
|
||||
echo(ctx.get_help(), color=ctx.color)
|
||||
ctx.exit()
|
||||
attrs.setdefault('is_flag', True)
|
||||
attrs.setdefault('expose_value', False)
|
||||
attrs.setdefault('help', 'Show this message and exit.')
|
||||
attrs.setdefault('is_eager', True)
|
||||
attrs['callback'] = callback
|
||||
return option(*(param_decls or ('--help',)), **attrs)(f)
|
||||
return decorator
|
||||
|
||||
|
||||
# Circular dependencies between core and decorators
|
||||
from .core import Command, Group, Argument, Option
|
||||
@@ -0,0 +1,201 @@
|
||||
from ._compat import PY2, filename_to_ui, get_text_stderr
|
||||
from .utils import echo
|
||||
|
||||
|
||||
class ClickException(Exception):
|
||||
"""An exception that Click can handle and show to the user."""
|
||||
|
||||
#: The exit code for this exception
|
||||
exit_code = 1
|
||||
|
||||
def __init__(self, message):
|
||||
if PY2:
|
||||
if message is not None:
|
||||
message = message.encode('utf-8')
|
||||
Exception.__init__(self, message)
|
||||
self.message = message
|
||||
|
||||
def format_message(self):
|
||||
return self.message
|
||||
|
||||
def show(self, file=None):
|
||||
if file is None:
|
||||
file = get_text_stderr()
|
||||
echo('Error: %s' % self.format_message(), file=file)
|
||||
|
||||
|
||||
class UsageError(ClickException):
|
||||
"""An internal exception that signals a usage error. This typically
|
||||
aborts any further handling.
|
||||
|
||||
:param message: the error message to display.
|
||||
:param ctx: optionally the context that caused this error. Click will
|
||||
fill in the context automatically in some situations.
|
||||
"""
|
||||
exit_code = 2
|
||||
|
||||
def __init__(self, message, ctx=None):
|
||||
ClickException.__init__(self, message)
|
||||
self.ctx = ctx
|
||||
|
||||
def show(self, file=None):
|
||||
if file is None:
|
||||
file = get_text_stderr()
|
||||
color = None
|
||||
if self.ctx is not None:
|
||||
color = self.ctx.color
|
||||
echo(self.ctx.get_usage() + '\n', file=file, color=color)
|
||||
echo('Error: %s' % self.format_message(), file=file, color=color)
|
||||
|
||||
|
||||
class BadParameter(UsageError):
|
||||
"""An exception that formats out a standardized error message for a
|
||||
bad parameter. This is useful when thrown from a callback or type as
|
||||
Click will attach contextual information to it (for instance, which
|
||||
parameter it is).
|
||||
|
||||
.. versionadded:: 2.0
|
||||
|
||||
:param param: the parameter object that caused this error. This can
|
||||
be left out, and Click will attach this info itself
|
||||
if possible.
|
||||
:param param_hint: a string that shows up as parameter name. This
|
||||
can be used as alternative to `param` in cases
|
||||
where custom validation should happen. If it is
|
||||
a string it's used as such, if it's a list then
|
||||
each item is quoted and separated.
|
||||
"""
|
||||
|
||||
def __init__(self, message, ctx=None, param=None,
|
||||
param_hint=None):
|
||||
UsageError.__init__(self, message, ctx)
|
||||
self.param = param
|
||||
self.param_hint = param_hint
|
||||
|
||||
def format_message(self):
|
||||
if self.param_hint is not None:
|
||||
param_hint = self.param_hint
|
||||
elif self.param is not None:
|
||||
param_hint = self.param.opts or [self.param.human_readable_name]
|
||||
else:
|
||||
return 'Invalid value: %s' % self.message
|
||||
if isinstance(param_hint, (tuple, list)):
|
||||
param_hint = ' / '.join('"%s"' % x for x in param_hint)
|
||||
return 'Invalid value for %s: %s' % (param_hint, self.message)
|
||||
|
||||
|
||||
class MissingParameter(BadParameter):
|
||||
"""Raised if click required an option or argument but it was not
|
||||
provided when invoking the script.
|
||||
|
||||
.. versionadded:: 4.0
|
||||
|
||||
:param param_type: a string that indicates the type of the parameter.
|
||||
The default is to inherit the parameter type from
|
||||
the given `param`. Valid values are ``'parameter'``,
|
||||
``'option'`` or ``'argument'``.
|
||||
"""
|
||||
|
||||
def __init__(self, message=None, ctx=None, param=None,
|
||||
param_hint=None, param_type=None):
|
||||
BadParameter.__init__(self, message, ctx, param, param_hint)
|
||||
self.param_type = param_type
|
||||
|
||||
def format_message(self):
|
||||
if self.param_hint is not None:
|
||||
param_hint = self.param_hint
|
||||
elif self.param is not None:
|
||||
param_hint = self.param.opts or [self.param.human_readable_name]
|
||||
else:
|
||||
param_hint = None
|
||||
if isinstance(param_hint, (tuple, list)):
|
||||
param_hint = ' / '.join('"%s"' % x for x in param_hint)
|
||||
|
||||
param_type = self.param_type
|
||||
if param_type is None and self.param is not None:
|
||||
param_type = self.param.param_type_name
|
||||
|
||||
msg = self.message
|
||||
if self.param is not None:
|
||||
msg_extra = self.param.type.get_missing_message(self.param)
|
||||
if msg_extra:
|
||||
if msg:
|
||||
msg += '. ' + msg_extra
|
||||
else:
|
||||
msg = msg_extra
|
||||
|
||||
return 'Missing %s%s%s%s' % (
|
||||
param_type,
|
||||
param_hint and ' %s' % param_hint or '',
|
||||
msg and '. ' or '.',
|
||||
msg or '',
|
||||
)
|
||||
|
||||
|
||||
class NoSuchOption(UsageError):
|
||||
"""Raised if click attempted to handle an option that does not
|
||||
exist.
|
||||
|
||||
.. versionadded:: 4.0
|
||||
"""
|
||||
|
||||
def __init__(self, option_name, message=None, possibilities=None,
|
||||
ctx=None):
|
||||
if message is None:
|
||||
message = 'no such option: %s' % option_name
|
||||
UsageError.__init__(self, message, ctx)
|
||||
self.option_name = option_name
|
||||
self.possibilities = possibilities
|
||||
|
||||
def format_message(self):
|
||||
bits = [self.message]
|
||||
if self.possibilities:
|
||||
if len(self.possibilities) == 1:
|
||||
bits.append('Did you mean %s?' % self.possibilities[0])
|
||||
else:
|
||||
possibilities = sorted(self.possibilities)
|
||||
bits.append('(Possible options: %s)' % ', '.join(possibilities))
|
||||
return ' '.join(bits)
|
||||
|
||||
|
||||
class BadOptionUsage(UsageError):
|
||||
"""Raised if an option is generally supplied but the use of the option
|
||||
was incorrect. This is for instance raised if the number of arguments
|
||||
for an option is not correct.
|
||||
|
||||
.. versionadded:: 4.0
|
||||
"""
|
||||
|
||||
def __init__(self, message, ctx=None):
|
||||
UsageError.__init__(self, message, ctx)
|
||||
|
||||
|
||||
class BadArgumentUsage(UsageError):
|
||||
"""Raised if an argument is generally supplied but the use of the argument
|
||||
was incorrect. This is for instance raised if the number of values
|
||||
for an argument is not correct.
|
||||
|
||||
.. versionadded:: 6.0
|
||||
"""
|
||||
|
||||
def __init__(self, message, ctx=None):
|
||||
UsageError.__init__(self, message, ctx)
|
||||
|
||||
|
||||
class FileError(ClickException):
|
||||
"""Raised if a file cannot be opened."""
|
||||
|
||||
def __init__(self, filename, hint=None):
|
||||
ui_filename = filename_to_ui(filename)
|
||||
if hint is None:
|
||||
hint = 'unknown error'
|
||||
ClickException.__init__(self, hint)
|
||||
self.ui_filename = ui_filename
|
||||
self.filename = filename
|
||||
|
||||
def format_message(self):
|
||||
return 'Could not open file %s: %s' % (self.ui_filename, self.message)
|
||||
|
||||
|
||||
class Abort(RuntimeError):
|
||||
"""An internal signalling exception that signals Click to abort."""
|
||||
@@ -0,0 +1,256 @@
|
||||
from contextlib import contextmanager
|
||||
from .termui import get_terminal_size
|
||||
from .parser import split_opt
|
||||
from ._compat import term_len
|
||||
|
||||
|
||||
# Can force a width. This is used by the test system
|
||||
FORCED_WIDTH = None
|
||||
|
||||
|
||||
def measure_table(rows):
|
||||
widths = {}
|
||||
for row in rows:
|
||||
for idx, col in enumerate(row):
|
||||
widths[idx] = max(widths.get(idx, 0), term_len(col))
|
||||
return tuple(y for x, y in sorted(widths.items()))
|
||||
|
||||
|
||||
def iter_rows(rows, col_count):
|
||||
for row in rows:
|
||||
row = tuple(row)
|
||||
yield row + ('',) * (col_count - len(row))
|
||||
|
||||
|
||||
def wrap_text(text, width=78, initial_indent='', subsequent_indent='',
|
||||
preserve_paragraphs=False):
|
||||
"""A helper function that intelligently wraps text. By default, it
|
||||
assumes that it operates on a single paragraph of text but if the
|
||||
`preserve_paragraphs` parameter is provided it will intelligently
|
||||
handle paragraphs (defined by two empty lines).
|
||||
|
||||
If paragraphs are handled, a paragraph can be prefixed with an empty
|
||||
line containing the ``\\b`` character (``\\x08``) to indicate that
|
||||
no rewrapping should happen in that block.
|
||||
|
||||
:param text: the text that should be rewrapped.
|
||||
:param width: the maximum width for the text.
|
||||
:param initial_indent: the initial indent that should be placed on the
|
||||
first line as a string.
|
||||
:param subsequent_indent: the indent string that should be placed on
|
||||
each consecutive line.
|
||||
:param preserve_paragraphs: if this flag is set then the wrapping will
|
||||
intelligently handle paragraphs.
|
||||
"""
|
||||
from ._textwrap import TextWrapper
|
||||
text = text.expandtabs()
|
||||
wrapper = TextWrapper(width, initial_indent=initial_indent,
|
||||
subsequent_indent=subsequent_indent,
|
||||
replace_whitespace=False)
|
||||
if not preserve_paragraphs:
|
||||
return wrapper.fill(text)
|
||||
|
||||
p = []
|
||||
buf = []
|
||||
indent = None
|
||||
|
||||
def _flush_par():
|
||||
if not buf:
|
||||
return
|
||||
if buf[0].strip() == '\b':
|
||||
p.append((indent or 0, True, '\n'.join(buf[1:])))
|
||||
else:
|
||||
p.append((indent or 0, False, ' '.join(buf)))
|
||||
del buf[:]
|
||||
|
||||
for line in text.splitlines():
|
||||
if not line:
|
||||
_flush_par()
|
||||
indent = None
|
||||
else:
|
||||
if indent is None:
|
||||
orig_len = term_len(line)
|
||||
line = line.lstrip()
|
||||
indent = orig_len - term_len(line)
|
||||
buf.append(line)
|
||||
_flush_par()
|
||||
|
||||
rv = []
|
||||
for indent, raw, text in p:
|
||||
with wrapper.extra_indent(' ' * indent):
|
||||
if raw:
|
||||
rv.append(wrapper.indent_only(text))
|
||||
else:
|
||||
rv.append(wrapper.fill(text))
|
||||
|
||||
return '\n\n'.join(rv)
|
||||
|
||||
|
||||
class HelpFormatter(object):
|
||||
"""This class helps with formatting text-based help pages. It's
|
||||
usually just needed for very special internal cases, but it's also
|
||||
exposed so that developers can write their own fancy outputs.
|
||||
|
||||
At present, it always writes into memory.
|
||||
|
||||
:param indent_increment: the additional increment for each level.
|
||||
:param width: the width for the text. This defaults to the terminal
|
||||
width clamped to a maximum of 78.
|
||||
"""
|
||||
|
||||
def __init__(self, indent_increment=2, width=None, max_width=None):
|
||||
self.indent_increment = indent_increment
|
||||
if max_width is None:
|
||||
max_width = 80
|
||||
if width is None:
|
||||
width = FORCED_WIDTH
|
||||
if width is None:
|
||||
width = max(min(get_terminal_size()[0], max_width) - 2, 50)
|
||||
self.width = width
|
||||
self.current_indent = 0
|
||||
self.buffer = []
|
||||
|
||||
def write(self, string):
|
||||
"""Writes a unicode string into the internal buffer."""
|
||||
self.buffer.append(string)
|
||||
|
||||
def indent(self):
|
||||
"""Increases the indentation."""
|
||||
self.current_indent += self.indent_increment
|
||||
|
||||
def dedent(self):
|
||||
"""Decreases the indentation."""
|
||||
self.current_indent -= self.indent_increment
|
||||
|
||||
def write_usage(self, prog, args='', prefix='Usage: '):
|
||||
"""Writes a usage line into the buffer.
|
||||
|
||||
:param prog: the program name.
|
||||
:param args: whitespace separated list of arguments.
|
||||
:param prefix: the prefix for the first line.
|
||||
"""
|
||||
usage_prefix = '%*s%s ' % (self.current_indent, prefix, prog)
|
||||
text_width = self.width - self.current_indent
|
||||
|
||||
if text_width >= (term_len(usage_prefix) + 20):
|
||||
# The arguments will fit to the right of the prefix.
|
||||
indent = ' ' * term_len(usage_prefix)
|
||||
self.write(wrap_text(args, text_width,
|
||||
initial_indent=usage_prefix,
|
||||
subsequent_indent=indent))
|
||||
else:
|
||||
# The prefix is too long, put the arguments on the next line.
|
||||
self.write(usage_prefix)
|
||||
self.write('\n')
|
||||
indent = ' ' * (max(self.current_indent, term_len(prefix)) + 4)
|
||||
self.write(wrap_text(args, text_width,
|
||||
initial_indent=indent,
|
||||
subsequent_indent=indent))
|
||||
|
||||
self.write('\n')
|
||||
|
||||
def write_heading(self, heading):
|
||||
"""Writes a heading into the buffer."""
|
||||
self.write('%*s%s:\n' % (self.current_indent, '', heading))
|
||||
|
||||
def write_paragraph(self):
|
||||
"""Writes a paragraph into the buffer."""
|
||||
if self.buffer:
|
||||
self.write('\n')
|
||||
|
||||
def write_text(self, text):
|
||||
"""Writes re-indented text into the buffer. This rewraps and
|
||||
preserves paragraphs.
|
||||
"""
|
||||
text_width = max(self.width - self.current_indent, 11)
|
||||
indent = ' ' * self.current_indent
|
||||
self.write(wrap_text(text, text_width,
|
||||
initial_indent=indent,
|
||||
subsequent_indent=indent,
|
||||
preserve_paragraphs=True))
|
||||
self.write('\n')
|
||||
|
||||
def write_dl(self, rows, col_max=30, col_spacing=2):
|
||||
"""Writes a definition list into the buffer. This is how options
|
||||
and commands are usually formatted.
|
||||
|
||||
:param rows: a list of two item tuples for the terms and values.
|
||||
:param col_max: the maximum width of the first column.
|
||||
:param col_spacing: the number of spaces between the first and
|
||||
second column.
|
||||
"""
|
||||
rows = list(rows)
|
||||
widths = measure_table(rows)
|
||||
if len(widths) != 2:
|
||||
raise TypeError('Expected two columns for definition list')
|
||||
|
||||
first_col = min(widths[0], col_max) + col_spacing
|
||||
|
||||
for first, second in iter_rows(rows, len(widths)):
|
||||
self.write('%*s%s' % (self.current_indent, '', first))
|
||||
if not second:
|
||||
self.write('\n')
|
||||
continue
|
||||
if term_len(first) <= first_col - col_spacing:
|
||||
self.write(' ' * (first_col - term_len(first)))
|
||||
else:
|
||||
self.write('\n')
|
||||
self.write(' ' * (first_col + self.current_indent))
|
||||
|
||||
text_width = max(self.width - first_col - 2, 10)
|
||||
lines = iter(wrap_text(second, text_width).splitlines())
|
||||
if lines:
|
||||
self.write(next(lines) + '\n')
|
||||
for line in lines:
|
||||
self.write('%*s%s\n' % (
|
||||
first_col + self.current_indent, '', line))
|
||||
else:
|
||||
self.write('\n')
|
||||
|
||||
@contextmanager
|
||||
def section(self, name):
|
||||
"""Helpful context manager that writes a paragraph, a heading,
|
||||
and the indents.
|
||||
|
||||
:param name: the section name that is written as heading.
|
||||
"""
|
||||
self.write_paragraph()
|
||||
self.write_heading(name)
|
||||
self.indent()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.dedent()
|
||||
|
||||
@contextmanager
|
||||
def indentation(self):
|
||||
"""A context manager that increases the indentation."""
|
||||
self.indent()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.dedent()
|
||||
|
||||
def getvalue(self):
|
||||
"""Returns the buffer contents."""
|
||||
return ''.join(self.buffer)
|
||||
|
||||
|
||||
def join_options(options):
|
||||
"""Given a list of option strings this joins them in the most appropriate
|
||||
way and returns them in the form ``(formatted_string,
|
||||
any_prefix_is_slash)`` where the second item in the tuple is a flag that
|
||||
indicates if any of the option prefixes was a slash.
|
||||
"""
|
||||
rv = []
|
||||
any_prefix_is_slash = False
|
||||
for opt in options:
|
||||
prefix = split_opt(opt)[0]
|
||||
if prefix == '/':
|
||||
any_prefix_is_slash = True
|
||||
rv.append((len(prefix), opt))
|
||||
|
||||
rv.sort(key=lambda x: x[0])
|
||||
|
||||
rv = ', '.join(x[1] for x in rv)
|
||||
return rv, any_prefix_is_slash
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user